diff --git a/.gitignore b/.gitignore index 99e9b12..2947fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ 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 index 2a003d0..542dd88 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,24 +1,39 @@ env: - - MARATHONVERSION: 0.10.1 - - MARATHONVERSION: 0.11.1 - - MARATHONVERSION: 0.13.1 - - MARATHONVERSION: 0.14.1 - - MARATHONVERSION: 0.15.3 - - MARATHONVERSION: 1.1.1 + - 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: - - 2.7 - - 3.4 + - 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 - - ./itests/install-marathon.sh - - /etc/init.d/zookeeper start - - ./itests/start-marathon.sh & - - make itests + - 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 c43f28f..dc2946f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,294 @@ -# Change Log +# Changelog + +## [0.13.0](https://github.com/thefactory/marathon-python/tree/0.13.0) (2020-08-21) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.12.0...0.13.0) + +**Closed issues:** + +- about view one instance app logs real time [\#277](https://github.com/thefactory/marathon-python/issues/277) + +**Merged pull requests:** + +- 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)) + +## [0.12.0](https://github.com/thefactory/marathon-python/tree/0.12.0) (2019-11-14) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.11.0...0.12.0) + +**Closed issues:** + +- Downloading Log For App [\#265](https://github.com/thefactory/marathon-python/issues/265) + +**Merged pull requests:** + +- 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)) + +## [0.11.0](https://github.com/thefactory/marathon-python/tree/0.11.0) (2019-01-15) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.10.0...0.11.0) + +**Merged pull requests:** + +- 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.10.0](https://github.com/thefactory/marathon-python/tree/0.10.0) (2018-08-08) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.3...0.10.0) + +**Closed issues:** + +- 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) + +**Merged pull requests:** + +- 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.9.3](https://github.com/thefactory/marathon-python/tree/0.9.3) (2017-10-16) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.2...0.9.3) + +**Closed issues:** + +- `list\_queue` doesn't like the `embed\_last\_unused\_offers` option [\#220](https://github.com/thefactory/marathon-python/issues/220) + +**Merged pull requests:** + +- 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)) + +## [0.9.2](https://github.com/thefactory/marathon-python/tree/0.9.2) (2017-09-13) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.1...0.9.2) + +**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:** @@ -13,6 +301,7 @@ - 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:** @@ -29,8 +318,8 @@ - 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)) +- 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)) @@ -42,6 +331,7 @@ - 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:** @@ -61,6 +351,7 @@ - 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:** @@ -75,6 +366,7 @@ - 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:** @@ -89,6 +381,7 @@ - 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:** @@ -97,6 +390,7 @@ - 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:** @@ -116,23 +410,26 @@ - 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:** -- Use automatic changelog generation [\#69](https://github.com/thefactory/marathon-python/pull/69) ([solarkennedy](https://github.com/solarkennedy)) - 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:** @@ -146,6 +443,7 @@ - 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:** @@ -157,7 +455,7 @@ **Merged pull requests:** -- Updated to support Marathon 0.9.1 with get\_info\(\) calls [\#59](https://github.com/thefactory/marathon-python/pull/59) ([pyronicide](https://github.com/pyronicide)) +- 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)) @@ -169,6 +467,7 @@ - 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:** @@ -184,6 +483,7 @@ - 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:** @@ -191,6 +491,7 @@ - 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:** @@ -203,10 +504,11 @@ **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) ([g----](https://github.com/g----)) +- 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:** @@ -217,9 +519,11 @@ - 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:** @@ -227,6 +531,7 @@ - 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:** @@ -236,9 +541,11 @@ - 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:** @@ -250,6 +557,7 @@ - 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:** @@ -257,9 +565,11 @@ - 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:** @@ -267,6 +577,7 @@ - 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:** @@ -274,6 +585,7 @@ - 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:** @@ -281,9 +593,11 @@ - 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:** @@ -291,9 +605,11 @@ - 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:** @@ -301,6 +617,7 @@ - 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:** @@ -309,6 +626,7 @@ - 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:** @@ -316,12 +634,15 @@ - 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:** @@ -329,6 +650,7 @@ - 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:** @@ -336,15 +658,21 @@ - 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 Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file +\* *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 index 4ad278f..1d94d86 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,30 @@ -itests: - tox -e itests +itests: itests-py36 itests-py37 -test: - tox +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/* - github_changelog_generator .PHONY: itests test clean package publish diff --git a/README.md b/README.md index 3470cf7..d4e38cf 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,12 @@ This is a Python library for interfacing with [Marathon](https://github.com/meso #### Compatibility -* For Marathon 1.1.1 and 0.15.x, use at least 0.8.1 -* For Marathon 0.14.x, use at least 0.7.6 -* For Marathon 0.8.x-0.11.x, use at least marathon-python 0.7.5 -* For Marathon 0.8.x-0.9.x, use as least marathon-python 0.6.11 - 0.7.4 -* For Marathon 0.7.x, use at least marathon-python 0.6.10 +* 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` -Note: Not all versions of Python are tested against every version of Marathon. - 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. @@ -55,7 +52,7 @@ make itests ### Running The Tests Against a Specific Version of Marathon ```bash -MARATHONVERSION=0.9.0 make itests +MARATHONVERSION=v1.6.322 make itests ``` ## Documentation 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/Dockerfile b/itests/Dockerfile deleted file mode 100644 index 5135abb..0000000 --- a/itests/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM ubuntu:14.04 -RUN apt-get install -y software-properties-common -RUN add-apt-repository ppa:webupd8team/java -RUN echo "debconf shared/accepted-oracle-license-v1-1 select true" | debconf-set-selections -RUN echo "debconf shared/accepted-oracle-license-v1-1 seen true" | debconf-set-selections -RUN apt-get update && apt-get -y install lsb-release oracle-java8-installer - -# Setup -ADD ./marathon-version /root/marathon-version -ADD ./install-marathon.sh /root/install-marathon.sh -RUN /root/install-marathon.sh - -EXPOSE 8080 -ADD ./start-marathon.sh /root/start-marathon.sh -CMD /etc/init.d/zookeeper start && /root/start-marathon.sh diff --git a/itests/docker-compose.yml b/itests/docker-compose.yml deleted file mode 100644 index 13a0062..0000000 --- a/itests/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -marathon: - build: . - ports: - - 8080 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/install-marathon.sh b/itests/install-marathon.sh deleted file mode 100755 index 4c44f79..0000000 --- a/itests/install-marathon.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -vxeu - -# Default version of marathon to test against if not set by the user -[[ -f /root/marathon-version ]] && source /root/marathon-version -MARATHONVERSION="${MARATHONVERSION:-0.8.2}" - -sudo apt-get update -q - -# Setup -sudo apt-key adv --keyserver keyserver.ubuntu.com --recv E56151BF -DISTRO=$(lsb_release -is | tr '[:upper:]' '[:lower:]') -CODENAME=$(lsb_release -cs) - -# Add the repository -echo "deb http://repos.mesosphere.com/${DISTRO} ${CODENAME} main" | - sudo tee /etc/apt/sources.list.d/mesosphere.list -sudo apt-get -y update - -# Install packages -sudo apt-get -y install oracle-java8-installer -sudo apt-get -y purge oracle-java7-installer -sudo update-java-alternatives -s java-8-oracle -sudo apt-get install oracle-java8-set-default - -sudo apt-get -y --force-yes install mesos=0.23.* marathon=$MARATHONVERSION* - -# WTF MARATHON? -# Why does the precise version have java7 hardcoded if it requires java8? -sudo mkdir -p /usr/lib/jvm/java-7-oracle/bin/ -sudo ln -s /usr/lib/jvm/java-8-oracle/bin/java /usr/lib/jvm/java-7-oracle/bin/java 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 index ed54055..0ec865e 100644 --- a/itests/itest_utils.py +++ b/itests/itest_utils.py @@ -2,12 +2,9 @@ from functools import wraps import os import signal -import sys -import re import time import requests -from compose.cli import command class TimeoutError(Exception): @@ -38,7 +35,7 @@ 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 + print('Connecting to marathon on %s' % marathon_service) try: response = requests.get( 'http://%s/ping' % marathon_service, timeout=2) @@ -49,34 +46,13 @@ def wait_for_marathon(): time.sleep(2) continue if response.status_code == 200: - print "Marathon is up and running!" + print("Marathon is up and running!") break -def get_compose_service(service_name): - """Returns a compose object for the service""" - cmd = command.Command() - project = cmd.get_project(cmd.get_config_path()) - return project.get_service(service_name) - - def get_marathon_connection_string(): # only reliable way I can detect travis.. if '/travis/' in os.environ.get('PATH'): return 'localhost:8080' else: - service_port = get_service_internal_port('marathon') - local_port = get_compose_service('marathon').get_container().get_local_port(service_port) - - # Check if we're at OSX. Use ip from DOCKER_HOST - if sys.platform == 'darwin': - m = re.match("(.*?)://(.*?):(\d+)", os.environ["DOCKER_HOST"]) - local_port = "{}:{}".format(m.group(2), local_port.split(":")[1]) - - return local_port - - -def get_service_internal_port(service_name): - """Gets the exposed port for service_name from docker-compose.yml. If there are - multiple ports. It returns the first one.""" - return get_compose_service(service_name).options['ports'][0] + return "localhost:18080" diff --git a/itests/start-marathon.sh b/itests/start-marathon.sh deleted file mode 100755 index 6c21fa2..0000000 --- a/itests/start-marathon.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -if [[ $MARATHONVERSION != '0.8.1' ]]; then - LOGGER="--no-logger" -else - LOGGER="" -fi - -java -version -exec /usr/bin/marathon --master local $LOGGER --hostname localhost diff --git a/itests/steps/marathon_steps.py b/itests/steps/marathon_steps.py index 9c460a2..131c199 100644 --- a/itests/steps/marathon_steps.py +++ b/itests/steps/marathon_steps.py @@ -1,7 +1,7 @@ import sys import time import multiprocessing -from distutils.version import StrictVersion +from distutils.version import LooseVersion import marathon from behave import given, when, then @@ -20,18 +20,18 @@ def working_marathon(context): context.client = marathon.MarathonClient(marathon_connection_string) -@then(u'we get the marathon instance\'s info') +@then('we get the marathon instance\'s info') def get_marathon_info(context): assert context.client.get_info() -@when(u'we create a trivial new app') +@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(u'we should be able to kill the tasks') +@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') @@ -40,7 +40,7 @@ def kill_a_task(context): app_id='test-trivial-app', task_id=tasks[0].id, scale=True) -@when(u'we create a complex new app') +@when('we create a complex new app') def create_complex_new_app_with_unicode(context): app_config = { 'container': { @@ -51,13 +51,13 @@ def create_complex_new_app_with_unicode(context): 'name': 'myport', 'containerPort': 8888, 'hostPort': 0}], - 'image': u'localhost/fake_docker_url', + 'image': 'localhost/fake_docker_url', 'network': 'BRIDGE', 'parameters': [{'key': 'add-host', 'value': 'google-public-dns-a.google.com:8.8.8.8'}], }, 'volumes': - [{'hostPath': u'/etc/stuff', - 'containerPath': u'/etc/stuff', + [{'hostPath': '/etc/stuff', + 'containerPath': '/etc/stuff', 'mode': 'RO'}], }, 'instances': 1, @@ -68,7 +68,7 @@ def create_complex_new_app_with_unicode(context): 'uris': ['file:///root/.dockercfg'], 'backoff_seconds': 1, 'constraints': None, - 'cmd': u'/bin/true', + 'cmd': '/bin/true', 'health_checks': [ { 'protocol': 'HTTP', @@ -85,13 +85,13 @@ def create_complex_new_app_with_unicode(context): 'test-complex-app', marathon.MarathonApp(**app_config)) -@then(u'we should see the {which} app running via the marathon api') +@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(u'we wait the {which} app deployment finish') +@when('we wait the {which} app deployment finish') def wait_deployment_finish(context, which): while True: time.sleep(1) @@ -100,7 +100,7 @@ def wait_deployment_finish(context, which): break -@then(u'we should be able to kill the #{to_kill} tasks of the {which} app') +@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 @@ -111,11 +111,11 @@ def kill_tasks(context, to_kill, which): context.client.kill_given_tasks(task_to_kill) -@then(u'we should be able to list tasks of the {which} app') +@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 + assert len(tasks) == app.instances, "we defined {} tasks, got {} tasks".format(app.instances, len(tasks)) def listen_for_events(client, events): @@ -123,14 +123,14 @@ def listen_for_events(client, events): events.append(msg) -@when(u'marathon version is greater than {version}') +@when('marathon version is greater than {version}') def marathon_version_chech(context, version): info = context.client.get_info() - if StrictVersion(info.version) < StrictVersion(version): + if LooseVersion(info.version) < LooseVersion(version): context.scenario.skip(reason='Marathon version is too low for this scenario') -@when(u'we start listening for events') +@when('we start listening for events') def start_listening_stream(context): manager = multiprocessing.Manager() mlist = manager.list() @@ -141,26 +141,14 @@ def start_listening_stream(context): context.p = p -@then(u'we should see list of events') +@then('we should see list of events') def stop_listening_stream(context): time.sleep(10) context.p.terminate() - - print(context.events) - - # event list should contain 5 status_update_event with taskStatus == TASK_RUNNING - filtered_events = [e for e in context.events if e.event_type == "status_update_event" and e.task_status == "TASK_RUNNING"] - assert len(filtered_events) == 5 - - # and 1 status_update_event with taskStatus == TASK_KILLED - filtered_events = [e for e in context.events if e.event_type == "status_update_event" and e.task_status == "TASK_KILLED"] - assert len(filtered_events) == 1 - - # and 2 deployment_step_success events - filtered_events = [e for e in context.events if e.event_type == "deployment_success"] - assert len(filtered_events) == 2 + 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): - assert len(context.client.list_deployments()) == 1 + 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 8d2975c..fdb6e47 100644 --- a/marathon/__init__.py +++ b/marathon/__init__.py @@ -1,7 +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__) +log = get_log() diff --git a/marathon/_compat.py b/marathon/_compat.py deleted file mode 100644 index b2c19ff..0000000 --- a/marathon/_compat.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Support for python 2 & 3, ripped pieces from six.py -""" -import sys - -PY3 = sys.version_info[0] == 3 - -if PY3: - string_types = str, -else: - string_types = basestring, diff --git a/marathon/client.py b/marathon/client.py index 32f3870..26c7914 100644 --- a/marathon/client.py +++ b/marathon/client.py @@ -8,18 +8,22 @@ import requests import requests.exceptions +from requests_toolbelt.adapters import socket_options import marathon from .models import MarathonApp, MarathonDeployment, MarathonGroup, MarathonInfo, MarathonTask, MarathonEndpoint, MarathonQueueItem -from .exceptions import InternalServerError, NotFoundError, MarathonHttpError, MarathonError -from .models.events import EventFactory +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(object): +class MarathonClient: """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 @@ -30,13 +34,33 @@ 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 """ - self.session = requests.Session() + 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 @@ -54,6 +78,10 @@ def _do_request(self, method, path, params=None, data=None): """Query Marathon server.""" 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: @@ -62,54 +90,65 @@ def _do_request(self, method, path, params=None, data=None): try: response = self.session.request( method, url, params=params, data=data, headers=headers, - auth=self.auth, timeout=self.timeout) + auth=self.auth, timeout=self.timeout, verify=self.verify) marathon.log.info('Got response from %s', server) 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)) + 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)) + 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)) + 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)) + code=response.status_code, body=response.text.encode('utf-8'))) return response - def _do_sse_request(self, path, params=None, data=None): - from sseclient import SSEClient - - headers = {'Accept': 'text/event-stream'} - messages = None - servers = list(self.servers) - while servers and messages is None: - server = servers.pop(0) - url = ''.join([server.rstrip('/'), path]) + 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: - messages = SSEClient(url, params=params, data=data, headers=headers, - auth=self.auth) + # 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) - - if messages is None: - raise MarathonError('No remaining Marathon servers to try') + 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() - return messages + raise MarathonError('No remaining Marathon servers to try') def list_endpoints(self): """List the current endpoints for all applications @@ -119,17 +158,19 @@ def list_endpoints(self): """ 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) @@ -139,7 +180,7 @@ def create_app(self, app_id, app): 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, **kwargs): + embed_task_stats=False, app_id=None, label=None, **kwargs): """List all apps. :param str cmd: if passed, only show apps with a matching `cmd` @@ -150,7 +191,8 @@ def list_apps(self, cmd=None, embed_tasks=False, embed_counts=False, :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 bool app_id: if passed, only show apps with with an 'id' that matches or contains this value + :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 @@ -161,6 +203,8 @@ def list_apps(self, cmd=None, embed_tasks=False, embed_counts=False, params['cmd'] = cmd if app_id: params['id'] = app_id + if label: + params['label'] = label embed_params = { 'app.tasks': embed_tasks, @@ -215,7 +259,7 @@ def get_app(self, app_id, embed_tasks=False, embed_counts=False, params['embed'] = filtered_embed_params response = self._do_request( - 'GET', '/v2/apps/{app_id}'.format(app_id=app_id), params=params) + 'GET', f'/v2/apps/{app_id}', params=params) return self._parse_response(response, MarathonApp, resource_name='app') def restart_app(self, app_id, force=False): @@ -228,7 +272,7 @@ def restart_app(self, app_id, force=False): """ params = {'force': force} response = self._do_request( - 'POST', '/v2/apps/{app_id}/restart'.format(app_id=app_id), params=params) + 'POST', f'/v2/apps/{app_id}/restart', params=params) return response.json() def update_app(self, app_id, app, force=False, minimal=True): @@ -253,7 +297,33 @@ def update_app(self, app_id, app, force=False, 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) + '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): @@ -269,7 +339,7 @@ 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) + 'PUT', f'/v2/apps/{app_id}', params=params, data=data) return response.json() def delete_app(self, app_id, force=False): @@ -283,7 +353,7 @@ def delete_app(self, app_id, force=False): """ params = {'force': force} response = self._do_request( - 'DELETE', '/v2/apps/{app_id}'.format(app_id=app_id), params=params) + 'DELETE', f'/v2/apps/{app_id}', params=params) return response.json() def scale_app(self, app_id, instances=None, delta=None, force=False): @@ -310,7 +380,7 @@ 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 ( @@ -353,7 +423,7 @@ def get_group(self, group_id): :rtype: :class:`marathon.models.group.MarathonGroup` """ response = self._do_request( - 'GET', '/v2/groups/{group_id}'.format(group_id=group_id)) + 'GET', f'/v2/groups/{group_id}') return self._parse_response(response, MarathonGroup) def update_group(self, group_id, group, force=False, minimal=True): @@ -378,7 +448,7 @@ def update_group(self, group_id, group, force=False, 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) + 'PUT', f'/v2/groups/{group_id}', data=data, params=params) return response.json() def rollback_group(self, group_id, version, force=False): @@ -394,7 +464,8 @@ def rollback_group(self, group_id, version, force=False): params = {'force': force} response = self._do_request( 'PUT', - '/v2/groups/{group_id}/versions/{version}'.format(group_id=group_id, version=version), + '/v2/groups/{group_id}/versions/{version}'.format( + group_id=group_id, version=version), params=params) return response.json() @@ -409,7 +480,7 @@ def delete_group(self, group_id, force=False): """ params = {'force': force} response = self._do_request( - 'DELETE', '/v2/groups/{group_id}'.format(group_id=group_id), params=params) + 'DELETE', f'/v2/groups/{group_id}', params=params) return response.json() def scale_group(self, group_id, scale_by): @@ -421,9 +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} + data = {'scaleBy': scale_by} response = self._do_request( - 'PUT', '/v2/groups/{group_id}'.format(group_id=group_id), params=params) + 'PUT', f'/v2/groups/{group_id}', data=json.dumps(data)) return response.json() def list_tasks(self, app_id=None, **kwargs): @@ -435,13 +506,10 @@ def list_tasks(self, app_id=None, **kwargs): :returns: list of tasks :rtype: list[:class:`marathon.models.task.MarathonTask`] """ - response = self._do_request('GET', '/v2/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') - if app_id: - tasks = [ - task for task in tasks if task.app_id.lstrip('/') == app_id.lstrip('/')] - [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(): @@ -465,7 +533,7 @@ def kill_given_tasks(self, task_ids, scale=False, force=None): data = json.dumps({"ids": task_ids}) response = self._do_request( 'POST', '/v2/tasks/delete', params=params, data=data) - return response == 200 + return response.status_code == 200 def kill_tasks(self, app_id, scale=False, wipe=False, host=None, batch_size=0, batch_delay=0): @@ -492,7 +560,7 @@ def batch(iterable, size): if host: params['host'] = host response = self._do_request( - 'DELETE', '/v2/apps/{app_id}/tasks'.format(app_id=app_id), params) + '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 @@ -510,12 +578,12 @@ def batch(iterable, size): # Pause until the tasks have been killed to avoid race # conditions - killed_task_ids = set(t.id for t in killed_tasks) + 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 @@ -560,7 +628,7 @@ def list_versions(self, app_id): :rtype: list[str] """ response = self._do_request( - 'GET', '/v2/apps/{app_id}/versions'.format(app_id=app_id)) + 'GET', f'/v2/apps/{app_id}/versions') return [version for version in response.json()['versions']] def get_version(self, app_id, version): @@ -618,13 +686,17 @@ def list_deployments(self): response = self._do_request('GET', '/v2/deployments') return self._parse_response(response, MarathonDeployment, is_list=True) - def list_queue(self): + 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`] """ - response = self._do_request('GET', '/v2/queue') + 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): @@ -645,9 +717,14 @@ def delete_deployment(self, deployment_id, force=False): return {} else: response = self._do_request( - 'DELETE', '/v2/deployments/{deployment}'.format(deployment=deployment_id)) + '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. @@ -682,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 @@ -693,18 +770,37 @@ def get_metrics(self): response = self._do_request('GET', '/metrics') return response.json() - def event_stream(self): + 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 """ - messages = self._do_sse_request('/v2/events') - ef = EventFactory() - for message in messages: - if not message.data: - continue - data = json.loads(message.data) - yield ef.process(data) + + 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 e5cf597..0889cb9 100644 --- a/marathon/exceptions.py +++ b/marathon/exceptions.py @@ -9,11 +9,12 @@ def __init__(self, response): :param :class:`requests.Response` response: HTTP response """ self.error_message = response.reason or '' - if response.content: + 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 - super(MarathonHttpError, self).__init__(self.__str__()) + super().__init__(self.__str__()) def __repr__(self): return 'MarathonHttpError: HTTP %s returned with message, "%s"' % \ @@ -31,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 ) ) + + +class NoResponseError(MarathonError): + pass diff --git a/marathon/models/app.py b/marathon/models/app.py index 281ee19..e290fd9 100644 --- a/marathon/models/app.py +++ b/marathon/models/app.py @@ -1,10 +1,13 @@ -from datetime import datetime - +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): @@ -31,9 +34,11 @@ 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 @@ -63,12 +68,15 @@ class MarathonApp(MarathonResource): :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""" @@ -79,15 +87,17 @@ class MarathonApp(MarathonResource): 'deployments', 'tasks', 'tasks_running', 'tasks_staged', 'tasks_healthy', 'tasks_unhealthy'] """List of read-only attributes""" + 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, 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, tasks_healthy=None, - task_kill_grace_period_seconds=None, tasks_unhealthy=None, upgrade_strategy=None, - uris=None, user=None, version=None, version_info=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,): + 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 @@ -112,8 +122,9 @@ def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=None, 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( @@ -121,7 +132,12 @@ def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=None, for hc in (health_checks or []) ] 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) @@ -160,6 +176,10 @@ def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=None, 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 @@ -168,6 +188,10 @@ def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=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): @@ -190,7 +214,21 @@ class MarathonHealthCheck(MarathonObject): 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, ignore_http1xx=None, **kwargs): - self.command = command + + 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 @@ -212,24 +250,23 @@ class MarathonTaskFailure(MarathonObject): :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, + 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 @@ -248,6 +285,42 @@ def __init__(self, maximum_over_capacity=None, 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. @@ -259,17 +332,9 @@ class MarathonAppVersionInfo(MarathonObject): :param str host: mesos slave running the task """ - DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' - def __init__(self, last_scaling_at=None, last_config_change_at=None): - self.last_scaling_at = self._to_datetime(last_scaling_at) - self.last_config_change_at = self._to_datetime(last_config_change_at) - - def _to_datetime(self, timestamp): - if (timestamp is None or isinstance(timestamp, datetime)): - return timestamp - else: - return datetime.strptime(timestamp, self.DATETIME_FORMAT) + self.last_scaling_at = to_datetime(last_scaling_at) + self.last_config_change_at = to_datetime(last_config_change_at) class MarathonTaskStats(MarathonObject): @@ -389,7 +454,7 @@ class ReadinessCheck(MarathonObject): """ 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): + http_status_codes_for_ready=None, preserve_last_response=None, timeout_seconds=None): self.name = name self.protocol = protocol self.path = path @@ -397,6 +462,7 @@ def __init__(self, name=None, protocol=None, path=None, port_name=None, interval 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): diff --git a/marathon/models/base.py b/marathon/models/base.py index 719b35d..39aba17 100644 --- a/marathon/models/base.py +++ b/marathon/models/base.py @@ -4,15 +4,23 @@ 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(minimal=False)) def __eq__(self, other): - return self.__dict__ == other.__dict__ + 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. @@ -53,15 +61,25 @@ class MarathonResource(MarathonObject): def __repr__(self): if 'id' in list(vars(self).keys()): - return "{clazz}::{id}".format(clazz=self.__class__.__name__, id=self.id) + return f"{self.__class__.__name__}::{self.id}" else: return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json()) def __eq__(self, other): - return self.__dict__ == other.__dict__ + 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 "{clazz}::".format(clazz=self.__class__.__name__) + str(self.__dict__) + 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 diff --git a/marathon/models/constraint.py b/marathon/models/constraint.py index 184d9f8..cbbde87 100644 --- a/marathon/models/constraint.py +++ b/marathon/models/constraint.py @@ -1,4 +1,3 @@ -from ..exceptions import InvalidChoiceError from .base import MarathonObject @@ -16,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 operator not in self.OPERATORS: - raise InvalidChoiceError('operator', operator, self.OPERATORS) self.field = field self.operator = operator self.value = value @@ -59,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 3ef4291..0ec267a 100644 --- a/marathon/models/container.py +++ b/marathon/models/container.py @@ -11,19 +11,33 @@ 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): + 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) @@ -46,10 +60,10 @@ class MarathonDockerContainer(MarathonObject): :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: @@ -80,10 +94,10 @@ class MarathonContainerPortMapping(MarathonObject): :param object labels: """ - PROTOCOLS = ['tcp', 'udp'] + PROTOCOLS = ['tcp', 'udp', 'udp,tcp'] """Valid protocols""" - def __init__(self, name=None, container_port=None, host_port=0, service_port=None, protocol='tcp', labels=None): + 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 @@ -104,14 +118,16 @@ class MarathonContainerVolume(MarathonObject): :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'] - def __init__(self, container_path=None, host_path=None, mode='RW', persistent=None): + 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 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 b93bf40..7a98c85 100644 --- a/marathon/models/deployment.py +++ b/marathon/models/deployment.py @@ -1,4 +1,4 @@ -from .base import MarathonObject, MarathonResource +from .base import MarathonObject, MarathonResource, assert_valid_path class MarathonDeployment(MarathonResource): @@ -17,14 +17,15 @@ 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, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(a) for a in (current_actions or []) ] self.current_step = current_step @@ -32,6 +33,7 @@ def __init__(self, affected_apps=None, current_actions=None, current_step=None, 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: @@ -39,7 +41,7 @@ def parse_deployment_step(self, step): 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] + return [s if isinstance(s, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(s) for s in step] else: return step @@ -56,10 +58,11 @@ class MarathonDeploymentAction(MarathonObject): :param type readiness_check_results: Undocumented """ - def __init__(self, action=None, app=None, apps=None, type=None, readiness_check_results=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.apps = apps + 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?" @@ -84,20 +87,22 @@ def __init__(self, actions=None): class MarathonDeploymentOriginalState(MarathonObject): def __init__(self, dependencies=None, - apps=None, id=None, version=None, groups=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): + 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/events.py b/marathon/models/events.py index 14e7b38..a0b3d4d 100644 --- a/marathon/models/events.py +++ b/marathon/models/events.py @@ -1,10 +1,13 @@ """ This module is used to translate Events from Marathon's EventBus system. -See: https://mesosphere.github.io/marathon/docs/event-bus.html +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 @@ -19,7 +22,11 @@ class MarathonEvent(MarathonObject): KNOWN_ATTRIBUTES = [] attribute_name_to_marathon_object = { # Allows embedding of MarathonObjects inside events. 'health_check': MarathonHealthCheck, - 'plan': MarathonDeploymentPlan + 'plan': MarathonDeploymentPlan, + 'ip_address': MarathonIpAddress, + } + seq_name_to_singular = { + 'ip_addresses': 'ip_address', } def __init__(self, event_type, timestamp, **kwargs): @@ -28,13 +35,25 @@ def __init__(self, event_type, timestamp, **kwargs): 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 - if attribute_name in self.attribute_name_to_marathon_object: - clazz = self.attribute_name_to_marathon_object[attribute_name] - attribute = clazz.from_json( - attribute) # If this attribute already has a Marathon object instantiate it. + # 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) @@ -44,7 +63,7 @@ class MarathonApiPostEvent(MarathonEvent): class MarathonStatusUpdateEvent(MarathonEvent): KNOWN_ATTRIBUTES = [ - 'slave_id', 'task_id', 'task_status', 'app_id', 'host', 'ports', 'version', 'message'] + 'slave_id', 'task_id', 'task_status', 'app_id', 'host', 'ports', 'version', 'message', 'ip_addresses'] class MarathonFrameworkMessageEvent(MarathonEvent): @@ -68,11 +87,11 @@ class MarathonRemoveHealthCheckEvent(MarathonEvent): class MarathonFailedHealthCheckEvent(MarathonEvent): - KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id'] + KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id'] class MarathonHealthStatusChangedEvent(MarathonEvent): - KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'alive'] + KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id', 'alive'] class MarathonGroupChangeSuccess(MarathonEvent): @@ -92,7 +111,7 @@ class MarathonDeploymentFailed(MarathonEvent): class MarathonDeploymentInfo(MarathonEvent): - KNOWN_ATTRIBUTES = ['plan'] + KNOWN_ATTRIBUTES = ['plan', 'current_step'] class MarathonDeploymentStepSuccess(MarathonEvent): @@ -111,6 +130,42 @@ 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: """ @@ -131,6 +186,7 @@ def __init__(self): '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, @@ -140,12 +196,22 @@ def __init__(self): '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('Unknown event_type: {}'.format(event_type)) + raise MarathonError(f'Unknown event_type: {event_type}, data: {event}') diff --git a/marathon/models/group.py b/marathon/models/group.py index 40cf6bf..f68e230 100644 --- a/marathon/models/group.py +++ b/marathon/models/group.py @@ -1,4 +1,4 @@ -from .base import MarathonResource, assert_valid_id +from .base import MarathonResource from .app import MarathonApp @@ -14,11 +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): + 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 []) @@ -28,5 +30,12 @@ def __init__(self, apps=None, dependencies=None, g if isinstance(g, MarathonGroup) else MarathonGroup().from_json(g) for g in (groups or []) ] - self.id = assert_valid_id(id) + 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 872f8f2..68622a3 100644 --- a/marathon/models/info.py +++ b/marathon/models/info.py @@ -21,10 +21,11 @@ class MarathonInfo(MarathonResource): :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, elected=None, zookeeper_config=None): + 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: @@ -43,6 +44,7 @@ def __init__(self, event_subscriber=None, framework_id=None, http_config=None, l 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): @@ -62,23 +64,60 @@ class MarathonConfig(MarathonObject): :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, 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, master=None, mesos_leader_ui_url=None, mesos_role=None, mesos_user=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): + 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 @@ -87,16 +126,39 @@ def __init__(self, checkpoint=None, executor=None, failover_timeout=None, framew 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): @@ -113,16 +175,28 @@ class MarathonZooKeeperConfig(MarathonObject): :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_max_versions=None, zk_path=None, - zk_session_timeout=None, zk_state=None, zk_timeout=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): diff --git a/marathon/models/queue.py b/marathon/models/queue.py index 5f14af8..d53fd70 100644 --- a/marathon/models/queue.py +++ b/marathon/models/queue.py @@ -25,13 +25,17 @@ class MarathonQueueItem(MarathonResource): :param bool overdue: """ - def __init__(self, app=None, overdue=None, count=None, delay=None): + 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): diff --git a/marathon/models/task.py b/marathon/models/task.py index fad8808..0348c6a 100644 --- a/marathon/models/task.py +++ b/marathon/models/task.py @@ -1,6 +1,5 @@ -from datetime import datetime - from .base import MarathonResource, MarathonObject +from ..util import to_datetime class MarathonTask(MarathonResource): @@ -21,12 +20,19 @@ class MarathonTask(MarathonResource): :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, - slave_id=None, staged_at=None, started_at=None, version=None, ip_addresses=[], state=None, local_volumes=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 = [ @@ -39,10 +45,8 @@ def __init__(self, app_id=None, health_check_results=None, host=None, id=None, p self.ports = ports or [] self.service_ports = service_ports or [] self.slave_id = slave_id - 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.staged_at = to_datetime(staged_at) + self.started_at = to_datetime(started_at) self.state = state self.version = version self.ip_addresses = [ @@ -50,6 +54,9 @@ def __init__(self, app_id=None, health_check_results=None, host=None, id=None, p 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): @@ -73,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 917c0b8..af2932e 100644 --- a/marathon/util.py +++ b/marathon/util.py @@ -1,5 +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 logging try: import json @@ -7,11 +14,9 @@ import simplejson as json import re -from ._compat import string_types - -def is_stringy(obj): - return isinstance(obj, string_types) +def get_log(): + return logging.getLogger(__name__.split('.')[0]) class MarathonJsonEncoder(json.JSONEncoder): @@ -25,7 +30,7 @@ def default(self, obj): if isinstance(obj, datetime.datetime): return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - if isinstance(obj, collections.Iterable) and not is_stringy(obj): + if isinstance(obj, collections.Iterable) and not isinstance(obj, str): try: return {k: self.default(v) for k, v in obj.items()} except AttributeError: @@ -45,7 +50,7 @@ def default(self, obj): if isinstance(obj, datetime.datetime): return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - if isinstance(obj, collections.Iterable) and not is_stringy(obj): + 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: @@ -56,9 +61,27 @@ def default(self, 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() + + +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 index cb1d49e..c20f36f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -requests-mock -sseclient +requests==2.20.0 diff --git a/setup.py b/setup.py index 653e935..fa25a98 100755 --- a/setup.py +++ b/setup.py @@ -8,12 +8,12 @@ setup( name='marathon', - version='0.8.4', + 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.0.0', 'sseclient'], + install_requires=['requests>=2.4.0', 'requests-toolbelt>=0.4.0'], url='https://github.com/thefactory/marathon-python', packages=['marathon', 'marathon.models'], license='MIT', @@ -24,10 +24,9 @@ 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python', 'Topic :: Software Development :: Libraries :: Python Modules' ], diff --git a/tests/test_api.py b/tests/test_api.py index 72f6b4a..3b0ec7d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,8 +3,7 @@ from marathon import models -@requests_mock.mock() -def test_get_deployments_pre_1_0(m): +def test_get_deployments_pre_1_0(): fake_response = """[ { "affectedApps": [ @@ -30,26 +29,26 @@ def test_get_deployments_pre_1_0(m): "totalSteps": 1 } ]""" - 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=u"fakeid", - steps=[ - [models.MarathonDeploymentAction( - action="ScaleApplication", app="/test")]], - current_actions=[models.MarathonDeploymentAction( - action="ScaleApplication", app="/test")], - current_step=1, - total_steps=1, - affected_apps=[u"/test"], - version=u"fakeversion" - )] - assert expected_deployments == actual_deployments + 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 -@requests_mock.mock() -def test_get_deployments_post_1_0(m): +def test_get_deployments_post_1_0(): fake_response = """[ { "id": "4d2ff4d8-fbe5-4239-a886-f0831ed68d20", @@ -86,94 +85,47 @@ def test_get_deployments_post_1_0(m): "totalSteps": 2 } ]""" - 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=u"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=[u"/test-trivial-app"], - version=u"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 + 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 -@requests_mock.mock() -def test_list_tasks_with_app_id(m): +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" }, { "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" } ] }' - m.get('http://fake_server/v2/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 - - -@requests_mock.mock() -def test_list_tasks_without_app_id(m): - 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" } ] }' - 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( + '"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( @@ -196,25 +148,68 @@ def test_list_tasks_without_app_id(m): 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 + 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 index c0529b8..300705a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,50 +1,42 @@ [tox] passenv = TRAVIS usedevelop=True -basepython = python2.7 -envlist = py,pep8 +envlist={test,itest}-{py36,py37},pep8 -[testenv:itests] +[testenv] passenv = TRAVIS MARATHONVERSION DOCKER_HOST DOCKER_TLS_VERIFY DOCKER_CERT_PATH DOCKER_MACHINE_NAME -basepython = python2.7 +basepython = + py36: python3.6 + py37: python3.7 whitelist_externals=/bin/bash skipsdist=True -changedir=itests/ +changedir = + test: {toxinidir} + itest: {toxinidir}/itests/ deps = - requests<2.7 - {[testenv]deps} - docker-compose==1.3.1 + -rrequirements.txt + requests-mock==1.0.0 + docker-compose behave + pytest mock commands = - /bin/bash -c "[[ -n $TRAVIS ]] || echo MARATHONVERSION=$MARATHONVERSION > marathon-version" - /bin/bash -c "[[ -n $TRAVIS ]] || docker-compose build" - /bin/bash -c "[[ -n $TRAVIS ]] || docker-compose pull" - /bin/bash -c "[[ -n $TRAVIS ]] || docker-compose up -d" - behave {posargs} - /bin/bash -c "[[ -n $TRAVIS ]] || docker-compose stop" - /bin/bash -c "[[ -n $TRAVIS ]] || docker-compose rm --force" - -[testenv] -usedevelop=True -basepython = python2.7 -install_command = pip install --upgrade {opts} {packages} -deps = -rrequirements.txt - -[testenv:py] -recreate=True -basepython = python2.7 -deps = - {[testenv]deps} - pytest - mock -commands = - py.test -s -vv {posargs:tests} + 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