├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── bin ├── chronos-nagios.py └── chronos-sync-jobs.py ├── chronos └── __init__.py ├── itests ├── chronos-python.feature ├── docker-compose.yml ├── environment.py ├── itest_utils.py └── steps │ └── chronos_steps.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── tests └── test_chronos_init.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.swp 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | .idea 38 | .cache 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: "3.6" 3 | cache: pip 4 | os: linux 5 | sudo: required 6 | env: 7 | - TOXENV=py27 8 | - TOXENV=py36 9 | - TOXENV=itests CHRONOSVERSION=2.4.0 10 | - TOXENV=itests CHRONOSVERSION=3.0.2 11 | - TOXENV=itests-py3 CHRONOSVERSION=2.4.0 12 | - TOXENV=itests-py3 CHRONOSVERSION=3.0.2 13 | services: 14 | - docker 15 | before_install: 16 | - docker-compose -f itests/docker-compose.yml pull 17 | install: pip install tox 18 | script: tox -i https://pypi.python.org/simple 19 | 20 | deploy: 21 | - provider: pypi 22 | user: yelplabs 23 | password: 24 | secure: "Im92teoC11SVnrDjrMGe/KMbXkzwDRh7kpaP8EfByQfqSR7T2ON2BbZngpQp0nc7qn6QKo2HNcAA0M2nnMDCIm+b0XMvgYxpPbFdzzDRI7jiNlgbZmdMtGU3scR88dGmO+Yct2Xau5zo3CPuVgvXE0Qr9wXRV5NDwHu7w3eciKXwAaPeX9pt76x9nlLpf8S0le5OCRObQbxzTve2pWC5BhoLFucP2Azx1auv9WKbL4qN7i0iG/VGQCS0mpQE+sr6AJCsIMTfFvRTEvCgFXyeCjwemHm/Rt0I3HIVkOnDj7Okl4bBSoVxZ0Y5kYFHrFNZ9ld69gNVV0cQRNGFVYCU5hMw+y5Xmut6Erf28BfPUuzsKgim6LQoyAe7FRXx3MKQoNiQtsQp9r3atMaWKrvTwwsXva+nshWjnY0UlQYAr4wzxK7q26gA5UPlIp39DYIfnYe/JWFxCEd+HYUqylM6npg8drPPBqAKDNUnYE1aKawXEsCOZDsQ2T965t10BAnMR3vrh/zIS9H8MAeDg2GzJOzQvDIvhXNQ2ybgZDefUmyEDoYMA4xQMcSQMtJ+XboEmvsXJdpwMJcCfZ7NpQUbL4MLSxYeedppDhKCOdhlGd8tN3WYK2L/ENVmTaioOb4C9q1c+hGs5sNbnQXzucBIwLSTMo4ZJUbwb9TCT9+brkw=" 25 | on: 26 | tags: true 27 | repo: asher/chronos-python 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.0.0](https://github.com/asher/chronos-python/tree/1.0.0) (2017-04-22) 4 | [Full Changelog](https://github.com/asher/chronos-python/compare/v0.38.0...1.0.0) 5 | 6 | **Closed issues:** 7 | 8 | - Library doesn't recognize leader change [\#38](https://github.com/asher/chronos-python/issues/38) 9 | 10 | **Merged pull requests:** 11 | 12 | - Make a hierarchy of exceptions [\#36](https://github.com/asher/chronos-python/pull/36) ([theosotr](https://github.com/theosotr)) 13 | - Initial support for chronos 3.x for \#33 [\#35](https://github.com/asher/chronos-python/pull/35) ([thekad](https://github.com/thekad)) 14 | - Added travis deployment to pypi. Fixes \#27 [\#28](https://github.com/asher/chronos-python/pull/28) ([solarkennedy](https://github.com/solarkennedy)) 15 | 16 | ## [v0.38.0](https://github.com/asher/chronos-python/tree/v0.38.0) (2016-11-16) 17 | [Full Changelog](https://github.com/asher/chronos-python/compare/v0.37.0...v0.38.0) 18 | 19 | **Closed issues:** 20 | 21 | - Setup travis to push packages to pypi [\#27](https://github.com/asher/chronos-python/issues/27) 22 | 23 | **Merged pull requests:** 24 | 25 | - Generalize auth token [\#32](https://github.com/asher/chronos-python/pull/32) ([hylandm](https://github.com/hylandm)) 26 | - epsilon is not required field of job definition [\#31](https://github.com/asher/chronos-python/pull/31) ([xkrt](https://github.com/xkrt)) 27 | - container required fields validation [\#30](https://github.com/asher/chronos-python/pull/30) ([xkrt](https://github.com/xkrt)) 28 | 29 | ## [v0.37.0](https://github.com/asher/chronos-python/tree/v0.37.0) (2016-09-29) 30 | [Full Changelog](https://github.com/asher/chronos-python/compare/v0.36.0...v0.37.0) 31 | 32 | **Merged pull requests:** 33 | 34 | - Add metrics endpoint [\#26](https://github.com/asher/chronos-python/pull/26) ([Rob-Johnson](https://github.com/Rob-Johnson)) 35 | 36 | ## [v0.36.0](https://github.com/asher/chronos-python/tree/v0.36.0) (2016-09-13) 37 | **Closed issues:** 38 | 39 | - Client doesn't deal with unauthorized access correctly [\#17](https://github.com/asher/chronos-python/issues/17) 40 | - parent job [\#16](https://github.com/asher/chronos-python/issues/16) 41 | - new version in pypi [\#12](https://github.com/asher/chronos-python/issues/12) 42 | - An issue with connect method [\#2](https://github.com/asher/chronos-python/issues/2) 43 | - Can't find the package on PyPi [\#1](https://github.com/asher/chronos-python/issues/1) 44 | 45 | **Merged pull requests:** 46 | 47 | - retry other chronos servers on connection errors [\#24](https://github.com/asher/chronos-python/pull/24) ([Rob-Johnson](https://github.com/Rob-Johnson)) 48 | - Add method to get undocumented job graph [\#23](https://github.com/asher/chronos-python/pull/23) ([keshavdv](https://github.com/keshavdv)) 49 | - Fix typo in README [\#22](https://github.com/asher/chronos-python/pull/22) ([keshavdv](https://github.com/keshavdv)) 50 | - Python 3 support [\#21](https://github.com/asher/chronos-python/pull/21) ([navidurrahman](https://github.com/navidurrahman)) 51 | - raise an exception when unauthorized [\#19](https://github.com/asher/chronos-python/pull/19) ([Rob-Johnson](https://github.com/Rob-Johnson)) 52 | - Update docs chronos [\#18](https://github.com/asher/chronos-python/pull/18) ([Rob-Johnson](https://github.com/Rob-Johnson)) 53 | - fail if more than one of the 'one-of' parameters exist [\#15](https://github.com/asher/chronos-python/pull/15) ([Rob-Johnson](https://github.com/Rob-Johnson)) 54 | - release 0.34.0 [\#13](https://github.com/asher/chronos-python/pull/13) ([Rob-Johnson](https://github.com/Rob-Johnson)) 55 | - add /job/stat/{job\_name} and /scheduler/stats/\* endpoints [\#11](https://github.com/asher/chronos-python/pull/11) ([Rob-Johnson](https://github.com/Rob-Johnson)) 56 | - Deal with chronos jobs that have spaces in the name [\#10](https://github.com/asher/chronos-python/pull/10) ([solarkennedy](https://github.com/solarkennedy)) 57 | - Added to pypi [\#8](https://github.com/asher/chronos-python/pull/8) ([keshavdv](https://github.com/keshavdv)) 58 | - Allow a list of servers to be passed in to the client [\#7](https://github.com/asher/chronos-python/pull/7) ([keshavdv](https://github.com/keshavdv)) 59 | - Run itests in a docker container [\#6](https://github.com/asher/chronos-python/pull/6) ([keshavdv](https://github.com/keshavdv)) 60 | - Added unit test framework [\#5](https://github.com/asher/chronos-python/pull/5) ([solarkennedy](https://github.com/solarkennedy)) 61 | - Added basic itest framework with travis integration [\#4](https://github.com/asher/chronos-python/pull/4) ([solarkennedy](https://github.com/solarkennedy)) 62 | - Correctly pass in username and password in the connect method [\#3](https://github.com/asher/chronos-python/pull/3) ([solarkennedy](https://github.com/solarkennedy)) 63 | 64 | 65 | 66 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Asher Feldman 4 | Derived in part from work by Chris Zacharias (chris@imgix.com) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | tox 4 | 5 | .PHONY: itests 6 | itests: 7 | tox -e itests 8 | tox -e itests-py3 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chronos-python 2 | 3 | [![Build Status](https://travis-ci.org/asher/chronos-python.svg?branch=master)](https://travis-ci.org/asher/chronos-python) 4 | 5 | This is a Python client library for the [Chronos](https://mesos.github.io/chronos/docs/api.html) HTTP [Rest API](https://mesos.github.io/chronos/docs/api.html) 6 | 7 | ## Installation 8 | 9 | pip install chronos-python 10 | # or 11 | git clone git@github.com/asher/chronos-python 12 | python setup.py install 13 | 14 | ## Usage Examples 15 | 16 | 17 | Create a ``ChronosClient`` 18 | 19 | >>> import chronos 20 | >>> client = chronos.connect("chronos.mesos.server.com:8080") 21 | # or specify multilple servers that will be tried in order 22 | >>> client = chronos.connect(["chronos1.mesos.server.com:8080", "chronos2.mesos.server.com:8080"]) 23 | 24 | List all jobs: 25 | 26 | >>> client.list() 27 | [{u'softError': False, u'scheduleTimeZone': u'null', u'successCount': 702, u'cpus': 0.25, u'disabled': False, u'ownerName': u'', u'owner': u'noop', u'disk': 256.0, u'errorCount': 0, u'container': {u'image': u'my-docker-registry:443/myimage', u'type': u'docker', u'network': u'BRIDGE', u'volumes': []}, u'errorsSinceLastSuccess': 0, u'highPriority': False, u'dataProcessingJobType': False, u'arguments': [], u'uris': [u'file:///root/.dockercfg'], u'shell': True, u'description': u'', u'schedule': u'R/2015-12-18T10:40:00.000Z/PT10M', u'mem': 1024.0, u'epsilon': u'PT60S', u'retries': 2, u'name': u'my job 1', u'runAsUser': u'root', u'lastSuccess': u'2015-12-18T10:30:09.755Z', u'environmentVariables': [], u'executorFlags': u'', u'command': u'sensu-scheduled-canary.sh', u'executor': u'', u'async': False, u'lastError': u'', u'constraints': []}, {u'softError': False, u'scheduleTimeZone': u'null', u'successCount': 40, u'cpus': 0.25, u'disabled': False, u'ownerName': u'', u'owner': u'noop', u'disk': 256.0, u'errorCount': 0, u'container': {u'image': u'my-docker-regsitry:443/myimage', u'type': u'docker', u'network': u'BRIDGE', u'volumes': [], u'errorsSinceLastSuccess': 0, u'highPriority': False, u'dataProcessingJobType': False, u'arguments': [], u'uris': [u'file:///root/.dockercfg'], u'shell': True, u'description': u'', u'schedule': u'R/2015-12-18T11:00:00.000Z/PT60M', u'mem': 1024.0, u'epsilon': u'PT60S', u'retries': 2, u'name': u'example_service mesosstage_kwabatch gitfb0c7ac5 config95bc9b2f', u'runAsUser': u'root', u'lastSuccess': u'2015-12-18T08:00:12.965Z', u'environmentVariables': [], u'executorFlags': u'', u'command': u'echo "This batch should run once per hour, and take 2 hours" && sleep 2h', u'executor': u'', u'async': False, u'lastError': u'', u'constraints': []}] 28 | 29 | 30 | Add a new job: 31 | 32 | >>> job = { 'async': False, 'command': 'echo 1', 'epsilon': 'PT15M', 'name': 'foo', 33 | 'owner': 'me@foo.com', 'disabled': True, 'schedule': 'R/2014-01-01T00:00:00Z/PT60M'} 34 | >>> client.add(job) 35 | 36 | Update an existing job: 37 | 38 | >>> job = { 'async': False, 'command': 'echo 1', 'epsilon': 'PT15M', 'name': 'foo', 39 | 'owner': 'me@foo.com', 'disabled': True, 'schedule': 'R/2014-01-01T00:00:00Z/PT60M'} 40 | >>> client.update(job) 41 | 42 | Run a job: 43 | 44 | >>> client.run("job123") 45 | 46 | Delete a job: 47 | 48 | >>> client.delete("job123") 49 | 50 | Delete all the in flight tasks for a job: 51 | 52 | 53 | >>> client.delete_tasks("job123") 54 | 55 | 56 | ## Included Scripts 57 | * `chronos-sync-jobs.py` - Sync chronos jobs from a directory tree containing job.json files. 58 | `chronos-sync-jobs.py --hostname chronos.server.com:4400 --sync /path/to/job.json/files` 59 | 60 | * `chronos-nagios.py` - Nagios/Icinga style monitor of jobs 61 | `chronos-nagios.py --hostname chronos.server.com:4400 --crit 3 --prefix etl. --prefix data.` 62 | `chronos-nagios.py --hostname chronos.server.com:4400 --crit 3 --exclude etl.` 63 | 64 | ## Testing 65 | 66 | ``chronos-python`` uses Travis to test against multiple versions of Chronos. You can run the tests locally on any machine 67 | with [Docker](https://www.docker.com/) and [Docker compose](https://docs.docker.com/compose/) on it. 68 | 69 | To run the tests: 70 | 71 | make itests 72 | 73 | To run against a different version of Chronos: 74 | 75 | CHRONOSVERSION=3.0.2 make itests 76 | -------------------------------------------------------------------------------- /bin/chronos-nagios.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2014 Asher Feldman 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | # this software and associated documentation files (the "Software"), to deal in 9 | # the Software without restriction, including without limitation the rights to 10 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | # the Software, and to permit persons to whom the Software is furnished to do so, 12 | # subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | from __future__ import print_function 24 | 25 | import sys 26 | import re 27 | import argparse 28 | import logging 29 | import chronos 30 | 31 | 32 | def match_prefix(prefixes=[], job=''): 33 | for prefix in prefixes: 34 | if re.search('^' + prefix, job): 35 | return True 36 | return False 37 | 38 | 39 | def main(): 40 | parser = argparse.ArgumentParser(description="Monitor the status of Chronos Jobs") 41 | parser.add_argument("--hostname", metavar="", required=True, 42 | help="hostname and port of the Chronos instance") 43 | parser.add_argument("--prefix", metavar="job-prefix", required=False, action="append", 44 | help="if set, only check jobs matching this prefix") 45 | parser.add_argument("--exclude", metavar="job-prefix", required=False, action="append", 46 | help="if set, exclude jobs matching this prefix") 47 | parser.add_argument("--warn", metavar="#", default=1, 48 | help="warn if at least this number of jobs are currently failed") 49 | parser.add_argument("--crit", metavar="#", default=1, 50 | help="critical if at least this number of jobs are currently failed") 51 | args = parser.parse_args() 52 | 53 | fails = [] 54 | ok = [] 55 | unknown = [] 56 | 57 | c = chronos.connect(args.hostname) 58 | cjobs = c.list() 59 | 60 | if not isinstance(cjobs, list): 61 | print("UNKNOWN: error querying chronos") 62 | sys.exit(3) 63 | 64 | for job in cjobs: 65 | if job['disabled']: 66 | continue 67 | 68 | if isinstance(args.prefix, list): 69 | if not match_prefix(args.prefix, job['name']): 70 | continue 71 | 72 | if isinstance(args.exclude, list): 73 | if match_prefix(args.exclude, job['name']): 74 | continue 75 | 76 | if job['lastError'] > job['lastSuccess']: 77 | fails.append(job['name'].encode('ascii')) 78 | elif job['lastSuccess']: 79 | ok.append(job['name'].encode('ascii')) 80 | else: 81 | unknown.append(job['name'].encode('ascii')) 82 | 83 | if len(unknown) > 0: 84 | umsg = "(%d waiting for execution or with no data)" % len(unknown) 85 | else: 86 | umsg = '' 87 | 88 | if len(fails) == 0: 89 | print("OK: %d jobs succeeded on last run %s" % (len(ok), umsg)) 90 | sys.exit(0) 91 | elif len(fails) >= int(args.crit): 92 | print("CRITICAL: %d failed jobs: %s %s" % (len(fails), str(fails).strip('[]'), umsg)) 93 | sys.exit(2) 94 | elif len(fails) >= int(args.warn): 95 | print("WARNING: %d failed jobs: %s %s" % (len(fails), str(fails).strip('[]'), umsg)) 96 | sys.exit(1) 97 | 98 | 99 | if __name__ == "__main__": 100 | logging.basicConfig(format='%(asctime)s %(levelname)-8s %(message)s', level="WARN") 101 | main() 102 | -------------------------------------------------------------------------------- /bin/chronos-sync-jobs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2014 Asher Feldman 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | # this software and associated documentation files (the "Software"), to deal in 9 | # the Software without restriction, including without limitation the rights to 10 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | # the Software, and to permit persons to whom the Software is furnished to do so, 12 | # subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | from __future__ import print_function 24 | 25 | import os 26 | import sys 27 | import re 28 | import argparse 29 | import json 30 | import logging 31 | import chronos 32 | 33 | 34 | def read_job_file(path): 35 | f = open(path, "r") 36 | try: 37 | job = json.loads(f.read()) 38 | if "name" in job: 39 | return job 40 | except: 41 | print("Error: failed to decode %s" % path) 42 | 43 | 44 | def find_json_files(path): 45 | """find /path -name *.json""" 46 | job_files = [] 47 | for root, dirs, files in os.walk(path): 48 | for file in files: 49 | if re.search(r"json$", file): 50 | job_files.append(root + '/' + file) 51 | return sorted(job_files) 52 | 53 | 54 | def check_update(jobs, job): 55 | """Return True if the job definition on Chronos != local json config""" 56 | on_chronos = jobs[job['name']] 57 | for key in job.keys(): 58 | if key in on_chronos: 59 | if on_chronos[key] != job[key]: 60 | return True 61 | else: 62 | return True 63 | return False 64 | 65 | 66 | def main(): 67 | parser = argparse.ArgumentParser(description="Tool for syncing Chronos jobs from local .json files") 68 | parser.add_argument("--hostname", metavar="", required=True, 69 | help="hostname and port of the Chronos instance") 70 | group = parser.add_mutually_exclusive_group(required=True) 71 | group.add_argument("--sync", metavar="/path/to/dir", 72 | help="path to a directory containing json files describing chronos jobs. \ 73 | All sub-directories will be searched for files ending in .json") 74 | group.add_argument("--list", action="store_true", help="list jobs on chronos") 75 | parser.add_argument("-n", action="store_true", default=False, 76 | help="dry-run, don't actually push anything to chronos") 77 | args = parser.parse_args() 78 | 79 | c = chronos.connect(args.hostname) 80 | cjobs = c.list() 81 | 82 | if args.list: 83 | # cjobs isn't json but this still gets us the pretty 84 | print(json.dumps(cjobs, sort_keys=True, indent=4)) 85 | sys.exit(0) 86 | 87 | if args.sync: 88 | jobs = {} 89 | retry = {'update': [], 'add': []} 90 | for job in cjobs: 91 | jobs[job["name"]] = job 92 | 93 | if not os.path.isdir(args.sync): 94 | raise Exception("%s must be a directory" % args.sync) 95 | 96 | job_files = find_json_files(args.sync) 97 | for file in job_files: 98 | job = read_job_file(file) 99 | if not job: 100 | print("Skipping %s" % file) 101 | else: 102 | if job['name'] in jobs: 103 | if check_update(jobs, job): 104 | print("Updating job %s from file %s" % (job['name'], file)) 105 | if not args.n: 106 | try: 107 | c.update(job) 108 | except: 109 | retry['update'].append(job) 110 | else: 111 | print( 112 | "Job %s defined in %s is up-to-date on Chronos" 113 | % (job['name'], file) 114 | ) 115 | else: 116 | print("Adding job %s from file %s" % (job['name'], file)) 117 | if not args.n: 118 | try: 119 | c.add(job) 120 | except: 121 | retry['add'].append(job) 122 | 123 | attempt = 0 124 | while (len(retry['update']) > 0 or len(retry['add']) > 0) and attempt < 10: 125 | attempt += 1 126 | if len(retry['update']) > 0: 127 | job = retry['update'].pop(0) 128 | try: 129 | print("Retry %d for job %s" % (attempt, job['name'])) 130 | c.update(job) 131 | except: 132 | retry['update'].append(job) 133 | 134 | if len(retry['add']) > 0: 135 | job = retry['add'].pop(0) 136 | try: 137 | print("Retry %d for job %s" % (attempt, job['name'])) 138 | c.add(job) 139 | except: 140 | retry['add'].append(job) 141 | 142 | if len(retry['update']) > 0 or len(retry['add']) > 0: 143 | print("Failed Jobs: %s" % sorted((retry['update'] + retry['add']))) 144 | 145 | 146 | if __name__ == "__main__": 147 | logging.basicConfig(format='%(asctime)s %(levelname)-8s %(message)s', level="WARN") 148 | main() 149 | -------------------------------------------------------------------------------- /chronos/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2014 Asher Feldman 6 | # Derived in part from work by Chris Zacharias (chris@imgix.com) 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 9 | # this software and associated documentation files (the "Software"), to deal in 10 | # the Software without restriction, including without limitation the rights to 11 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 12 | # the Software, and to permit persons to whom the Software is furnished to do so, 13 | # subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 20 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 21 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 22 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | import httplib2 26 | import socket 27 | import json 28 | import logging 29 | 30 | # Python 3 changed the submodule for quote 31 | try: 32 | from urllib import quote 33 | except ImportError: 34 | from urllib.parse import quote 35 | 36 | # also load urlencode 37 | try: 38 | from urllib import urlencode 39 | except ImportError: 40 | from urllib.parse import urlencode 41 | 42 | SCHEDULER_API_VERSIONS = ('v1',) 43 | 44 | 45 | class ChronosError(Exception): 46 | pass 47 | 48 | 49 | class ChronosAPIError(ChronosError): 50 | pass 51 | 52 | 53 | class UnauthorizedError(ChronosAPIError): 54 | pass 55 | 56 | 57 | class ChronosValidationError(ChronosError): 58 | pass 59 | 60 | 61 | class MissingFieldError(ChronosValidationError): 62 | pass 63 | 64 | 65 | class OneOfViolationError(ChronosValidationError): 66 | pass 67 | 68 | 69 | class ChronosClient(object): 70 | _user = None 71 | _password = None 72 | 73 | def __init__( 74 | self, servers, proto="http", username=None, password=None, 75 | extra_headers=None, scheduler_api_version='v1', 76 | validate_ssl_certificates=True, 77 | ): 78 | server_list = servers if isinstance(servers, list) else [servers] 79 | self.servers = ["%s://%s" % (proto, server) for server in server_list] 80 | self.extra_headers = extra_headers 81 | if username and password: 82 | self._user = username 83 | self._password = password 84 | self.logger = logging.getLogger(__name__) 85 | if scheduler_api_version is None: 86 | self._prefix = '' 87 | else: 88 | if scheduler_api_version not in SCHEDULER_API_VERSIONS: 89 | raise ChronosAPIError('scheduler_api_version not supported yet: %s' % scheduler_api_version) 90 | self._prefix = "/%s" % (scheduler_api_version,) 91 | self.scheduler_api_version = scheduler_api_version 92 | self.disable_ssl_certificate_validation = not validate_ssl_certificates 93 | 94 | def list(self): 95 | """List all jobs on Chronos.""" 96 | return self._call("/scheduler/jobs", "GET") 97 | 98 | def search(self, name=None, command=None): 99 | """Searches for jobs that match the criteria.""" 100 | 101 | params = {} 102 | if name: 103 | params['name'] = name 104 | if command: 105 | params['command'] = command 106 | 107 | return self._call('/scheduler/jobs/search', 'GET', params=params) 108 | 109 | def delete(self, name): 110 | """Delete a job by name""" 111 | path = "/scheduler/job/%s" % name 112 | return self._call(path, "DELETE") 113 | 114 | def delete_tasks(self, name): 115 | """Terminate all tasks for a running/stuck job""" 116 | path = "/scheduler/task/kill/%s" % name 117 | return self._call(path, "DELETE") 118 | 119 | def run(self, name): 120 | """Run a job by name""" 121 | path = "/scheduler/job/%s" % name 122 | return self._call(path, "PUT") 123 | 124 | def add(self, job_def, update=False): 125 | """Schedule a new job""" 126 | path = "/scheduler/iso8601" 127 | self._check_fields(job_def) 128 | if "parents" in job_def: 129 | path = "/scheduler/dependency" 130 | # Cool story: chronos >= 3.0 ditched PUT and only allows POST here, 131 | # trying to maintain backwards compat with < 3.0 132 | method = "POST" 133 | if self.scheduler_api_version is None: 134 | if update: 135 | method = "PUT" 136 | else: 137 | method = "POST" 138 | return self._call(path, method, json.dumps(job_def)) 139 | 140 | def update(self, job_def): 141 | """Update an existing job by name""" 142 | return self.add(job_def, update=True) 143 | 144 | def job_stat(self, name): 145 | """ List stats for a job """ 146 | return self._call('/scheduler/job/stat/%s' % name, "GET") 147 | 148 | def scheduler_graph(self): 149 | return self._call('/scheduler/graph/csv', 'GET') 150 | 151 | def scheduler_stat_99th(self): 152 | return self._call('/scheduler/stats/99thPercentile', 'GET') 153 | 154 | def scheduler_stat_98th(self): 155 | return self._call('/scheduler/stats/98thPercentile', 'GET') 156 | 157 | def scheduler_stat_95th(self): 158 | return self._call('/scheduler/stats/95thPercentile', 'GET') 159 | 160 | def scheduler_stat_75th(self): 161 | return self._call('/scheduler/stats/75thPercentile', 'GET') 162 | 163 | def scheduler_stat_median(self): 164 | return self._call('/scheduler/stats/median', 'GET') 165 | 166 | def scheduler_stat_mean(self): 167 | return self._call('/scheduler/stats/mean', 'GET') 168 | 169 | def metrics(self): 170 | # for some reason, /metrics is not prefixed with the version 171 | return self._call('/metrics', 'GET', prefix=False) 172 | 173 | def _call(self, url, method="GET", body=None, headers={}, prefix=True, params={}): 174 | hdrs = {} 175 | if body: 176 | hdrs['Content-Type'] = "application/json" 177 | hdrs.update(headers) 178 | if prefix: 179 | _url = '%s%s' % (self._prefix, url, ) 180 | else: 181 | _url = url 182 | self.logger.debug("Calling: %s %s" % (method, _url)) 183 | if body: 184 | self.logger.debug("Body: %s" % body) 185 | conn = httplib2.Http(disable_ssl_certificate_validation=self.disable_ssl_certificate_validation) 186 | if self._user and self._password: 187 | conn.add_credentials(self._user, self._password) 188 | if self.extra_headers: 189 | hdrs.update(self.extra_headers) 190 | 191 | response = None 192 | servers = list(self.servers) 193 | while servers: 194 | server = servers.pop(0) 195 | endpoint = "%s%s" % (server, quote(_url)) 196 | if params and method == 'GET': 197 | # usually you'd urlencode the params in the body, but we're 198 | # already sending the body in a different argument... 199 | endpoint += '?%s' % (urlencode(params)) 200 | try: 201 | self.logger.debug("Fetch %s %s" % (endpoint, method, )) 202 | resp, content = conn.request(endpoint, method, body=body, headers=hdrs) 203 | except (socket.error, httplib2.ServerNotFoundError) as e: 204 | self.logger.error('Error while calling %s: %s. Retrying', endpoint, str(e)) 205 | continue 206 | try: 207 | response = self._check(resp, content) 208 | return response 209 | except ChronosAPIError as e: 210 | self.logger.error('Error while calling %s: %s', endpoint, str(e)) 211 | 212 | raise ChronosAPIError('No remaining Chronos servers to try') 213 | 214 | def _check(self, resp, content): 215 | status = resp.status 216 | self.logger.debug("status: %d" % status) 217 | payload = None 218 | 219 | if status == 401: 220 | raise UnauthorizedError() 221 | 222 | if content: 223 | try: 224 | payload = json.loads(content.decode('utf-8')) 225 | except ValueError: 226 | if resp['content-type'] == "application/json": 227 | self.logger.error("Response not valid json: %s" % content) 228 | payload = content.decode('utf-8') 229 | 230 | if payload is None and status != 204: 231 | raise ChronosAPIError("Request to Chronos API failed: status: %d, response: %s" % (status, content)) 232 | 233 | # if the status returned is not an OK status, raise an exception 234 | if status >= 400: 235 | message = "API returned status %d, content: %s" % (status, payload,) 236 | # newer chronos does return the full stack trace in a message field, 237 | # grabbing the first 160 chars from it 238 | if 'message' in payload: 239 | self.logger.debug(payload['message']) 240 | message = '%s (...)' % (payload['message'][:120],) 241 | raise ChronosAPIError(message) 242 | 243 | return payload 244 | 245 | def _check_fields(self, job): 246 | fields = ChronosJob.fields 247 | if self.scheduler_api_version is None: 248 | fields.extend(ChronosJob.legacy_fields) 249 | for k in fields: 250 | if k not in job: 251 | raise MissingFieldError("missing required field %s" % k) 252 | 253 | if any(field in job for field in ChronosJob.one_of): 254 | if len([field for field in ChronosJob.one_of if field in job]) > 1: 255 | raise OneOfViolationError("Job must only include 1 of %s" % ChronosJob.one_of) 256 | else: 257 | raise MissingFieldError("Job must include one of %s" % ChronosJob.one_of) 258 | 259 | if "container" in job: 260 | container = job["container"] 261 | for k in ChronosJob.container_fields: 262 | if k not in container: 263 | raise MissingFieldError("missing required container field %s" % k) 264 | 265 | return True 266 | 267 | 268 | class ChronosJob(object): 269 | fields = [ 270 | "command", 271 | "name", 272 | "owner", 273 | "disabled" 274 | ] 275 | legacy_fields = [ 276 | "async", 277 | ] 278 | one_of = ["schedule", "parents"] 279 | container_fields = [ 280 | "type", 281 | "image" 282 | ] 283 | 284 | 285 | def connect(servers, proto="http", username=None, password=None, extra_headers=None, scheduler_api_version='v1'): 286 | return ChronosClient( 287 | servers, proto=proto, username=username, password=password, 288 | extra_headers=extra_headers, scheduler_api_version=scheduler_api_version 289 | ) 290 | -------------------------------------------------------------------------------- /itests/chronos-python.feature: -------------------------------------------------------------------------------- 1 | Feature: chronos-python can interact with chronos 2 | 3 | @all 4 | Scenario: Trivial chronos interaction 5 | Given a working chronos instance 6 | When we create a trivial chronos job named "myjob" 7 | Then we should be able to see the job named "myjob" when we list jobs 8 | 9 | @3.0.2 10 | Scenario: Handling spaces in job names 11 | Given a working chronos instance 12 | When we create a trivial chronos job named "job with spaces" 13 | Then we should not see the job named "job with spaces" when we list jobs 14 | 15 | @2.4.0 16 | Scenario: Handling spaces in job names 17 | Given a working chronos instance 18 | When we create a trivial chronos job named "job with spaces" 19 | Then we should be able to see the job named "job with spaces" when we list jobs 20 | 21 | @all 22 | Scenario: Gathering job stats for job 23 | Given a working chronos instance 24 | When we create a trivial chronos job named "myjob" 25 | Then we should be able to see timings for the job named "myjob" when we look at scheduler stats 26 | And we should be able to see percentiles for all jobs 27 | And we should be able to see the median timing for all jobs 28 | And we should be able to see the mean timing for all jobs 29 | 30 | @all 31 | Scenario: Gathering scheduler graph 32 | Given a working chronos instance 33 | When we create a trivial chronos job named "myjob" 34 | And we create a trivial chronos job named "myotherjob" 35 | Then we should be able to see 2 jobs in the job graph 36 | 37 | @all 38 | Scenario: Getting metrics 39 | Given a working chronos instance 40 | Then we should be able to see metrics 41 | 42 | @3.0.2 43 | Scenario: Searching for a job named "myjob" 44 | Given a working chronos instance 45 | When we create a trivial chronos job named "myjob" 46 | Then we should be able to search for a job named "myjob" 47 | 48 | @3.0.2 49 | Scenario: Searching for a job by command "echo" 50 | Given a working chronos instance 51 | When we create a trivial chronos job named "myjob" 52 | Then we should be able to search for a job with the command "echo" 53 | -------------------------------------------------------------------------------- /itests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | zookeeper: 5 | image: zookeeper:3.4.9 6 | container_name: zookeeper 7 | environment: 8 | ZOO_MY_ID: 1 9 | ZK_INIT_LIMIT: 10 10 | ZK_SYNC_LIMIT: 5 11 | 12 | master: 13 | image: mesosphere/mesos-master:1.0.3 14 | container_name: mesos-master 15 | environment: 16 | MESOS_ZK: zk://zookeeper:2181/mesos 17 | MESOS_QUORUM: 1 18 | MESOS_CLUSTER: chronos-python 19 | MESOS_REGISTRY: in_memory 20 | MESOS_HOSTNAME: localhost 21 | ports: 22 | - 5050:5050 23 | depends_on: 24 | - zookeeper 25 | links: 26 | - zookeeper 27 | 28 | agent: 29 | image: mesosphere/mesos-slave:1.0.3 30 | container_name: mesos-agent 31 | pid: host 32 | environment: 33 | MESOS_MASTER: zk://zookeeper:2181/mesos 34 | MESOS_WORK_DIR: /tmp/mesos 35 | MESOS_PORT: 5051 36 | MESOS_RESOURCES: ports(*):[11000-11999] 37 | MESOS_CONTAINERIZERS: mesos 38 | MESOS_HOSTNAME: localhost 39 | ports: 40 | - 5051:5051 41 | volumes: 42 | - /sys/fs/cgroup:/sys/fs/cgroup 43 | depends_on: 44 | - zookeeper 45 | links: 46 | - zookeeper 47 | - master 48 | 49 | chronos-2.4.0: 50 | image: mesosphere/chronos:chronos-2.4.0-0.1.20150828104228.ubuntu1404-mesos-0.27.0-0.2.190.ubuntu1404 51 | container_name: chronos-2.4.0 52 | command: '/usr/bin/chronos run_jar --http_port 4400 --zk_hosts zookeeper:2181 --master zk://zookeeper:2181/mesos' 53 | ports: 54 | - 4400:4400 55 | depends_on: 56 | - zookeeper 57 | - master 58 | links: 59 | - zookeeper 60 | - master 61 | 62 | chronos-3.0.2: 63 | image: mesosphere/chronos:v3.0.2 64 | container_name: chronos-3.0.2 65 | command: '--zk_hosts zookeeper:2181 --master zk://zookeeper:2181/mesos' 66 | environment: 67 | PORT0: 4400 68 | PORT1: 8080 69 | ports: 70 | - 4400:4400 71 | depends_on: 72 | - zookeeper 73 | - master 74 | links: 75 | - zookeeper 76 | - master 77 | 78 | -------------------------------------------------------------------------------- /itests/environment.py: -------------------------------------------------------------------------------- 1 | import time 2 | from itest_utils import wait_for_chronos 3 | 4 | 5 | def before_all(context): 6 | wait_for_chronos() 7 | 8 | 9 | def after_scenario(context, scenario): 10 | """If a chronos client object exists in our context, delete any jobs before the next scenario.""" 11 | if context.client: 12 | while True: 13 | jobs = context.client.list() 14 | if not jobs: 15 | break 16 | for job in jobs: 17 | context.client.delete(job['name']) 18 | time.sleep(1.0) 19 | -------------------------------------------------------------------------------- /itests/itest_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import errno 5 | import os 6 | import signal 7 | import time 8 | from functools import wraps 9 | 10 | import requests 11 | 12 | 13 | class TimeoutError(Exception): 14 | pass 15 | 16 | 17 | def timeout(seconds=10, error_message=os.strerror(errno.ETIME)): 18 | def decorator(func): 19 | def _handle_timeout(signum, frame): 20 | raise TimeoutError(error_message) 21 | 22 | def wrapper(*args, **kwargs): 23 | signal.signal(signal.SIGALRM, _handle_timeout) 24 | signal.alarm(seconds) 25 | try: 26 | result = func(*args, **kwargs) 27 | finally: 28 | signal.alarm(0) 29 | return result 30 | 31 | return wraps(func)(wrapper) 32 | 33 | return decorator 34 | 35 | 36 | @timeout(60) 37 | def wait_for_chronos(): 38 | """Blocks until chronos is up""" 39 | # we start chronos always on port 4400 40 | chronos_service = 'localhost:4400' 41 | while True: 42 | print('Connecting to chronos on %s' % chronos_service) 43 | try: 44 | response = requests.get('http://%s/ping' % chronos_service, timeout=2) 45 | except ( 46 | requests.exceptions.ConnectionError, 47 | requests.exceptions.Timeout, 48 | ): 49 | time.sleep(2) 50 | continue 51 | if response.status_code == 200: 52 | print("Chronos is up and running!") 53 | break 54 | -------------------------------------------------------------------------------- /itests/steps/chronos_steps.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | import sys 4 | from behave import given, when, then 5 | import time 6 | 7 | import chronos 8 | 9 | log = logging.getLogger('chronos') 10 | log.addHandler(logging.StreamHandler(sys.stdout)) 11 | log.setLevel(logging.DEBUG) 12 | LEGACY_VERSIONS = ('2.4.0',) 13 | DEFAULT_CHRONOS_VERSION = '3.0.2' 14 | 15 | 16 | @given('a working chronos instance') 17 | def working_chronos(context): 18 | """Adds a working chronos client as context.client for the purposes of 19 | interacting with it in the test.""" 20 | if not hasattr(context, 'client'): 21 | chronos_servers = ['127.0.0.1:4400'] 22 | chronos_version = context.config.userdata.get('chronos_version', DEFAULT_CHRONOS_VERSION) 23 | if chronos_version in LEGACY_VERSIONS: 24 | scheduler_api_version = None 25 | else: 26 | scheduler_api_version = 'v1' 27 | context.client = chronos.connect(chronos_servers, scheduler_api_version=scheduler_api_version) 28 | 29 | 30 | @when(u'we create a trivial chronos job named "{job_name}"') 31 | def create_trivial_chronos_job(context, job_name): 32 | job = { 33 | 'command': 'echo 1', 34 | 'name': job_name, 35 | 'owner': '', 36 | 'disabled': False, 37 | 'schedule': 'R0/2014-01-01T00:00:00Z/PT60M', 38 | } 39 | chronos_version = context.config.userdata.get('chronos_version', DEFAULT_CHRONOS_VERSION) 40 | if chronos_version in LEGACY_VERSIONS: 41 | job['async'] = False 42 | try: 43 | context.client.add(job) 44 | context.created = True 45 | except: 46 | context.created = False 47 | # give it a bit of time to reflect the job in ZK 48 | time.sleep(0.5) 49 | 50 | 51 | @then(u'we should be able to see the job named "{job_name}" when we list jobs') 52 | def list_chronos_jobs_has_trivial_job(context, job_name): 53 | jobs = context.client.list() 54 | job_names = [job['name'] for job in jobs] 55 | assert job_name in job_names 56 | 57 | 58 | @then(u'we should not see the job named "{job_name}" when we list jobs') 59 | def list_chronos_jobs_hasnt_trivial_job(context, job_name): 60 | jobs = context.client.list() 61 | job_names = [job['name'] for job in jobs] 62 | assert job_name not in job_names 63 | 64 | 65 | @then(u'we should be able to delete the job named "{job_name}"') 66 | def delete_job_with_spaces(context, job_name): 67 | context.client.delete(job_name) 68 | time.sleep(0.5) 69 | 70 | 71 | @then(u'we should not be able to see the job named "{job_name}" when we list jobs') 72 | def not_see_job_with_spaces(context, job_name): 73 | jobs = context.client.list() 74 | job_names = [job['name'] for job in jobs] 75 | assert 'test chronos job with spaces' not in job_names 76 | 77 | 78 | @then(u'we should be able to see {num_jobs:d} jobs in the job graph') 79 | def see_job_in_graph(context, num_jobs): 80 | jobs = csv.reader(context.client.scheduler_graph().splitlines()) 81 | actual = sum(1 for row in jobs) 82 | assert actual == num_jobs 83 | 84 | 85 | @then(u'we should be able to see timings for the job named "{job_name}" when we look at scheduler stats') 86 | def check_job_has_timings(context, job_name): 87 | stats = context.client.job_stat(job_name) 88 | assert stats == { 89 | 'histogram': { 90 | 'median': 0.0, 91 | '98thPercentile': 0.0, 92 | '75thPercentile': 0.0, 93 | '95thPercentile': 0.0, 94 | '99thPercentile': 0.0, 95 | 'count': 0, 96 | 'mean': 0.0, 97 | }, 98 | 'taskStatHistory': [] 99 | } 100 | 101 | 102 | @then(u'we should be able to see percentiles for all jobs') 103 | def check_percentiles(context): 104 | ninety_ninth = context.client.scheduler_stat_99th() 105 | ninety_eighth = context.client.scheduler_stat_98th() 106 | ninety_fifth = context.client.scheduler_stat_95th() 107 | seventy_fifth = context.client.scheduler_stat_75th() 108 | for percentile in ninety_ninth, ninety_eighth, ninety_fifth, seventy_fifth: 109 | assert percentile == [{'jobNameLabel': 'myjob', 'time': 0.0}] 110 | 111 | 112 | @then(u'we should be able to see the median timing for all jobs') 113 | def check_median(context): 114 | medians = context.client.scheduler_stat_median() 115 | assert medians == [{'jobNameLabel': 'myjob', 'time': 0.0}] 116 | 117 | 118 | @then(u'we should be able to see the mean timing for all jobs') 119 | def check_mode(context): 120 | modes = context.client.scheduler_stat_median() 121 | assert modes == [{'jobNameLabel': 'myjob', 'time': 0.0}] 122 | 123 | 124 | @then(u'we should be able to see metrics') 125 | def check_metrics(context): 126 | metrics = context.client.metrics() 127 | assert isinstance(metrics, dict) 128 | assert "version"in metrics 129 | assert "gauges" in metrics 130 | 131 | 132 | @then(u'we should be able to search for a job named "{job_name}"') 133 | def search_job_by_name(context, job_name): 134 | jobs = context.client.search(name=job_name) 135 | result = False 136 | for job in jobs: 137 | if 'name' in job and job['name'] == job_name: 138 | result = True 139 | break 140 | assert result 141 | 142 | 143 | @then(u'we should be able to search for a job with the command "{command}"') 144 | def search_job_by_command(context, command): 145 | jobs = context.client.search(command=command) 146 | # there might be more than 1 job with the command FOO, so just ensure 147 | # there are results 148 | assert len(jobs) > 0 149 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httplib2 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="chronos-python", 6 | version="1.2.1", 7 | author="Asher Feldman", 8 | author_email="asher@democument.com", 9 | description=("A Python client libary for the Chronos Job Scheduler."), 10 | license="MIT", 11 | keywords="chronos", 12 | packages=['chronos'], 13 | scripts=['bin/chronos-sync-jobs.py', 'bin/chronos-nagios.py'], 14 | long_description="A python client library for the Chronos Job Scheduler, with support scripts.", 15 | classifiers=[ 16 | "Development Status :: 3 - Alpha", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 2", 20 | "Programming Language :: Python :: 2.7", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.6", 23 | ], 24 | install_requires=[ 25 | 'httplib2 >= 0.9' 26 | ], 27 | url='https://github.com/asher/chronos-python', 28 | ) 29 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | docker-compose==1.8.1 3 | pep8==1.5.7 4 | behave 5 | flake8 6 | pytest 7 | mock 8 | tox 9 | -------------------------------------------------------------------------------- /tests/test_chronos_init.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mock 3 | import pytest 4 | import httplib2 5 | 6 | import chronos 7 | 8 | 9 | def test_connect_accepts_single_host(): 10 | client = chronos.ChronosClient("localhost", proto="http") 11 | assert client.servers == ['http://localhost'] 12 | 13 | 14 | def test_connect_accepts_list_of_hosts(): 15 | client = chronos.ChronosClient(["host1", "host2"], proto="http") 16 | assert client.servers == ['http://host1', 'http://host2'] 17 | 18 | 19 | def test_connect_accepts_proto(): 20 | client = chronos.ChronosClient("localhost", proto="fake_proto") 21 | assert client.servers == ['fake_proto://localhost'] 22 | 23 | 24 | def test_check_accepts_json(): 25 | client = chronos.ChronosClient("localhost") 26 | fake_response = mock.Mock() 27 | fake_response.status = 200 28 | fake_content = '{ "foo": "bar" }'.encode('utf-8') 29 | actual = client._check(fake_response, fake_content) 30 | assert actual == json.loads(fake_content) 31 | 32 | 33 | def test_http_codes(): 34 | client = chronos.ChronosClient("localhost") 35 | fake_response = mock.Mock() 36 | # all status codes 2xx and 3xx are potentially valid 37 | valid_codes = range(200, 399) 38 | for status in valid_codes: 39 | fake_response.status = status 40 | fake_content = '{ "foo": "bar" }'.encode('utf-8') 41 | actual = client._check(fake_response, fake_content) 42 | assert actual == json.loads(fake_content) 43 | # we treat 401 in a special way, so we skip it (add 400 at the beginning) 44 | invalid_codes = list(range(402, 550)) 45 | invalid_codes.insert(0, 400) 46 | for status in invalid_codes: 47 | fake_response.status = status 48 | fake_content = '{ "foo": "bar" }'.encode('utf-8') 49 | with pytest.raises(chronos.ChronosAPIError): 50 | actual = client._check(fake_response, fake_content) 51 | # let's test 401 finally 52 | fake_response.status = 401 53 | fake_content = '{ "foo": "bar" }'.encode('utf-8') 54 | with pytest.raises(chronos.UnauthorizedError): 55 | actual = client._check(fake_response, fake_content) 56 | 57 | 58 | def test_check_returns_raw_response_when_not_json(): 59 | client = chronos.ChronosClient("localhost") 60 | fake_response = mock.Mock(__getitem__=mock.Mock(return_value="not-json")) 61 | fake_response.status = 400 62 | fake_content = 'foo bar baz'.encode('utf-8') 63 | try: 64 | actual = client._check(fake_response, fake_content) 65 | except chronos.ChronosAPIError as cap: 66 | actual = str(cap) 67 | # on exceptions, the content is passed on the exception's message 68 | assert actual == "API returned status 400, content: {0}".format( 69 | fake_content.decode('utf-8') 70 | ) 71 | 72 | 73 | def test_check_does_not_log_error_when_content_type_is_not_json(): 74 | with mock.patch('logging.getLogger', return_value=mock.Mock(error=mock.Mock())) as mock_log: 75 | client = chronos.ChronosClient("localhost") 76 | fake_response = mock.Mock(__getitem__=mock.Mock(return_value="not-json")) 77 | fake_response.status = 400 78 | fake_content = 'foo bar baz'.encode('utf-8') 79 | try: 80 | client._check(fake_response, fake_content) 81 | except chronos.ChronosAPIError: 82 | pass 83 | assert mock_log().error.call_count == 0 84 | 85 | 86 | def test_check_logs_error_when_invalid_json(): 87 | with mock.patch('logging.getLogger', return_value=mock.Mock(error=mock.Mock())) as mock_log: 88 | client = chronos.ChronosClient("localhost") 89 | fake_response = mock.Mock(__getitem__=mock.Mock(return_value="application/json")) 90 | fake_response.status = 400 91 | fake_content = 'foo bar baz'.encode('utf-8') 92 | try: 93 | client._check(fake_response, fake_content) 94 | except chronos.ChronosAPIError: 95 | pass 96 | mock_log().error.assert_called_once_with("Response not valid json: %s" % fake_content) 97 | 98 | 99 | def test_uses_server_list(): 100 | client = chronos.ChronosClient(["host1", "host2", "host3"], proto="http") 101 | good_request = (mock.Mock(status=204), '') 102 | bad_request = (mock.Mock(status=500), '') 103 | 104 | conn_mock = mock.Mock(request=mock.Mock(side_effect=[bad_request, good_request, bad_request])) 105 | with mock.patch('httplib2.Http', return_value=conn_mock): 106 | client._call('/fake_url') 107 | assert conn_mock.request.call_count == 2 108 | 109 | 110 | def test_api_error_throws_exception(): 111 | client = chronos.ChronosClient(servers="localhost") 112 | mock_response = mock.Mock() 113 | mock_response.status = 500 114 | mock_request = mock.Mock(return_value=(mock_response, None)) 115 | with mock.patch.object(httplib2.Http, 'request', mock_request): 116 | with pytest.raises(chronos.ChronosAPIError): 117 | client.list() 118 | 119 | 120 | def test_check_missing_top_level_fields(): 121 | client = chronos.ChronosClient(servers="localhost") 122 | for field in chronos.ChronosJob.fields: 123 | without_field = {x: 'foo' for x in filter(lambda y: y != field, chronos.ChronosJob.fields)} 124 | with pytest.raises(chronos.MissingFieldError): 125 | client._check_fields(without_field) 126 | 127 | 128 | def test_check_missing_container_fields(): 129 | client = chronos.ChronosClient(servers="localhost") 130 | for field in chronos.ChronosJob.container_fields: 131 | container_without_field = {x: 'foo' for x in filter(lambda y: y != field, chronos.ChronosJob.container_fields)} 132 | job_def = { 133 | "container": container_without_field, 134 | "command": "while sleep 10; do date =u %T; done", 135 | "schedule": "R/2014-09-25T17:22:00Z/PT2M", 136 | "name": "dockerjob", 137 | "owner": "test", 138 | "disabled": False 139 | } 140 | with pytest.raises(chronos.MissingFieldError) as excinfo: 141 | client._check_fields(job_def) 142 | assert field in str(excinfo.value) 143 | 144 | 145 | def test_check_one_of_missing(): 146 | client = chronos.ChronosClient(servers="localhost") 147 | job = {field: 'foo' for field in chronos.ChronosJob.fields} 148 | with pytest.raises(chronos.MissingFieldError): 149 | client._check_fields(job) 150 | 151 | 152 | def test_check_one_of_all(): 153 | client = chronos.ChronosClient(servers="localhost") 154 | job = {field: 'foo' for field in (chronos.ChronosJob.fields + chronos.ChronosJob.one_of)} 155 | with pytest.raises(chronos.OneOfViolationError): 156 | client._check_fields(job) 157 | 158 | 159 | def test_check_unauthorized_raises(): 160 | client = chronos.ChronosClient(servers="localhost") 161 | mock_response = mock.Mock() 162 | mock_response.status = 401 163 | with pytest.raises(chronos.UnauthorizedError): 164 | client._check(mock_response, '{"foo": "bar"}') 165 | 166 | 167 | @mock.patch('chronos.ChronosJob') 168 | def test_check_one_of_ok(patch_chronos_job): 169 | patch_chronos_job.one_of = ['foo', 'bar'] 170 | patch_chronos_job.fields = ['field1', 'field2'] 171 | job = {field: 'foo' for field in chronos.ChronosJob.fields} 172 | client = chronos.ChronosClient(servers="localhost") 173 | for one_of_field in ['foo', 'bar']: 174 | complete = job.copy() 175 | complete.update({one_of_field: 'val'}) 176 | assert client._check_fields(complete) 177 | 178 | 179 | @mock.patch('chronos.ChronosJob') 180 | def test_check_one_of_more_than_one(patch_chronos_job): 181 | patch_chronos_job.one_of = ['foo', 'bar', 'baz'] 182 | patch_chronos_job.fields = ['field1', 'field2'] 183 | job = {field: 'foo' for field in (chronos.ChronosJob.fields + ['foo', 'bar'])} 184 | client = chronos.ChronosClient(servers="localhost") 185 | with pytest.raises(chronos.OneOfViolationError): 186 | client._check_fields(job) 187 | 188 | 189 | @mock.patch('chronos.httplib2.Http') 190 | def test_call_retries_on_http_error(mock_http): 191 | mock_call = mock.Mock(side_effect=[ 192 | httplib2.socket.error, 193 | httplib2.ServerNotFoundError, 194 | (mock.Mock(status=200), '{"foo": "bar"}'.encode('utf-8')) 195 | ]) 196 | mock_http.return_value = mock.Mock( 197 | request=mock_call 198 | ) 199 | client = chronos.ChronosClient(servers=['1.2.3.4', '1.2.3.5', '1.2.3.6']) 200 | client._call("/foo") 201 | mock_call.assert_any_call('http://1.2.3.4%s/foo' % client._prefix, 'GET', body=None, headers={}) 202 | mock_call.assert_any_call('http://1.2.3.5%s/foo' % client._prefix, 'GET', body=None, headers={}) 203 | mock_call.assert_any_call('http://1.2.3.6%s/foo' % client._prefix, 'GET', body=None, headers={}) 204 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | usedevelop=True 3 | envlist = py27, py36 4 | 5 | [flake8] 6 | max-line-length = 120 7 | 8 | [testenv] 9 | deps = 10 | -rtest-requirements.txt 11 | commands = 12 | flake8 bin chronos itests tests setup.py 13 | py.test -v {posargs:tests} 14 | 15 | [testenv:itests] 16 | passenv = DOCKER_TLS_VERIFY DOCKER_HOST DOCKER_CERT_PATH 17 | basepython = python2.7 18 | whitelist_externals=/bin/bash 19 | skipsdist=True 20 | changedir=itests/ 21 | deps = 22 | -rtest-requirements.txt 23 | commands = 24 | /bin/bash -c "docker-compose up -d chronos-{env:CHRONOSVERSION:3.0.2}" 25 | behave --tags all,{env:CHRONOSVERSION:3.0.2} --no-capture --no-capture-stderr -Dchronos_version={env:CHRONOSVERSION:3.0.2} {posargs} 26 | /bin/bash -c "docker-compose down" 27 | 28 | [testenv:itests-py3] 29 | passenv = DOCKER_TLS_VERIFY DOCKER_HOST DOCKER_CERT_PATH 30 | basepython = python3 31 | whitelist_externals=/bin/bash 32 | skipsdist=True 33 | changedir=itests/ 34 | deps = 35 | -rtest-requirements.txt 36 | commands = 37 | /bin/bash -c "docker-compose up -d chronos-{env:CHRONOSVERSION:3.0.2}" 38 | behave --tags all,{env:CHRONOSVERSION:3.0.2} --no-capture --no-capture-stderr -Dchronos_version={env:CHRONOSVERSION:3.0.2} {posargs} 39 | /bin/bash -c "docker-compose down" 40 | --------------------------------------------------------------------------------