├── .bowerrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── bin ├── hermes ├── hermes-notify └── hermes-server ├── bower.json ├── config ├── dev-client.yaml └── dev.yaml ├── db ├── bootstrap.sql ├── truncate_tables.sql ├── update_to_046.sql ├── update_to_05.sql ├── update_to_051.sql ├── update_to_0511.sql ├── update_to_06.sql ├── update_to_0726.sql └── update_to_0_5_15.sql ├── docs ├── Makefile ├── api-ref.rst ├── api.rst ├── conf.py ├── config.rst ├── index.rst └── intro.rst ├── examples └── ex1 │ ├── create-quest │ ├── reboot-complete │ ├── reboot-complete-remaining │ └── servers.list ├── gulpfile.js ├── hermes ├── __init__.py ├── app.py ├── exc.py ├── handlers │ ├── __init__.py │ ├── api.py │ ├── frontends.py │ └── util.py ├── models.py ├── plugin.py ├── plugins │ ├── __init__.py │ └── hooks │ │ └── __init__.py ├── routes.py ├── settings.py ├── settings_client.py ├── util.py ├── version.py └── webapp │ └── src │ ├── css │ ├── main.css │ └── main.less │ ├── img │ ├── icon.gif │ ├── loading.gif │ ├── loading_15.gif │ ├── logo.png │ ├── remove_10.png │ ├── remove_15.png │ └── send_email_15.png │ ├── index.html │ ├── js │ ├── controllers │ │ ├── laborStatusCtrl.js │ │ ├── questCreationCtrl.js │ │ ├── questEditCtrl.js │ │ ├── questStatusCtrl.js │ │ └── userHomeCtrl.js │ ├── directives │ │ ├── boxContinuation.js │ │ ├── fateGraph.js │ │ ├── questProgressBar.js │ │ ├── questProgressChart.js │ │ ├── scrollWatch.js │ │ └── viewportHeight.js │ ├── filters │ │ ├── encode.js │ │ └── num.js │ ├── hermesApp.js │ └── services │ │ ├── hermesService.js │ │ └── skipReload.js │ └── templates │ ├── fateViewer.html │ ├── laborList.html │ ├── questCreation.html │ ├── questEdit.html │ ├── questStatus.html │ └── userHome.html ├── npm-shrinkwrap.json ├── package.json ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── api_tests ├── __init__.py ├── data │ └── set1 │ │ ├── event.json │ │ ├── eventtypes.json │ │ ├── fates.json │ │ └── hosts.json ├── fixtures.py ├── test_events.py ├── test_eventtypes.py ├── test_fates.py ├── test_hosts.py ├── test_labors.py ├── test_quests.py └── util.py ├── model_tests ├── __init__.py ├── fixtures.py ├── test_events.py ├── test_eventtypes.py ├── test_fates.py ├── test_host.py ├── test_labors.py └── test_quests.py └── sample_data ├── sample_data1.sql └── sample_data2.sql /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "analytics": false, 3 | "directory": "_bc" 4 | } 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | *.sqlite 4 | *.sqlite-journal 5 | 6 | # Bower Components 7 | _bc 8 | 9 | __pycache__ 10 | 11 | MANIFEST 12 | *.py[cod] 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Packages 18 | *.egg 19 | *.egg-info 20 | dist 21 | build 22 | eggs 23 | parts 24 | var 25 | sdist 26 | develop-eggs 27 | .installed.cfg 28 | lib 29 | lib64 30 | 31 | # Installer logs 32 | pip-log.txt 33 | 34 | # Unit test / coverage reports 35 | .coverage 36 | .tox 37 | nosetests.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | 47 | docs/_build 48 | .*sw? 49 | 50 | # Node 51 | /npm-debug.log 52 | /node_modules 53 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | install: 5 | - pip install -r requirements-dev.txt 6 | - pip install . 7 | script: py.test -vvv tests/ 8 | after_success: curl -X POST http://readthedocs.org/build/dbx_hermes 9 | notifications: 10 | slack: 11 | secure: q2LtdP0ATZOFPZG2vI4CS1mCL+jZKVImzCq8mdOX3iM2FkIVKo4YxT/bNUJiWb4xOrenpuFz7J8scbY0gu3e3lgUGTSWFjQc9W6NEvgGKImSunqkF5vKi1VkAvFuYTbBxW6kQpRadTyRrcmVrK2OoBDyeVqIa5crmJ8oswCnSM0IE8m6qJk0RkRZvdJzRdWgGhWjri0Mk+Bh/6gRZPOpjpfjHhtFv2qAqPH1G3z/KaheH/PsckGx9/iQdAIvRkK02/e9ne82ON8sy05RN8WPgCMm4kk5gdfdlD7ZUUtOqlKgNHmQyggjgd7DfxgFzPI62P9zvHiHyYGHubZ/XJYwWVHy6oP9PIcUxEH01MnoNxKYwFDLD6ujG0+Cb4umB8SacPNnXdjZ8AG+gndTlHdqRXm7KPaAaGqA+HuPlRJysBEnuvr6WeCZOJ6KWjgK+Z0xaR7GQB1obfId7Pc3zhqYXf9G0+hMLVAYJ+eJrfEZauYPtsAAW0qeOiyEDsJkFyv0Qrnee537R/12A0mV1YjWIBSvvYXQJzyUx01FIR9v0hFnT7/0P4L9cluoKxZt4Yl4q57edPQ6XlerhG/1zbkCRDqq/xaG5RSEw3nbhTTbNE8WHj3lkJ9Qd1+YCjr9Ta1xJjJpEdfBVBQ5UwAR3BCuu5Hkmg7WG4q/zDnw6qkPN2s= 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Dropbox, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include runtests 3 | include README 4 | include README.md 5 | include requirements.txt 6 | include requirements-dev.txt 7 | recursive-include config * 8 | recursive-include tests * 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build 2 | Status](https://travis-ci.org/dropbox/hermes.svg?branch=master)](https://travis-ci.org/dropbox/hermes) 3 | # Introduction # 4 | 5 | Hermes logs events, generates tasks, and tracks tasks in logical groups. 6 | 7 | # Documentation # 8 | 9 | [dbx-hermes.readthedocs.org](http://dbx-hermes.readthedocs.org/en/latest/) 10 | 11 | # How to Build 12 | 13 | 1. gulp build 14 | 2. python setup.py sdist 15 | -------------------------------------------------------------------------------- /bin/hermes-notify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import logging 5 | import textwrap 6 | 7 | import hermes 8 | from hermes.util import PluginHelper 9 | from hermes.models import get_db_session, get_db_engine, Session, Quest, Labor 10 | from hermes.settings import settings 11 | from hermes.util import email_message 12 | 13 | from sqlalchemy.exc import OperationalError 14 | 15 | sa_log = logging.getLogger("sqlalchemy.engine.base.Engine") 16 | 17 | 18 | def parse_args(): 19 | parser = argparse.ArgumentParser(description="Hermes Web Service") 20 | parser.add_argument("-c", "--config", default="/etc/hermes/server.yaml", 21 | help="Path to config file.") 22 | parser.add_argument( 23 | "-v", "--verbose", action="count", default=0, 24 | help="Increase logging verbosity." 25 | ) 26 | parser.add_argument( 27 | "-q", "--quiet", action="count", default=0, 28 | help="Decrease logging verbosity." 29 | ) 30 | parser.add_argument( 31 | "-V", "--version", action="version", 32 | version="%%(prog)s %s" % hermes.__version__, 33 | help="Display version information." 34 | ) 35 | parser.add_argument( 36 | "-s", "--send-to", type=str, default=None, help="Send all reports to this email address" 37 | ) 38 | return parser.parse_args() 39 | 40 | 41 | def find_quest(quests, quest_id): 42 | for quest in quests: 43 | if quest.id == quest_id: 44 | return quest 45 | 46 | return None 47 | 48 | 49 | def main(): 50 | args = parse_args() 51 | settings.update_from_config(args.config) 52 | 53 | if args.verbose: 54 | logging.basicConfig(level=logging.DEBUG, format=settings.log_format) 55 | elif args.quiet: 56 | logging.basicConfig(level=logging.ERROR, format=settings.log_format) 57 | else: 58 | logging.basicConfig(level=logging.INFO, format=settings.log_format) 59 | 60 | logging.info( 61 | "Starting Hermes notification system" 62 | ) 63 | 64 | # Connect to the database and get the open quests 65 | db_engine = get_db_engine(settings.database) 66 | Session.configure(bind=db_engine) 67 | session = Session() 68 | open_quests = Quest.get_open_quests(session) 69 | open_labors = Labor.get_open_labors(session) 70 | 71 | # get the list of all the hostnames that have open labors 72 | hostnames = set() 73 | for labor in open_labors: 74 | hostnames.add(labor.host.hostname) 75 | 76 | # get the owner information 77 | try: 78 | results = PluginHelper.request_post( 79 | json_body={ 80 | "operation": "owners", 81 | "hostnames": list(hostnames) 82 | } 83 | ) 84 | owners = results.json()['results'] 85 | except Exception as e: 86 | logging.error("Failed to get host owners: " + e.message) 87 | 88 | strongpoc_contacts = None 89 | if settings.strongpoc_server: 90 | # get the contact information 91 | try: 92 | results = PluginHelper.request_get( 93 | path = "/api/pocs/?expand=teams&expand=service_providers&expand=contact_types&service_provider__name=hermes&contact_type__name=email", 94 | server = settings.strongpoc_server 95 | ) 96 | strongpoc_results = results.json() 97 | strongpoc_contacts = {} 98 | for result in strongpoc_results: 99 | strongpoc_contacts[result['team']['name']] = result['value'] 100 | except Exception as e: 101 | logging.error("Failed to get strongpoc contacts: " + e.message) 102 | 103 | # get the tags for hosts 104 | try: 105 | results = PluginHelper.request_post( 106 | json_body={ 107 | "operation": "tags", 108 | "hostnames": list(hostnames) 109 | } 110 | ) 111 | tags = results.json()['results'] 112 | except Exception as e: 113 | logging.error("Failed to get host tags: " + e.message) 114 | 115 | # map the labors and quests to owners 116 | info = {} 117 | for labor in open_labors: 118 | if labor.for_creator and not labor.quest: 119 | # FIXME: what should we do here? See Issue #145 120 | continue 121 | elif labor.for_creator and labor.quest: 122 | if labor.quest.creator not in info: 123 | info[labor.quest.creator] = {} 124 | 125 | # if this is our first time seeing this owner, create the empty record 126 | elif owners[labor.host.hostname] not in info: 127 | info[owners[labor.host.hostname]] = {} 128 | 129 | owner = ( 130 | info[owners[labor.host.hostname]] if labor.for_owner 131 | else info[labor.quest.creator] 132 | ) 133 | 134 | quest_id = labor.quest_id if labor.quest else 0 135 | if quest_id not in owner: 136 | owner[quest_id] = [] 137 | 138 | owner[quest_id].append(labor.host.hostname) 139 | 140 | # generate and send emails 141 | for owner in info: 142 | plain_msg = generate_plain_mesg(info, open_quests, owner, tags) 143 | html_msg = generate_html_mesg(info, open_quests, owner, tags) 144 | 145 | # send to strongPOC specified email, otherwise send to owner@domainname 146 | if strongpoc_contacts and owner in strongpoc_contacts: 147 | recipient = strongpoc_contacts[owner] 148 | else: 149 | recipient = "{}@{}".format( 150 | owner, settings.domain 151 | ) 152 | 153 | # always send email to args.send_to if defined 154 | if args.send_to: 155 | logging.debug('Overriding {} recipient for owner {}'.format(recipient, owner)) 156 | recipient = args.send_to 157 | logging.debug('Sending email to {}'.format(recipient)) 158 | 159 | email_message( 160 | recipient, 161 | "{}: Open Hermes labors need your attention".format(owner), 162 | plain_msg, html_message=html_msg 163 | ) 164 | 165 | 166 | def generate_plain_mesg(info, open_quests, owner, tags): 167 | """Generate the plain text version of the 'open labors' email 168 | 169 | Args: 170 | info: the gathered and sorted open labor information 171 | open_quests: the information on open quests 172 | owner: the owner we care about emailing 173 | tags: the host tags information 174 | 175 | Returns: 176 | a plain text message to be sent to the owner specified 177 | """ 178 | 179 | msg = ( 180 | "This email is being sent to {} because that is the owner listed\n" 181 | "for the systems with open Hermes labors listed below.\n\n" 182 | "Due dates, if any, are noted with each quest.\n".format(owner) 183 | ) 184 | msg += ( 185 | "\nTo throw an event manually, you can run the following command " 186 | "on a shell server:" 187 | "\n\n" 188 | "$ hermes event create [event] --host [hostname].\n\n" 189 | "Or you can visit the quests linked below.\n\n".format( 190 | settings.frontend) 191 | ) 192 | for quest_id in info[owner]: 193 | quest = find_quest(open_quests, quest_id) 194 | if quest: 195 | msg += ( 196 | "==[ QUEST {} ]================================\n" 197 | "CREATOR: {}\n" 198 | ).format( 199 | quest_id, quest.creator 200 | ) 201 | if quest.target_time: 202 | msg += "DUE: {}\n".format(quest.target_time) 203 | msg += "DESC: \"{}\"\n".format(textwrap.fill( 204 | quest.description, 205 | width=60, subsequent_indent="" 206 | )) 207 | msg += "LINK: {}/v1/quests/{}\n\n".format( 208 | settings.frontend, quest_id 209 | ) 210 | else: 211 | msg += " Labors not associated with a quest:\n\n" 212 | 213 | msg += "Machines with labors:\n" 214 | 215 | for hostname in sorted(info[owner][quest_id]): 216 | if tags[hostname]: 217 | tags_str = "{}".format((", ".join(tags[hostname]))) 218 | else: 219 | tags_str = "no services" 220 | msg += " {} ({})\n".format(hostname, tags_str) 221 | 222 | msg += "\n\n" 223 | 224 | return msg 225 | 226 | 227 | def generate_html_mesg(info, open_quests, owner, tags): 228 | """Generate the HTML version of the 'open labors' email 229 | 230 | Args: 231 | info: the gathered and sorted open labor information 232 | open_quests: the information on open quests 233 | owner: the owner we care about emailing 234 | tags: the host tags information 235 | 236 | Returns: 237 | an HTML doc of the message to be sent to the owner specified 238 | """ 239 | 240 | msg = '' \ 241 | '' 242 | msg += ( 243 | "
" 245 | "Hermes Notifications" 246 | "
" 247 | "

This email is being sent to {} because that is the owner listed\n" 248 | "for the systems with open Hermes labors listed below.

" 249 | "

Due dates, if any, are noted with each quest.

" 250 | "".format(owner) 251 | ) 252 | msg += ( 253 | "

To throw an event manually, you can run the following command " 254 | "on a shell server:

" 255 | "
$ hermes event create [event] --host "
256 |         "[hostname]
" 257 | "

Or you can visit the quests linked below.

".format( 258 | settings.frontend) 259 | ) 260 | for quest_id in info[owner]: 261 | quest = find_quest(open_quests, quest_id) 262 | if quest: 263 | msg += ( 264 | "
" 266 | "QUEST {}
" 267 | "CREATOR: {}
" 268 | ).format( 269 | quest_id, quest.creator 270 | ) 271 | if quest.target_time: 272 | msg += "DUE: {}
".format(quest.target_time) 273 | msg += "DESC:

\"{}\"

".format(quest.description) 274 | msg += "LINK: {}/v1/quests/{}
".format( 275 | settings.frontend, quest_id 276 | ) 277 | else: 278 | msg += ( 279 | "
" 281 | "Labors not " 282 | "associated with a quest:
" 283 | ) 284 | 285 | msg += "

Machines with labors:

" 286 | 287 | msg += "
"
288 |         for hostname in sorted(info[owner][quest_id]):
289 |             if tags[hostname]:
290 |                 tags_str = "{}".format((", ".join(tags[hostname])))
291 |             else:
292 |                 tags_str = "no services"
293 |             msg += "{} ({})\n".format(hostname, tags_str)
294 | 
295 |         msg += "
" 296 | 297 | msg += "" 298 | 299 | return msg 300 | 301 | 302 | if __name__ == "__main__": 303 | main() 304 | -------------------------------------------------------------------------------- /bin/hermes-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import tornado.ioloop 7 | import tornado.httpserver 8 | import tornado.web 9 | 10 | import hermes 11 | from hermes import version 12 | from hermes.app import Application 13 | from hermes.settings import settings 14 | from hermes.plugin import get_hooks 15 | from hermes import models 16 | 17 | 18 | from sqlalchemy.exc import OperationalError 19 | 20 | try: 21 | from raven.contrib.tornado import AsyncSentryClient 22 | raven_installed = True 23 | except ImportError: 24 | raven_installed = False 25 | 26 | sa_log = logging.getLogger("sqlalchemy.engine.base.Engine") 27 | 28 | 29 | def parse_args(): 30 | parser = argparse.ArgumentParser(description="Hermes Web Service") 31 | parser.add_argument("-c", "--config", default="/etc/hermes/server.yaml", 32 | help="Path to config file.") 33 | parser.add_argument( 34 | "-v", "--verbose", action="count", default=0, 35 | help="Increase logging verbosity." 36 | ) 37 | parser.add_argument( 38 | "-q", "--quiet", action="count", default=0, 39 | help="Decrease logging verbosity." 40 | ) 41 | parser.add_argument( 42 | "-V", "--version", action="version", 43 | version="%%(prog)s %s" % hermes.__version__, 44 | help="Display version information." 45 | ) 46 | parser.add_argument( 47 | "-p", "--port", type=int, default=None, help="Override port in config." 48 | ) 49 | return parser.parse_args() 50 | 51 | 52 | def main(): 53 | 54 | args = parse_args() 55 | settings.update_from_config(args.config) 56 | 57 | if args.verbose: 58 | logging.basicConfig(level=logging.DEBUG, format=settings.log_format) 59 | elif args.quiet: 60 | logging.basicConfig(level=logging.ERROR, format=settings.log_format) 61 | else: 62 | logging.basicConfig(level=logging.INFO, format=settings.log_format) 63 | 64 | tornado_settings = { 65 | "static_path": os.path.join(os.path.dirname(hermes.__file__), "static"), 66 | "debug": settings.debug, 67 | "xsrf_cookies": False, 68 | "cookie_secret": settings.secret_key, 69 | } 70 | 71 | # load and register any hooks we have 72 | # hooks = get_hooks([settings.plugin_dir]) 73 | # for hook in hooks: 74 | # logging.debug("registering hook {}".format(hook)) 75 | # models.register_hook(hook) 76 | 77 | my_settings = { 78 | "db_uri": settings.database, 79 | "db_engine": None, 80 | "db_session": None, 81 | "domain": settings.domain, 82 | "count_events": settings.count_events, 83 | } 84 | 85 | application = Application(my_settings=my_settings, **tornado_settings) 86 | 87 | logging.info("HERMES SERVER v{}".format(version.__version__)) 88 | 89 | # If Sentry DSN is set, try to import raven 90 | if settings.sentry_dsn: 91 | if not raven_installed: 92 | logging.warning( 93 | 'Sentry DSN set but raven not installed. Not enabling Sentry.' 94 | ) 95 | else: 96 | logging.info( 97 | 'Sentry DSN set and raven installed. Enabling Sentry.' 98 | ) 99 | application.sentry_client = AsyncSentryClient(settings.sentry_dsn) 100 | else: 101 | logging.info('Sentry DSN not set. Not enabling Sentry.') 102 | 103 | port = args.port or settings.port 104 | 105 | logging.info( 106 | "Starting application server with %d processes on port %d", 107 | settings.num_processes, port 108 | ) 109 | 110 | server = tornado.httpserver.HTTPServer(application) 111 | server.bind(port, address=settings.bind_address) 112 | server.start(settings.num_processes) 113 | try: 114 | tornado.ioloop.IOLoop.instance().start() 115 | except KeyboardInterrupt: 116 | tornado.ioloop.IOLoop.instance().stop() 117 | finally: 118 | print "Bye" 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hermes", 3 | "version": "0.0.0", 4 | "private": true, 5 | "homepage": "https://github.com/diggyk/hermes", 6 | "authors": [ 7 | "Digant Kasundra " 8 | ], 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "_bc", 14 | "test", 15 | "tests" 16 | ], 17 | "dependencies": { 18 | "angular": "~1.4.5", 19 | "angular-animate": "~1.4.5", 20 | "angular-location-update": "*", 21 | "angular-route": "~1.4.5", 22 | "angular-smooth-scroll": "ngSmoothScroll#~1.7.1", 23 | "bootstrap": "~3.3.5", 24 | "d3": "~3.5.6", 25 | "jquery": "~2.1.4", 26 | "raphael": "raphael.js#~2.1.4", 27 | "angular-bootstrap": "~0.14.0", 28 | "angular-cookies": "~1.4.7" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/dev-client.yaml: -------------------------------------------------------------------------------- 1 | # Format for logging output. 2 | # See https://docs.python.org/2/library/logging.html#logrecord-attributes 3 | # Type: str 4 | log_format: "%(asctime)-15s\t%(levelname)s\t%(message)s" 5 | 6 | # Passing debug option down tornado. Useful for development to 7 | # automatically reload code. 8 | # Type: bool 9 | debug: true 10 | 11 | # The location of the Hermes server 12 | hermes_server: "http://localhost:10901" -------------------------------------------------------------------------------- /config/dev.yaml: -------------------------------------------------------------------------------- 1 | # Format for logging output. 2 | # See https://docs.python.org/2/library/logging.html#logrecord-attributes 3 | # Type: str 4 | log_format: "%(asctime)-15s\t%(levelname)s\t%(message)s" 5 | 6 | # Number of worker processes to fork for receving requests. This option 7 | # is mutually exclusive with debug. 8 | # Type: int 9 | num_processes: 1 10 | 11 | # The port to listen to requests on. 12 | # Type: int 13 | port: 10901 14 | 15 | # The address to bind to. By default it listens on all interfaces. 16 | # Type: string 17 | bind_address: "127.0.0.1" 18 | 19 | # Passing debug option down tornado. Useful for development to 20 | # automatically reload code. 21 | # Type: bool 22 | debug: true 23 | 24 | # The domain name to append to user names if not specified 25 | domain: "dropbox.com" 26 | 27 | # Specifies whether to use XSRF headers/cookies for API calls. Default: true 28 | # Type: bool 29 | api_xsrf_enabled: false 30 | 31 | # Takes a SqlAlchemy URL to the database. More details 32 | # can be found at the following URL: 33 | # http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls 34 | # 35 | # Type: str 36 | database: "mysql://localhost:3306/emsdb?user=emsdb&passwd=testpw" 37 | 38 | # The server to use to host queries 39 | query_server: "http://localhost:5353/api/query" 40 | 41 | # Slack integration (optional) 42 | # slack_webhook: "https://hooks.slack.com/services/" 43 | # slack_proxyhost: "proxyserver:port" 44 | 45 | # Email notifications 46 | email_notifications: false 47 | # email_sender_address: "hermes@localhost" 48 | 49 | # Always send email notifications to this comma seperated list 50 | # email_always_copy: "admin@company.com" 51 | 52 | # This is the expiration (in seconds) of auth_tokens used for API calls 53 | # Type: int 54 | auth_token_expiry: 600 55 | 56 | # Sentry DSN if using Sentry to log exceptions. 57 | # sentry_dsn: 58 | 59 | # Additional plugin directory (full path) 60 | # plugin_dir: 61 | 62 | # Specify the org identifier for FullStory integration 63 | # fullstory_id: 64 | 65 | # StrongPOC integration (optional) 66 | # strongpoc_server: 67 | 68 | # Specify the environment - dev is default, set to prod for production 69 | # environment: "dev" 70 | 71 | # if environment is dev, send emails to the following email address instead 72 | # of actual recipients 73 | # dev_email_recipient: 74 | -------------------------------------------------------------------------------- /db/bootstrap.sql: -------------------------------------------------------------------------------- 1 | # Dump of table event_types 2 | # ------------------------------------------------------------ 3 | 4 | LOCK TABLES `event_types` WRITE; 5 | 6 | INSERT INTO `event_types` (`id`, `category`, `state`, `description`) 7 | VALUES 8 | (1,'system-reboot','required','This system requires a reboot.'), 9 | (2,'system-reboot','completed','This system rebooted.'), 10 | (3,'system-maintenance','required','This system requires maintenance.'), 11 | (4,'system-maintenance','ready','This system is ready for maintenance.'), 12 | (5,'system-maintenance','completed','System maintenance completed.'); 13 | 14 | UNLOCK TABLES; 15 | 16 | # Dump of table fates 17 | # ------------------------------------------------------------ 18 | 19 | LOCK TABLES `fates` WRITE; 20 | 21 | INSERT INTO `fates` (`id`, `creation_type_id`, `completion_type_id`, `follows_id`, `for_creator`, `for_owner`, `description`) 22 | VALUES 23 | (1, 1, 2, NULL, 0, 1, 'A system that needs a reboot can be cleared by rebooting the machine.'), 24 | (2, 3, 4, NULL, 0, 1, 'System must be released using \"cloudbox release\" so maintenance can be carried out.'), 25 | (3, 4, 5, 2, 1, 0, 'Maintenance must be performed on a system that is prepped.'), 26 | (4, 1, 4, NULL, 0, 1, 'A system that needs a reboot can also just be released.'); 27 | 28 | 29 | UNLOCK TABLES; 30 | -------------------------------------------------------------------------------- /db/truncate_tables.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS = 0; 2 | TRUNCATE TABLE quests; 3 | TRUNCATE TABLE labors; 4 | TRUNCATE TABLE hosts; 5 | TRUNCATE TABLE events; 6 | SET FOREIGN_KEY_CHECKS = 1; 7 | -------------------------------------------------------------------------------- /db/update_to_046.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `fates` ADD `for_owner` INT(1) NOT NULL DEFAULT 1 AFTER `follows_id`; 2 | ALTER TABLE `fates` ADD `for_creator` INT(1) NOT NULL DEFAULT 0 AFTER `follows_id`; 3 | ALTER TABLE `labors` ADD `for_owner` INT(1) NOT NULL DEFAULT 1 AFTER `host_id`; 4 | ALTER TABLE `labors` ADD `for_creator` INT(1) NOT NULL DEFAULT 0 AFTER `host_id`; 5 | 6 | UPDATE `fates` SET `for_owner`=0 where `id`=2; 7 | UPDATE `fates` SET `for_creator`=1 where `id`=2; 8 | 9 | UPDATE `labors` SET `for_owner`=1, `for_creator`=0 where `starting_labor_id` IS NULL; 10 | UPDATE `labors` SET `for_owner`=0, `for_creator`=1 where `starting_labor_id` IS NOT NULL; 11 | -------------------------------------------------------------------------------- /db/update_to_05.sql: -------------------------------------------------------------------------------- 1 | TRUNCATE TABLE `fates`; 2 | ALTER TABLE `fates` DROP FOREIGN KEY `fates_ibfk_2`; 3 | ALTER TABLE `fates` DROP `completion_type_id`; 4 | 5 | INSERT INTO fates 6 | VALUES 7 | (1,1,NULL, 0, 1, 'Reboot or release the system.'); 8 | 9 | INSERT INTO fates 10 | VALUES 11 | (2,2,1, 0, 1, 'A reboot finishes labors.'); 12 | 13 | INSERT INTO fates 14 | VALUES 15 | (3,3,NULL, 0, 1, 'Release or acknowledge downtime'); 16 | 17 | INSERT INTO fates 18 | VALUES 19 | (4,4,3, 0, 1, 'Perform maintenance'); 20 | 21 | INSERT INTO fates 22 | VALUES 23 | (5,5,4, 1, 0, 'Maintenance completed'); 24 | 25 | ALTER TABLE `labors` ADD `fate_id` INT(11) NOT NULL DEFAULT 0 AFTER `starting_labor_id`; 26 | ALTER TABLE `labors` ADD INDEX `ix_labors_fate_id` (`fate_id`); 27 | 28 | UPDATE `labors` SET `fate_id`=1; 29 | ALTER TABLE `labors` ADD FOREIGN KEY (`fate_id`) REFERENCES `fates` (`id`); 30 | 31 | UPDATE `labors` l 32 | SET `fate_id` = ( 33 | SELECT f.id 34 | FROM `events` e, `event_types` et, `fates` f 35 | WHERE l.creation_event_id = e.id AND e.event_type_id = et.id AND 36 | f.creation_type_id = et.id 37 | ); 38 | 39 | INSERT INTO fates 40 | VALUES 41 | (6,4,1, 0, 1, 'A release finishes labors'); 42 | 43 | -------------------------------------------------------------------------------- /db/update_to_051.sql: -------------------------------------------------------------------------------- 1 | SET foreign_key_checks=0; 2 | DROP TABLE IF EXISTS event_types; 3 | 4 | CREATE TABLE event_types ( 5 | id int(11) NOT NULL AUTO_INCREMENT, 6 | category varchar(64) COLLATE utf8_unicode_ci NOT NULL, 7 | state varchar(32) COLLATE utf8_unicode_ci NOT NULL, 8 | description varchar(1024) COLLATE utf8_unicode_ci DEFAULT NULL, 9 | PRIMARY KEY (id), 10 | UNIQUE KEY _category_state_uc (category,state), 11 | KEY event_type_idx (id,category,state) 12 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 13 | 14 | LOCK TABLES event_types WRITE; 15 | /*!40000 ALTER TABLE event_types DISABLE KEYS */; 16 | 17 | INSERT INTO event_types (id, category, state, description) 18 | VALUES 19 | (1,'system-reboot','required','System requires a reboot'), 20 | (2,'system-reboot','completed','System rebooted'), 21 | (3,'system-maintenance','required','System requires maintenance'), 22 | (4,'system-maintenance','ready','System ready for maintenance'), 23 | (5,'system-maintenance','completed','System maintenance completed'), 24 | (6,'system-maintenance','acknowledge','Acknowledge system maintenance'), 25 | (7,'system-maintenance','cancel','Cancel system maintenance'); 26 | 27 | /*!40000 ALTER TABLE event_types ENABLE KEYS */; 28 | UNLOCK TABLES; 29 | 30 | 31 | # Dump of table fates 32 | # ------------------------------------------------------------ 33 | 34 | DROP TABLE IF EXISTS fates; 35 | 36 | CREATE TABLE fates ( 37 | id int(11) NOT NULL AUTO_INCREMENT, 38 | creation_type_id int(11) NOT NULL, 39 | follows_id int(11) DEFAULT NULL, 40 | for_creator int(1) NOT NULL DEFAULT '0', 41 | for_owner int(1) NOT NULL DEFAULT '1', 42 | description varchar(2048) COLLATE utf8_unicode_ci DEFAULT NULL, 43 | PRIMARY KEY (id), 44 | UNIQUE KEY _creation_completion_uc (creation_type_id,follows_id), 45 | KEY ix_fates_creation_type_id (creation_type_id), 46 | KEY fate_idx (id,creation_type_id,follows_id), 47 | KEY ix_fates_follows_id (follows_id), 48 | CONSTRAINT fates_ibfk_1 FOREIGN KEY (creation_type_id) REFERENCES event_types (id), 49 | CONSTRAINT fates_ibfk_3 FOREIGN KEY (follows_id) REFERENCES fates (id) 50 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 51 | 52 | LOCK TABLES fates WRITE; 53 | /*!40000 ALTER TABLE fates DISABLE KEYS */; 54 | 55 | INSERT INTO fates (id, creation_type_id, follows_id, for_creator, for_owner, description) 56 | VALUES 57 | (1,1,NULL,0,1,'Reboot or release the system'), 58 | (2,2,1,0,1,'System rebooted'), 59 | (3,3,NULL,0,1,'Release or acknowledge downtime'), 60 | (4,4,3,1,0,'Perform maintenance'), 61 | (5,5,4,1,0,'Maintenance completed'), 62 | (6,6,3,1,0,'Perform online maintenance'), 63 | (7,5,6,1,0,'Maintenance completed'), 64 | (8,7,3,1,0,'Maintenance cancelled'), 65 | (9,7,4,1,0,'Maintenance cancelled'), 66 | (10,7,6,1,0,'Maintenance cancelled'); 67 | 68 | /*!40000 ALTER TABLE fates ENABLE KEYS */; 69 | UNLOCK TABLES; 70 | SET foreign_key_checks=1; -------------------------------------------------------------------------------- /db/update_to_0511.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `labors` ADD `closing_fate_id` INT(11) DEFAULT NULL AFTER `fate_id`; 2 | ALTER TABLE `labors` ADD INDEX `ix_labors_closing_fate_id` (`closing_fate_id`); 3 | 4 | UPDATE `labors` SET `closing_fate_id`=4 WHERE `fate_id`=3; 5 | UPDATE `labors` SET `closing_fate_id`=5 WHERE `fate_id`=4; 6 | ALTER TABLE `labors` ADD FOREIGN KEY (`closing_fate_id`) REFERENCES `fates` (`id`); 7 | -------------------------------------------------------------------------------- /db/update_to_06.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `event_types` ADD `restricted` INT(1) NOT NULL DEFAULT '0' AFTER `description`; 2 | -------------------------------------------------------------------------------- /db/update_to_0726.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `events` MODIFY `note` text; 2 | -------------------------------------------------------------------------------- /db/update_to_0_5_15.sql: -------------------------------------------------------------------------------- 1 | drop index _creation_completion_uc on fates; -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/NetworkSourceofTruth.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/NetworkSourceofTruth.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/NetworkSourceofTruth" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/NetworkSourceofTruth" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api-ref.rst: -------------------------------------------------------------------------------- 1 | .. _api-ref: 2 | 3 | ============= 4 | API Reference 5 | ============= 6 | 7 | .. autotornado:: hermes.app:Application() 8 | :endpoints: 9 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ***************** 3 | 4 | Hermes is designed as an API first so anything possible in the Web UI 5 | or command line tools would be available here. 6 | 7 | Authentication 8 | -------------- 9 | 10 | Authentication is still in the works. Right now, Hermes API is expected to sit behind some kind of authenticating proxy. 11 | 12 | Requests 13 | -------- 14 | 15 | In addition to the authentication header above all ``POST``/``PUT`` requests 16 | will be sent as json rather than form data and should include the header ``Content-Type: application/json`` 17 | 18 | 19 | Responses 20 | --------- 21 | All responses will be in ``JSON`` format along with the header 22 | ``Content-Type: application/json`` set. 23 | 24 | The ``JSON`` payload will be in one of two potential structures and will always contain a ``status`` field to distinguish between them. If the ``status`` field 25 | has a value of ``"ok"`` or ``"created"``, then the request (or creation, respectively) was successful and the response will 26 | be available the remaining fields. 27 | 28 | .. sourcecode:: javascript 29 | 30 | { 31 | "status": "ok", 32 | "id": 1, 33 | ... 34 | } 35 | 36 | If the ``status`` field has a value of ``"error"`` then the response failed 37 | in some way. You will have access to the error from the ``error`` field which 38 | will contain an error ``code`` and ``message``. 39 | 40 | .. sourcecode:: javascript 41 | 42 | { 43 | "status": "error", 44 | "error": { 45 | "code": 404, 46 | "message": "Resource not found." 47 | } 48 | } 49 | 50 | Pagination 51 | ---------- 52 | 53 | Most, if not all, responses that return a list of resources will support pagination. If the 54 | ``data`` object on the response has a ``total`` attribute then the endpoint supports pagination. 55 | When making a request against this endpoint ``limit`` and ``offset`` query parameters are 56 | supported. 57 | 58 | An example response for querying the ``sites`` endpoint might look like: 59 | 60 | .. sourcecode:: javascript 61 | 62 | { 63 | "status": "ok", 64 | "hosts": [ 65 | { 66 | "id": 1 67 | "hostname": "example", 68 | "href": "/api/v1/hostname/example", 69 | } 70 | ], 71 | "limit": 10, 72 | "offset": 0, 73 | "total": 1 74 | } 75 | 76 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Network Source of Truth documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Dec 26 11:33:34 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import sphinx_rtd_theme 18 | 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinxcontrib.httpdomain", 35 | "sphinxcontrib.autohttp.tornado", 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix of source filenames. 42 | source_suffix = '.rst' 43 | 44 | # The encoding of source files. 45 | #source_encoding = 'utf-8-sig' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'Hermes' 52 | copyright = u'2015, Dropbox, Inc.' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | def get_version(): 60 | from hermes import __version__ 61 | return __version__ 62 | 63 | version = get_version() 64 | # The full version, including alpha/beta/rc tags. 65 | release = version 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | #keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | html_theme = "sphinx_rtd_theme" 109 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 110 | 111 | # The theme to use for HTML and HTML Help pages. See the documentation for 112 | # a list of builtin themes. 113 | #html_theme = 'default' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | #html_theme_options = {} 119 | 120 | # Add any paths that contain custom themes here, relative to this directory. 121 | #html_theme_path = [] 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | #html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | #html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | #html_logo = None 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | #html_favicon = None 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # Add any extra paths that contain custom files (such as robots.txt or 145 | # .htaccess) here, relative to this directory. These files are copied 146 | # directly to the root of the documentation. 147 | #html_extra_path = [] 148 | 149 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 150 | # using the given strftime format. 151 | #html_last_updated_fmt = '%b %d, %Y' 152 | 153 | # If true, SmartyPants will be used to convert quotes and dashes to 154 | # typographically correct entities. 155 | #html_use_smartypants = True 156 | 157 | # Custom sidebar templates, maps document names to template names. 158 | #html_sidebars = {} 159 | 160 | # Additional templates that should be rendered to pages, maps page names to 161 | # template names. 162 | #html_additional_pages = {} 163 | 164 | # If false, no module index is generated. 165 | #html_domain_indices = True 166 | 167 | # If false, no index is generated. 168 | #html_use_index = True 169 | 170 | # If true, the index is split into individual pages for each letter. 171 | #html_split_index = False 172 | 173 | # If true, links to the reST sources are added to the pages. 174 | #html_show_sourcelink = True 175 | 176 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 177 | #html_show_sphinx = True 178 | 179 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 180 | #html_show_copyright = True 181 | 182 | # If true, an OpenSearch description file will be output, and all pages will 183 | # contain a tag referring to it. The value of this option must be the 184 | # base URL from which the finished HTML is served. 185 | #html_use_opensearch = '' 186 | 187 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 188 | #html_file_suffix = None 189 | 190 | # Output file base name for HTML help builder. 191 | htmlhelp_basename = 'Hermesdoc' 192 | 193 | 194 | # -- Options for LaTeX output --------------------------------------------- 195 | 196 | latex_elements = { 197 | # The paper size ('letterpaper' or 'a4paper'). 198 | #'papersize': 'letterpaper', 199 | 200 | # The font size ('10pt', '11pt' or '12pt'). 201 | #'pointsize': '10pt', 202 | 203 | # Additional stuff for the LaTeX preamble. 204 | #'preamble': '', 205 | } 206 | 207 | # Grouping the document tree into LaTeX files. List of tuples 208 | # (source start file, target name, title, 209 | # author, documentclass [howto, manual, or own class]). 210 | latex_documents = [ 211 | ('index', 'Hermes.tex', u'Hermes Documentation', 212 | u'Digant C Kasundra', 'manual'), 213 | ] 214 | 215 | # The name of an image file (relative to this directory) to place at the top of 216 | # the title page. 217 | #latex_logo = None 218 | 219 | # For "manual" documents, if this is true, then toplevel headings are parts, 220 | # not chapters. 221 | #latex_use_parts = False 222 | 223 | # If true, show page references after internal links. 224 | #latex_show_pagerefs = False 225 | 226 | # If true, show URL addresses after external links. 227 | #latex_show_urls = False 228 | 229 | # Documents to append as an appendix to all manuals. 230 | #latex_appendices = [] 231 | 232 | # If false, no module index is generated. 233 | #latex_domain_indices = True 234 | 235 | 236 | # -- Options for manual page output --------------------------------------- 237 | 238 | # One entry per manual page. List of tuples 239 | # (source start file, name, description, authors, manual section). 240 | man_pages = [ 241 | ('index', 'hermes', u'Hermes Documentation', 242 | [u'Digant C Kasundra'], 1) 243 | ] 244 | 245 | # If true, show URL addresses after external links. 246 | #man_show_urls = False 247 | 248 | 249 | # -- Options for Texinfo output ------------------------------------------- 250 | 251 | # Grouping the document tree into Texinfo files. List of tuples 252 | # (source start file, target name, title, author, 253 | # dir menu entry, description, category) 254 | texinfo_documents = [ 255 | ('index', 'Hermes', u'Hermes Documentation', 256 | u'Digant C Kasundra', 'Hermes', 'One line description of project.', 257 | 'Miscellaneous'), 258 | ] 259 | 260 | # Documents to append as an appendix to all manuals. 261 | #texinfo_appendices = [] 262 | 263 | # If false, no module index is generated. 264 | #texinfo_domain_indices = True 265 | 266 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 267 | #texinfo_show_urls = 'footnote' 268 | 269 | # If true, do not generate a @detailmenu in the "Top" node's menu. 270 | #texinfo_no_detailmenu = False 271 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | ============= 4 | Configuration 5 | ============= 6 | 7 | .. literalinclude:: ../config/dev.yaml 8 | :language: yaml 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Hermes 2 | =================================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | intro 10 | config 11 | api 12 | api-ref 13 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Hermes logs events, generates tasks, and tracks tasks in logical groups. 5 | 6 | Terminology 7 | =========== 8 | 9 | Rather than mimic the overloaded and overused terminology typically used, and in keeping with the Dropbox principal of "cupcake," Hermes adopts a more interesting language. 10 | 11 | Events and Event Types 12 | ---------------------- 13 | 14 | Events double as journal entries, logging system activities like server restarts, and requests for action, such as a need to restart or turn off a server. 15 | 16 | As journal entries, events provide an audit trail and can potentially be used to track a range of activities. As request entries, events can initialize labors and subsequent events would close these labors. 17 | 18 | Each event must be of a predefined event type. An event type consists of a category and state, the combination of which provides meaningful grouping and definition: 19 | :: 20 | ID CATEGORY STATE 21 | [1] system-reboot required 22 | [2] system-reboot completed 23 | [3] system-maintenance required 24 | [4] system-maintenance ready 25 | [5] system-maintenance completed 26 | 27 | 28 | Event types are often written simply as ``category-state``, such as ``system-reboot-required``. 29 | 30 | An individual event entry consists of the event type, the host, and the time of occurrence. 31 | 32 | Labors 33 | ------ 34 | 35 | Labors represent tasks that need to be performed or outstanding issues that need to be addressed for a host. All labors are created and closed as the result of events. 36 | 37 | Labors are usually referred to by the event which triggered its creation, so a ``system-reboot-required`` event creates a ``system-reboot-required`` labor. 38 | 39 | Fates 40 | ----- 41 | Basics 42 | `````` 43 | The fates define how labors are created and completed. A typical fate will specify which event type will result in the creation of a labor for the host, and which event type will close labors for a host. 44 | :: 45 | [1] system-reboot-required => system-reboot-completed 46 | 47 | 48 | Chained Fates 49 | ````````````` 50 | An ``intermediate`` flag in the definition of a fate indicates if the fate only applies to existing labors. This allows fates to be chained together to essentially create a workflow engine. 51 | 52 | For example: 53 | :: 54 | [1] system-maintenance-required => system-maintenance-ready 55 | [2] system-maintenance-ready => system-maintenance-completed 56 | 57 | 58 | (with the second fate being flagged as an intermediate) would essentially mean: 59 | :: 60 | system-maintenance-required => system-maintenance-ready => system-maintenance-completed 61 | 62 | In this example, an event of type ``system-maintenance-ready`` only creates a labor if an existing labor created by an event of type ``system-maintenance-required`` was present. 63 | 64 | Choose Your Own Adventure 65 | ````````````````````````` 66 | 67 | Fates can allow multiple ways to resolve a labor. 68 | :: 69 | [1] puppet-restart-required => puppet-restart-completed 70 | [2] puppet-restart-required => system-restart-completed 71 | 72 | In this example, a labor created by the event ``puppet-restart-required`` can be completed by either a ``puppet-restart-completed`` event, or a ``system-restart-completed`` event. 73 | 74 | Quests 75 | ------ 76 | 77 | Quests are collections of labors, making tracking and reporting of progress much easier. 78 | 79 | For example, when a security fix is released that requires all web servers to be restarted, a quest can be created with a ``system-restart-required`` labor for all the hosts. 80 | 81 | Quests will eventually contain information to outside references, such as Jira tickets. 82 | 83 | Status 84 | ====== 85 | 86 | Development can be tracked at GitHub_ and Travis_CI_ 87 | 88 | .. _GitHub: https://github.com/dropbox/hermes 89 | .. _Travis_CI: https://travis-ci.org/dropbox/hermes 90 | 91 | TODOS 92 | ===== 93 | 94 | Deletion Support 95 | ---------------- 96 | 97 | Currently, nothing can be deleted through the API or client. It would be nice to be able to delete event-types and 98 | fates. 99 | -------------------------------------------------------------------------------- /examples/ex1/create-quest: -------------------------------------------------------------------------------- 1 | cat examples/ex1/servers.list | bin/hermes -c config/dev-client.yaml -v quest create system-reboot required -d "The quest of ritual healing." 2 | -------------------------------------------------------------------------------- /examples/ex1/reboot-complete: -------------------------------------------------------------------------------- 1 | for host in $( cat examples/ex1/servers.list | tail -n 20 ); do bin/hermes -c config/dev-client.yaml event create $host system-reboot completed; done 2 | -------------------------------------------------------------------------------- /examples/ex1/reboot-complete-remaining: -------------------------------------------------------------------------------- 1 | for host in $( cat examples/ex1/servers.list | head -n 10 ); do bin/hermes -c config/dev-client.yaml event create $host system-reboot completed; done 2 | -------------------------------------------------------------------------------- /examples/ex1/servers.list: -------------------------------------------------------------------------------- 1 | server-10871 2 | server-12178 3 | server-13406 4 | server-13533 5 | server-14488 6 | server-18994 7 | server-19287 8 | server-19753 9 | server-20998 10 | server-21523 11 | server-22306 12 | server-22565 13 | server-22772 14 | server-23445 15 | server-25320 16 | server-25852 17 | server-27560 18 | server-27857 19 | server-29767 20 | server-31114 21 | server-31462 22 | server-3186 23 | server-31902 24 | server-32066 25 | server-3527 26 | server-4393 27 | server-5957 28 | server-6985 29 | server-7121 30 | server-8090 31 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Build tasks are broken up as either top-level tasks, for example `build`, 3 | * or sub-tasks, namespaced with a top-level task name and a color as a prefix, 4 | * for example, `build:js` 5 | * 6 | * You'll likely want to have gulp installed globally if you're using it regularly 7 | * though you'll be able to run it fron `node_modules/.bin/gulp` if you don't 8 | * use it often. 9 | * 10 | * Top Level Tasks 11 | * --------------- 12 | * gulp clean - Remove built assets 13 | * gulp build - Build all static assets for distribution 14 | * gulp lint - Lint JavaScript and CSS files 15 | * gulp bower - Update local cache for web dependencies 16 | */ 17 | 18 | var gulp = require('gulp'); 19 | 20 | // Plugin Imports 21 | var jshint = require('gulp-jshint'); 22 | var concat = require('gulp-concat'); 23 | var ngAnnotate = require('gulp-ng-annotate'); 24 | var uglify = require('gulp-uglify'); 25 | var rename = require('gulp-rename'); 26 | var minifyCss = require('gulp-minify-css'); 27 | var csslint = require('gulp-csslint'); 28 | var mainBowerFiles = require('main-bower-files'); 29 | var bower = require('gulp-bower'); 30 | var sort = require('gulp-sort'); 31 | var del = require('del'); 32 | var watch = require('gulp-watch'); 33 | var less = require('gulp-less'); 34 | var path = require('path'); 35 | 36 | var SRC_ROOT = './hermes/webapp/src/'; 37 | var BUILD_DEST = './hermes/webapp/build/'; 38 | var VENDOR_ROOT = "./_bc/"; 39 | 40 | var JS_MAIN_SRC = SRC_ROOT + 'js/hermesApp.js'; 41 | var JS_SRC = SRC_ROOT + 'js/**/*.js'; 42 | var STYLE_SRC = SRC_ROOT + 'css/**/*.less'; 43 | var IMAGE_SRC = SRC_ROOT + 'img/**'; 44 | var HTML_SRC = SRC_ROOT + "**/*.html"; 45 | var BS_FONT_SRC = VENDOR_ROOT + "bootstrap/dist/fonts/*.woff2"; 46 | 47 | 48 | /** 49 | * Task to lint JavaScript files. 50 | */ 51 | gulp.task('lint:js', function() { 52 | return gulp.src([JS_MAIN_SRC, JS_SRC]) 53 | .pipe(jshint()) 54 | .pipe(jshint.reporter('jshint-stylish')); 55 | }); 56 | 57 | 58 | /** 59 | * Task to lint CSS files. 60 | */ 61 | gulp.task('lint:style', function() { 62 | return gulp.src(STYLE_SRC) 63 | .pipe(csslint()) 64 | .pipe(csslint.reporter()); 65 | }); 66 | 67 | 68 | /** 69 | * Top level Task to run all lint tasks. 70 | */ 71 | gulp.task('lint', ['lint:js', 'lint:style']); 72 | 73 | 74 | /** 75 | * Updates the local cache of bower dependencies 76 | */ 77 | gulp.task('bower', function() { 78 | return bower({ cmd: 'update'}); 79 | }); 80 | 81 | 82 | /** 83 | * Task to build JavaScript files. 84 | */ 85 | gulp.task('build:js', function() { 86 | return gulp.src([JS_MAIN_SRC, JS_SRC]) 87 | .pipe(ngAnnotate()) 88 | //.pipe(sort()) 89 | .pipe(concat('app.js')) 90 | .pipe(gulp.dest((BUILD_DEST + 'js'))) 91 | .pipe(uglify()) 92 | .pipe(rename('app.min.js')) 93 | .pipe(gulp.dest((BUILD_DEST + 'js'))); 94 | }); 95 | 96 | /** 97 | * Task to build our HTML files 98 | */ 99 | gulp.task('build:html', function() { 100 | return gulp.src(HTML_SRC) 101 | .pipe(gulp.dest((BUILD_DEST))) 102 | }); 103 | 104 | 105 | /** 106 | * Task to build CSS files. 107 | */ 108 | gulp.task('build:style', function() { 109 | return gulp.src(STYLE_SRC) 110 | .pipe(less({ 111 | paths: [ path.join(__dirname, 'less', 'includes') ] 112 | })) 113 | .pipe(sort()) 114 | .pipe(concat('hermes.css')) 115 | .pipe(gulp.dest((BUILD_DEST + 'css'))) 116 | .pipe(minifyCss()) 117 | .pipe(rename('hermes.min.css')) 118 | .pipe(gulp.dest((BUILD_DEST + 'css'))); 119 | }); 120 | 121 | /** 122 | * Task to process less files for bootstrap 123 | */ 124 | gulp.task('build:bsless', function() { 125 | return gulp.src('_bc/bootstrap/less/bootstrap.less') 126 | .pipe(less({ 127 | paths: [ path.join(__dirname, 'less', 'includes') ] 128 | })) 129 | .pipe(gulp.dest(BUILD_DEST + 'css')); 130 | }); 131 | 132 | 133 | /** 134 | * Task to "build" images. While we're not doing anything interesting 135 | * now this opens up the option for building sprites if needed. This 136 | * also keeps our src separate from our build where we'll do things like 137 | * hash built files eventually. 138 | */ 139 | gulp.task('build:images', function() { 140 | return gulp.src(IMAGE_SRC) 141 | .pipe(gulp.dest((BUILD_DEST + 'img'))) 142 | }); 143 | 144 | gulp.task('build:fonts', function() { 145 | return gulp.src(BS_FONT_SRC) 146 | .pipe(gulp.dest((BUILD_DEST + 'fonts'))) 147 | }); 148 | 149 | /** 150 | * Uses bower to install the "main" files into our build. In most cases 151 | * the "main" files are manually specified in the `overrides` section 152 | * of bower.json 153 | */ 154 | gulp.task('build:3rdparty', ['bower'], function() { 155 | return gulp.src(mainBowerFiles(), {base: '_bc'}) 156 | .pipe(gulp.dest(BUILD_DEST + 'vendor')) 157 | }); 158 | 159 | 160 | /** 161 | * Create a hashed version of all built files. This is currently 162 | * just a placeholder and hasn't been finished yet. 163 | */ 164 | gulp.task('build:revisions', ['build:html', 'build:js', 'build:bsless', 'build:style', 'build:images', 'build:fonts', 'build:3rdparty'], function() { 165 | // TODO(gary): Do. 166 | return gulp.src(BUILD_DEST); 167 | }); 168 | 169 | 170 | /** 171 | * Super task to build everything. 172 | */ 173 | gulp.task('build', ['build:revisions']); 174 | 175 | 176 | /** 177 | * Remove the build directory 178 | */ 179 | gulp.task('clean', function(cb) { 180 | del([BUILD_DEST], cb); 181 | }); 182 | 183 | 184 | gulp.task('watch', ['build'], function() { 185 | gulp.watch([JS_MAIN_SRC, JS_SRC, STYLE_SRC, IMAGE_SRC, HTML_SRC], ['build']); 186 | }); 187 | -------------------------------------------------------------------------------- /hermes/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__ 2 | -------------------------------------------------------------------------------- /hermes/app.py: -------------------------------------------------------------------------------- 1 | 2 | import tornado 3 | import mimetypes 4 | 5 | from .routes import HANDLERS 6 | 7 | class Application(tornado.web.Application): 8 | def __init__(self, *args, **kwargs): 9 | 10 | kwargs["handlers"] = HANDLERS 11 | self.my_settings = kwargs.pop("my_settings", {}) 12 | super(Application, self).__init__(*args, **kwargs) 13 | -------------------------------------------------------------------------------- /hermes/exc.py: -------------------------------------------------------------------------------- 1 | from tornado.web import HTTPError 2 | 3 | class Error(Exception): 4 | """ Baseclass for Hermes Exceptions.""" 5 | 6 | class ModelError(Error): 7 | """ Baseclass for Hermes Model Exceptions.""" 8 | 9 | class ValidationError(ModelError): 10 | """ Raised when validation fails on a model.""" 11 | 12 | class BaseHttpError(HTTPError): 13 | def __init__(self, log_message, *args, **kwargs): 14 | HTTPError.__init__( 15 | self, self.status_code, log_message, *args, **kwargs 16 | ) 17 | 18 | class BadRequest(BaseHttpError): status_code = 400 19 | class Unauthorized(BaseHttpError): status_code = 401 20 | class Forbidden(BaseHttpError): status_code = 403 21 | class NotFound(BaseHttpError): status_code = 404 22 | class Conflict(BaseHttpError): status_code = 409 23 | -------------------------------------------------------------------------------- /hermes/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'digant' 2 | -------------------------------------------------------------------------------- /hermes/handlers/frontends.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from tornado import web 5 | 6 | # Logging object 7 | log = logging.getLogger(__name__) 8 | 9 | class NgApp(web.RequestHandler): 10 | """Our generic handler to serve out the root of our AngularJS app.""" 11 | def get(self): 12 | self.render( 13 | os.path.join(os.path.dirname(__file__), "../webapp/build/index.html") 14 | ) 15 | -------------------------------------------------------------------------------- /hermes/handlers/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import requests 4 | import sys 5 | from tornado.web import RequestHandler, urlparse, HTTPError 6 | from tornado.escape import utf8 7 | from werkzeug.http import parse_options_header 8 | 9 | from .. import exc 10 | from .. import models 11 | from ..settings import settings 12 | 13 | 14 | # Logging object 15 | log = logging.getLogger(__name__) 16 | 17 | # If raven library is available, modify the base handler to support Sentry. 18 | try: 19 | from raven.contrib.tornado import SentryMixin 20 | except ImportError: 21 | pass 22 | else: 23 | class SentryHandler(SentryMixin, RequestHandler): 24 | pass 25 | RequestHandler = SentryHandler 26 | 27 | 28 | API_VER = "/api/v1" 29 | 30 | 31 | class BaseHandler(RequestHandler): 32 | def initialize(self): 33 | 34 | my_settings = self.application.my_settings 35 | 36 | # Lazily build the engine and session for support of multi-processing 37 | if my_settings.get("db_engine") is None: 38 | my_settings["db_engine"] = models.get_db_engine(my_settings.get("db_uri")) 39 | models.Session.configure(bind=my_settings["db_engine"]) 40 | my_settings["db_session"] = models.Session 41 | 42 | self.engine = my_settings.get("db_engine") 43 | self.session = my_settings.get("db_session")() 44 | self.domain = my_settings.get("domain") 45 | self.count_events = my_settings.get("count_events", True) 46 | 47 | def on_finish(self): 48 | self.session.close() 49 | 50 | def get_current_user(self): 51 | """Default global user fetch by user_auth_header.""" 52 | 53 | # Fetch the email address from the auth_header (e.g. X-Hermes-Email) 54 | auth_header = settings.user_auth_header 55 | log.debug(' fetching auth_header: %s' % auth_header) 56 | email = self.request.headers.get(auth_header) 57 | 58 | if email is not None: 59 | log.debug('auth_header authenticated user: %s' % email) 60 | return email 61 | return None 62 | 63 | def prepare(self): 64 | log.debug('BaseHandler.prepare()') 65 | 66 | 67 | class FeHandler(BaseHandler): 68 | 69 | def prepare(self): 70 | BaseHandler.prepare(self) 71 | # Need to access token to set Cookie. 72 | # self.xsrf_token 73 | 74 | def render_template(self, template_name, **kwargs): 75 | template = self.application.my_settings["template_env"].get_template( 76 | template_name 77 | ) 78 | content = template.render(kwargs) 79 | return content 80 | 81 | def render(self, template_name, **kwargs): 82 | context = {} 83 | context.update(self.get_template_namespace()) 84 | context.update(kwargs) 85 | self.write(self.render_template(template_name, **context)) 86 | 87 | def write_error(self, status_code, **kwargs): 88 | message = "An unknown problem has occured :(" 89 | if "exc_info" in kwargs: 90 | inst = kwargs["exc_info"][1] 91 | if isinstance(inst, HTTPError): 92 | message = inst.log_message 93 | else: 94 | message = str(inst) 95 | 96 | # Pass context to the error template 97 | self.render("error.html", code=status_code, message=message) 98 | 99 | 100 | class ApiHandler(BaseHandler): 101 | def initialize(self): 102 | BaseHandler.initialize(self) 103 | self._jbody = None 104 | self.href_prefix = None 105 | 106 | @property 107 | def jbody(self): 108 | if self._jbody is None: 109 | if self.request.body: 110 | self._jbody = json.loads(self.request.body) 111 | else: 112 | self._jbody = {} 113 | return self._jbody 114 | 115 | def get_pagination_values(self, max_limit=None): 116 | if self.get_arguments("limit"): 117 | if self.get_arguments("limit")[0] == "all": 118 | limit = None 119 | else: 120 | limit = int(self.get_arguments("limit")[0]) 121 | else: 122 | limit = 10 123 | offset = int((self.get_arguments("offset") or [0])[0]) 124 | 125 | if max_limit is not None and limit > max_limit: 126 | limit = max_limit 127 | 128 | return offset, limit, self.get_arguments("expand") 129 | 130 | def paginate_query(self, query, offset, limit, count=True): 131 | total = None 132 | if count: 133 | total = query.count() 134 | 135 | query = query.offset(offset) 136 | if limit is not None: 137 | query = query.limit(limit) 138 | 139 | return query, total 140 | 141 | def prepare(self): 142 | BaseHandler.prepare(self) 143 | 144 | if self.request.method.lower() in ("put", "post"): 145 | content_type = parse_options_header( 146 | self.request.headers.get("Content-Type") 147 | )[0] 148 | if content_type.lower() != "application/json": 149 | raise exc.BadRequest("Invalid Content-Type for POST/PUT request.") 150 | 151 | self.add_header( 152 | "Content-Type", 153 | "application/json" 154 | ) 155 | 156 | self.href_prefix = "{}://{}{}".format( 157 | self.request.protocol, 158 | self.request.host, 159 | API_VER 160 | ) 161 | 162 | def not_supported(self): 163 | self.write({ 164 | "status": "error", 165 | "error": { 166 | "code": 405, 167 | "message": "Method not supported for this resource." 168 | } 169 | }) 170 | self.set_status(405, reason="Method not supported.") 171 | 172 | def write_error(self, status_code, **kwargs): 173 | 174 | message = "An unknown problem has occured :(" 175 | if "message" in kwargs: 176 | message = kwargs['message'] 177 | 178 | if "exc_info" in kwargs: 179 | inst = kwargs["exc_info"][1] 180 | if isinstance(inst, HTTPError): 181 | message = inst.log_message 182 | else: 183 | message = str(inst) 184 | 185 | self.write({ 186 | "status": "error", 187 | "error": { 188 | "code": status_code, 189 | "message": message, 190 | }, 191 | }) 192 | self.set_status(status_code, message) 193 | 194 | def success(self, data): 195 | """200 OK""" 196 | data['status'] = "ok" 197 | if 'href' not in data: 198 | data['href'] = "{}://{}{}".format( 199 | self.request.protocol, 200 | self.request.host, 201 | self.request.uri 202 | ) 203 | self.write(data) 204 | self.finish() 205 | 206 | def created(self, location=None, data=None): 207 | """201 CREATED""" 208 | self.set_status(201) 209 | if data is None: 210 | data = {} 211 | data['status'] = 'created' 212 | if location is not None: 213 | self.set_header( 214 | "Location", 215 | urlparse.urljoin(utf8(self.request.uri), utf8(location)) 216 | ) 217 | self.write(data) 218 | self.finish() 219 | -------------------------------------------------------------------------------- /hermes/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for declaring plugin base classes and helpers. 3 | """ 4 | 5 | import annex 6 | import os 7 | import logging 8 | 9 | BUILTIN_PLUGIN_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins") 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | class BaseHermesHook(object): 14 | """ Base class for adding hooks into Hermes. 15 | 16 | This class must be overridden to add actions to perform before and 17 | after particular state transitions in Hermes. 18 | """ 19 | 20 | def on_event(self, event): 21 | """Called when an event is created. 22 | 23 | Args: 24 | event: the event that was created 25 | """ 26 | 27 | 28 | def get_hooks(additional_dirs=None): 29 | """ Helper function to find and load all hooks. """ 30 | log.debug("get_hooks()") 31 | if additional_dirs is None: 32 | additional_dirs = [] 33 | hooks = annex.Annex(BaseHermesHook, [ 34 | os.path.join(BUILTIN_PLUGIN_DIR, "hooks"), 35 | "/etc/hermes/plugins/hooks", 36 | [os.path.expanduser(os.path.join(plugin_dir, "hooks")) 37 | for plugin_dir in additional_dirs] 38 | ], instantiate=True) 39 | 40 | return hooks 41 | -------------------------------------------------------------------------------- /hermes/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'digant' 2 | -------------------------------------------------------------------------------- /hermes/plugins/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'digant' 2 | -------------------------------------------------------------------------------- /hermes/routes.py: -------------------------------------------------------------------------------- 1 | from .handlers import frontends, api 2 | from tornado import web 3 | import os 4 | 5 | HANDLERS = [ 6 | # Hosts 7 | (r"/api/v1/hosts\/?", api.HostsHandler), 8 | (r"/api/v1/hosts/(?P.*)\/?", api.HostHandler), 9 | 10 | # Event Types 11 | (r"/api/v1/eventtypes\/?", api.EventTypesHandler), 12 | (r"/api/v1/eventtypes/(?P\d+)\/?", api.EventTypeHandler), 13 | 14 | # Events 15 | (r"/api/v1/events\/?", api.EventsHandler), 16 | (r"/api/v1/events/(?P\d+)\/?", api.EventHandler), 17 | 18 | # Fates 19 | (r"/api/v1/fates\/?", api.FatesHandler), 20 | (r"/api/v1/fates/(?P\d+)\/?", api.FateHandler), 21 | 22 | # Labors 23 | (r"/api/v1/labors\/?", api.LaborsHandler), 24 | (r"/api/v1/labors/(?P\d+)\/?", api.LaborHandler), 25 | 26 | # Quests 27 | (r"/api/v1/quests\/?", api.QuestsHandler), 28 | (r"/api/v1/quests/(?P\d+)\/?", api.QuestHandler), 29 | (r"/api/v1/quests/(?P\d+)/mail\/?", api.QuestMailHandler), 30 | 31 | # Queries to 3rd party tools 32 | (r"/api/v1/extquery\/?", api.ExtQueryHandler), 33 | 34 | # Query for the current user 35 | (r"/api/v1/currentUser", api.CurrentUserHandler), 36 | 37 | # Query the server for its configs 38 | (r"/api/v1/serverConfig", api.ServerConfig), 39 | 40 | # Frontend Handlers 41 | ( 42 | r"/((?:css|fonts|img|js|vendor|templates)/.*)", 43 | web.StaticFileHandler, 44 | dict( 45 | path=os.path.join(os.path.dirname(__file__), "webapp/build") 46 | ) 47 | ), 48 | 49 | # Frontend Handlers 50 | ( 51 | r"/.*", 52 | frontends.NgApp 53 | ) 54 | ] 55 | -------------------------------------------------------------------------------- /hermes/settings.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | 4 | class Settings(object): 5 | def __init__(self, initial_settings): 6 | self.settings = initial_settings 7 | 8 | @classmethod 9 | def from_settings(cls, settings, initial_settings=None): 10 | _settings = {} 11 | _settings.update(settings.settings) 12 | if initial_settings: 13 | _settings.update(initial_settings) 14 | return cls(_settings) 15 | 16 | def update_from_config(self, filename): 17 | with open(filename) as config: 18 | data = yaml.safe_load(config.read()) 19 | 20 | settings = {} 21 | settings.update(data) 22 | 23 | for key, value in settings.iteritems(): 24 | key = key.lower() 25 | 26 | if key not in self.settings: 27 | continue 28 | 29 | override = getattr(self, "override_%s" % key, None) 30 | if override is not None and callable(override): 31 | value = override(value) 32 | 33 | self.settings[key] = value 34 | 35 | def __getitem__(self, key): 36 | return self.settings[key] 37 | 38 | def __getattr__(self, name): 39 | try: 40 | return self.settings[name] 41 | except KeyError as err: 42 | raise AttributeError(err) 43 | 44 | 45 | settings = Settings({ 46 | "log_format": "%(asctime)-15s\t%(levelname)s\t%(message)s", 47 | "num_processes": 1, 48 | "database": None, 49 | "query_server": "http://localhost:5353/api/query", 50 | "frontend": "https://hermes.company.net", 51 | "slack_webhook": None, 52 | "slack_proxyhost": None, 53 | "debug": False, 54 | "domain": "localhost", 55 | "port": 8990, 56 | "user_auth_header": "X-Hermes-Email", 57 | "email_notifications": False, 58 | "email_sender_address": "hermes@localhost", 59 | "email_always_copy": "", 60 | "restrict_networks": [], 61 | "bind_address": None, 62 | "api_xsrf_enabled": True, 63 | "secret_key": "SECRET_KEY", 64 | "auth_token_expiry": 600, # 10 minutes 65 | "sentry_dsn": None, 66 | "plugin_dir": "plugins ", 67 | "environment": "dev", 68 | "dev_email_recipient": "", 69 | "fullstory_id": None, 70 | "strongpoc_server": None, 71 | "count_events": True, 72 | }) 73 | -------------------------------------------------------------------------------- /hermes/settings_client.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | 4 | class Settings(object): 5 | def __init__(self, initial_settings): 6 | self.settings = initial_settings 7 | 8 | @classmethod 9 | def from_settings(cls, settings, initial_settings=None): 10 | _settings = {} 11 | _settings.update(settings.settings) 12 | if initial_settings: 13 | _settings.update(initial_settings) 14 | return cls(_settings) 15 | 16 | def update_from_config(self, filename): 17 | with open(filename) as config: 18 | data = yaml.safe_load(config.read()) 19 | 20 | settings = {} 21 | settings.update(data) 22 | 23 | for key, value in settings.iteritems(): 24 | key = key.lower() 25 | 26 | if key not in self.settings: 27 | continue 28 | 29 | override = getattr(self, "override_%s" % key, None) 30 | if override is not None and callable(override): 31 | value = override(value) 32 | 33 | self.settings[key] = value 34 | 35 | def __getitem__(self, key): 36 | return self.settings[key] 37 | 38 | def __getattr__(self, name): 39 | try: 40 | return self.settings[name] 41 | except KeyError as err: 42 | raise AttributeError(err) 43 | 44 | 45 | settings = Settings({ 46 | "log_format": "%(asctime)-15s\t%(levelname)s\t%(message)s", 47 | "debug": False, 48 | "user_auth_header": "X-Hermes-Email", 49 | "hermes_server": "http://localhost:10901", 50 | "api_retries": 5 51 | }) 52 | -------------------------------------------------------------------------------- /hermes/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Project-wide utilities. 3 | """ 4 | 5 | import logging 6 | import random 7 | import requests 8 | import smtplib 9 | import string 10 | 11 | from email.mime.text import MIMEText 12 | from email.mime.multipart import MIMEMultipart 13 | 14 | from .settings import settings 15 | 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def id_generator(size=6, chars=string.ascii_lowercase + string.digits): 21 | """Generate a random ID of specified length 22 | 23 | Args: 24 | size: the length of the id to generate 25 | chars: the characters to use 26 | 27 | Returns: 28 | string of random id generated 29 | """ 30 | return ''.join(random.choice(chars) for _ in range(size)) 31 | 32 | 33 | def slack_message(message): 34 | """Post a message to Slack if a webhook as been defined. 35 | 36 | Args: 37 | message: the content of the Slack post 38 | """ 39 | if not settings.slack_webhook: 40 | return 41 | 42 | if settings.slack_proxyhost: 43 | proxies = { 44 | "http": "http://{}".format(settings.slack_proxyhost), 45 | "https": "http://{}".format(settings.slack_proxyhost) 46 | } 47 | else: 48 | proxies = None 49 | 50 | json = { 51 | "text": message, 52 | "username": "Hermes Log", 53 | "icon_emoji": ":hermes:", 54 | } 55 | try: 56 | log.debug("{} {}".format(settings.slack_webhook, json)) 57 | response = requests.post( 58 | settings.slack_webhook, json=json, proxies=proxies 59 | ) 60 | except Exception as exc: 61 | log.warn("Error writing to Slack: {}".format(exc.message)) 62 | 63 | 64 | def email_message(recipients, subject, message, html_message=None, cc=None, sender=None): 65 | """Email a message to a user. 66 | 67 | Args: 68 | subject: the subject of the email we wish to send 69 | message: the content of the email we wish to send 70 | recipients: the email address to whom we wish to send the email 71 | html_message: optional html formatted message we wish to send 72 | cc: optional list of email addresses to carbon copy 73 | sender: optional sender email address 74 | """ 75 | 76 | if not settings.email_notifications: 77 | return 78 | 79 | if isinstance(recipients, basestring): 80 | recipients = recipients.split(",") 81 | if isinstance(settings.email_always_copy, basestring): 82 | extra_recipients = settings.email_always_copy.split(",") 83 | else: 84 | extra_recipients = [settings.email_always_copy] 85 | 86 | if cc and isinstance(cc, basestring): 87 | extra_recipients.append(cc) 88 | elif cc: 89 | extra_recipients.extend(cc) 90 | 91 | # If this is the dev environment, we need to only send to the dev recipient 92 | # and put a tag explaining what would have happened 93 | 94 | if settings.environment == "dev": 95 | recipients_statement = "To: {} CC: {}\n".format( 96 | recipients, extra_recipients 97 | ) 98 | subject = "[DEV] {}".format(subject) 99 | message = ( 100 | "[DEV]: Sent to {}\nOriginally addressed as: {}\n\n{}".format( 101 | settings.dev_email_recipient, 102 | recipients_statement, 103 | message 104 | ) 105 | ) 106 | if html_message: 107 | html_message = ( 108 | "

DEV: Sent to {}
" 109 | "Originally addressed as: {}

{}".format( 110 | settings.dev_email_recipient, 111 | recipients_statement, 112 | html_message 113 | ) 114 | ) 115 | recipients = [settings.dev_email_recipient] 116 | extra_recipients = [] 117 | 118 | part1 = MIMEText(message, 'plain') 119 | if html_message: 120 | part2 = MIMEText(html_message, 'html') 121 | else: 122 | part2 = None 123 | 124 | if part1 and part2: 125 | msg = MIMEMultipart('alternative') 126 | msg.attach(part1) 127 | msg.attach(part2) 128 | else: 129 | msg = part1 130 | 131 | msg["Subject"] = subject 132 | msg["From"] = settings.email_sender_address if not sender else sender 133 | msg["To"] = ", ".join(recipients) 134 | msg["Cc"] = ", ".join(extra_recipients) 135 | 136 | logging.debug("Sending email: From {}, To {}, Msg: {}".format( 137 | settings.email_sender_address, 138 | recipients + extra_recipients, 139 | msg.as_string() 140 | )) 141 | 142 | try: 143 | smtp = smtplib.SMTP("localhost") 144 | smtp.sendmail( 145 | settings.email_sender_address, 146 | recipients + extra_recipients, 147 | msg.as_string() 148 | ) 149 | smtp.quit() 150 | except Exception as exc: 151 | log.warn("Error sending email: {}".format(exc.message)) 152 | 153 | 154 | class PluginHelper(object): 155 | @classmethod 156 | def request_get(cls, path="", params={}, server=None): 157 | """Make an HTTP GET request for the given path 158 | 159 | Args: 160 | path: the full path to the resource 161 | params: the query parameters to send 162 | server: the server to talk to, default is query_server 163 | Returns: 164 | the http response 165 | """ 166 | 167 | if not server: 168 | server = settings.query_server 169 | 170 | response = requests.get(server + path, params=params) 171 | 172 | return response 173 | 174 | @classmethod 175 | def request_post(cls, path="", params={}, json_body={}, server=None): 176 | """Make an HTTP POST request for the given path 177 | 178 | Args: 179 | path: the full path to the resource 180 | params: the query params to send 181 | json_body: the body of the message in JSON format 182 | server: the server to talk to, default is query_server 183 | Returns: 184 | the http response 185 | """ 186 | 187 | if not server: 188 | server = settings.query_server 189 | 190 | response = requests.post( 191 | server + path, params=params, json=json_body 192 | ) 193 | 194 | return response 195 | -------------------------------------------------------------------------------- /hermes/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.7.27" 2 | -------------------------------------------------------------------------------- /hermes/webapp/src/img/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/icon.gif -------------------------------------------------------------------------------- /hermes/webapp/src/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/loading.gif -------------------------------------------------------------------------------- /hermes/webapp/src/img/loading_15.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/loading_15.gif -------------------------------------------------------------------------------- /hermes/webapp/src/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/logo.png -------------------------------------------------------------------------------- /hermes/webapp/src/img/remove_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/remove_10.png -------------------------------------------------------------------------------- /hermes/webapp/src/img/remove_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/remove_15.png -------------------------------------------------------------------------------- /hermes/webapp/src/img/send_email_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/hermes/webapp/src/img/send_email_15.png -------------------------------------------------------------------------------- /hermes/webapp/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hermes 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /hermes/webapp/src/js/controllers/questCreationCtrl.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | function QuestCreationCtrl(hermesService, $q, $routeParams, $location) { 5 | var vm = this; 6 | 7 | vm.user = null; 8 | vm.hostList = []; 9 | vm.selectedFate = null; 10 | vm.description = null; 11 | vm.today = new Date(); 12 | vm.targetDate = new Date(); 13 | vm.targetDate.setDate(new Date().getDate() + 14); 14 | vm.targetDate.setTime(Math.round(vm.targetDate.getTime() / 900000) * 900000); 15 | 16 | vm.errorMessages = null; 17 | vm.addHostErrorMessages = []; 18 | vm.queryErrorMessage = null; 19 | vm.successMessage = null; 20 | vm.createInProgress = false; 21 | vm.queryInProgress = false; 22 | vm.result = null; 23 | vm.showFatesModal = false; 24 | vm.queryString = null; 25 | vm.hostNameEntry = null; 26 | 27 | vm.queriedHosts = []; 28 | vm.startingEventTypes = null; 29 | vm.startingFates = []; 30 | 31 | vm.selectOptions = { 32 | updateOn: 'default change blur', 33 | getterSetter: true, 34 | allowInvalid: true 35 | }; 36 | 37 | 38 | hermesService.getFates().then(function(fates) { 39 | vm.startingFates = []; 40 | for (var idx in fates) { 41 | if (!fates[idx]['followsId']) { 42 | vm.startingFates.push(fates[idx]); 43 | } 44 | } 45 | if (vm.startingFates.length != 0) { 46 | vm.selectedFate = vm.startingFates[0]; 47 | } 48 | }); 49 | 50 | hermesService.getCurrentUser().then(function(user){ 51 | if (user) { 52 | vm.user = user; 53 | } else { 54 | vm.errorMessages.push("Cannot create a new quest if not authenticated."); 55 | } 56 | }); 57 | 58 | vm.runQuery = runQuery; 59 | vm.removeQueriedHost = removeQueriedHost; 60 | vm.removeHost = removeHost; 61 | vm.addHost = addHost; 62 | vm.moveQueriedToQueued = moveQueriedToQueued; 63 | vm.fateSelection = fateSelection; 64 | vm.createQuest = createQuest; 65 | vm.calDateClasser = calDateClasser; 66 | 67 | 68 | //////////////////////////////// 69 | 70 | /** 71 | * Create a quest with the information we have 72 | */ 73 | function createQuest() { 74 | if (vm.createInProgress) return; 75 | 76 | vm.createInProgress = true; 77 | 78 | vm.errorMessages = []; 79 | 80 | if (vm.hostList.length == 0) { 81 | vm.errorMessages.push("Cannot create a quest with an empty list of hosts."); 82 | } 83 | 84 | if (!vm.selectedFate) { 85 | vm.errorMessages.push("Cannot create a quest without a starting fate."); 86 | } 87 | 88 | if (!vm.description) { 89 | vm.errorMessages.push("Cannot create a quest without a description."); 90 | } 91 | 92 | if (!vm.user) { 93 | vm.errorMessages.push("Cannot create a new quest if not authenticated."); 94 | } 95 | 96 | if (vm.errorMessages.length != 0) { 97 | vm.createInProgress = false; 98 | return; 99 | } 100 | 101 | vm.result = hermesService.createQuest(vm.user, vm.hostList, 102 | vm.selectedFate.id, vm.targetDate, vm.description) 103 | .then(function(response) { 104 | vm.createInProgress = false; 105 | vm.hostList = []; 106 | vm.description = null; 107 | vm.successMessage = "Successfully create quest " + response.data.id; 108 | }) 109 | .catch(function(error) { 110 | vm.createInProgress = false; 111 | vm.errorMessages.push("Quest creation failed! " + error.statusText); 112 | }); 113 | 114 | } 115 | 116 | /** 117 | * The getter/setter for event types 118 | */ 119 | function fateSelection(selection) { 120 | if (angular.isDefined(selection)) { 121 | vm.selectedFate = selection; 122 | } else { 123 | return vm.selectedFate; 124 | } 125 | 126 | } 127 | 128 | /** 129 | * Run the user specified query against the query passthrough service 130 | */ 131 | function runQuery() { 132 | if (!vm.queryString || vm.queryString.trim().length == 0) { 133 | vm.queryErrorMessage = "Query is empty."; 134 | return; 135 | } 136 | vm.queryErrorMessage = null; 137 | vm.queryInProgress = true; 138 | hermesService.runQuery(vm.queryString).then(function(hosts) { 139 | vm.queryInProgress = false; 140 | if (hosts && hosts.length != 0) { 141 | vm.queriedHosts = hosts; 142 | } else { 143 | vm.queryErrorMessage = "Query returned no results."; 144 | } 145 | }).catch(function(error) { 146 | vm.queryInProgress = false; 147 | vm.queryErrorMessage = "Failed to run query! " + error.statusText; 148 | }); 149 | } 150 | 151 | /** 152 | * Remove a host from the list of hosts generated by the query 153 | * @param host the host to remove 154 | */ 155 | function removeQueriedHost(host) { 156 | var idx = vm.queriedHosts.indexOf(host); 157 | if (idx > -1) { 158 | vm.queriedHosts.splice(idx, 1); 159 | } 160 | } 161 | 162 | /** 163 | * Remove a host from the list of hosts queued up for this quest 164 | * @param host the host to remove 165 | */ 166 | function removeHost(host) { 167 | var idx = vm.hostList.indexOf(host); 168 | if (idx > -1) { 169 | vm.hostList.splice(idx, 1); 170 | } 171 | } 172 | 173 | /** 174 | * Add a specified host to the hosts queued up, but only if it isn't 175 | * in there already 176 | * @param host the host to add 177 | */ 178 | function addHost(host) { 179 | vm.addHostErrorMessages = []; 180 | if (!host) { 181 | vm.addHostErrorMessages.push("Hostname empty."); 182 | return; 183 | } 184 | 185 | var hosts = host.split(","); 186 | for (var idx in hosts) { 187 | var host = hosts[idx].trim(); 188 | if (vm.hostList.indexOf(host) == -1) { 189 | vm.hostList.push(host); 190 | } else { 191 | vm.addHostErrorMessages.push("Ignoring duplicate host " + host); 192 | } 193 | } 194 | } 195 | 196 | /** 197 | * Add queried hosts to queued hosts 198 | */ 199 | function moveQueriedToQueued() { 200 | while (vm.queriedHosts.length > 0) { 201 | addHost(vm.queriedHosts.shift()); 202 | } 203 | } 204 | 205 | /** 206 | * Adds our classes to the date picker 207 | * @param date the date in question 208 | * @param mode the mode 209 | */ 210 | function calDateClasser(date, mode) { 211 | return "date-picker"; 212 | } 213 | 214 | } 215 | 216 | angular.module('hermesApp').controller('QuestCreationCtrl', QuestCreationCtrl); 217 | QuestCreationCtrl.$inject = ['HermesService', '$q', '$routeParams', '$location']; 218 | })(); 219 | -------------------------------------------------------------------------------- /hermes/webapp/src/js/controllers/questEditCtrl.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | function QuestEditCtrl(hermesService, $q, $routeParams, $location) { 5 | var vm = this; 6 | 7 | vm.user = null; // holds the current user; we should only let current owners update quests 8 | vm.today = new Date(); // used to restrict how far back the due date can be set 9 | vm.quest = null; // holds the retrieve quest details 10 | 11 | // new values that the user can edit 12 | vm.newCreator = null; 13 | vm.description = null; 14 | vm.targetDate = null; 15 | 16 | vm.editingCreator = false; // controls if we show the creator editing field 17 | vm.editingDate = false; // controls if we show the date picker 18 | vm.editingDesc = false; // controls if we show the desc edit field 19 | 20 | // various messaging fields 21 | vm.successMessage = null; 22 | vm.errorMessage = null; 23 | 24 | hermesService.getCurrentUser().then(function(user){ 25 | if (user) { 26 | vm.user = user; 27 | } else { 28 | vm.errorMessages.push("Cannot create a new quest if not authenticated."); 29 | } 30 | }); 31 | 32 | refreshQuestInfo(); 33 | 34 | vm.calDateClasser = calDateClasser; 35 | vm.focus = focus; 36 | vm.saveCreator = saveCreator; 37 | vm.saveTargetTime = saveTargetTime; 38 | vm.saveDescription = saveDescription; 39 | vm.refreshQuestInfo = refreshQuestInfo; 40 | 41 | //////////////////////////////// 42 | 43 | function refreshQuestInfo() { 44 | hermesService.getQuestDetails($routeParams.questId).then(function(quest) { 45 | vm.quest = quest; 46 | vm.description = quest.description; 47 | vm.targetDate = new Date(); 48 | //vm.targetDate.setDate(new Date(targetDate).getDate()); 49 | vm.targetDate.setTime(Math.round(vm.targetDate.getTime() / 900000) * 900000); 50 | }); 51 | } 52 | 53 | /** 54 | * Adds our classes to the date picker 55 | * @param date the date in question 56 | * @param mode the mode 57 | */ 58 | function calDateClasser(date, mode) { 59 | return "date-picker"; 60 | } 61 | 62 | /** 63 | * Helper to set focus and select the text 64 | * @param id the item that gets focus 65 | */ 66 | function focus(id) { 67 | setTimeout(function() { 68 | document.getElementById(id).focus(); 69 | document.getElementById(id).select(); 70 | }, 10); 71 | } 72 | 73 | /** 74 | * Change the creator for a quest 75 | */ 76 | function saveCreator() { 77 | hermesService.updateQuest(vm.quest.id, {"creator": vm.newCreator}) 78 | .then(function(response) { 79 | vm.successMessage = "Updated creator to " + vm.newCreator; 80 | vm.newCreator = null; 81 | vm.editingCreator = false; 82 | vm.refreshQuestInfo(); 83 | }) 84 | .catch(function(error) { 85 | vm.errorMessage = "Error updating creator: " + error.statusText; 86 | }) 87 | } 88 | 89 | /** 90 | * Change the target time for a quest 91 | */ 92 | function saveTargetTime() { 93 | hermesService.updateQuest(vm.quest.id, {"targetTime": vm.targetDate}) 94 | .then(function(response) { 95 | vm.successMessage = "Updated target time to " + vm.targetDate; 96 | vm.editingDate = false; 97 | vm.refreshQuestInfo(); 98 | }) 99 | .catch(function(error) { 100 | vm.errorMessage = "Error updating target time: " + error.statusText; 101 | }) 102 | } 103 | 104 | /** 105 | * Change the description for a quest 106 | */ 107 | function saveDescription() { 108 | hermesService.updateQuest(vm.quest.id, {"description": vm.description}) 109 | .then(function(response) { 110 | vm.successMessage = "Updated description!"; 111 | vm.editingDesc = false; 112 | vm.refreshQuestInfo(); 113 | }) 114 | .catch(function(error) { 115 | vm.errorMessage = "Error updating description: " + error.statusText; 116 | }) 117 | } 118 | 119 | } 120 | 121 | angular.module('hermesApp').controller('QuestEditCtrl', QuestEditCtrl); 122 | QuestEditCtrl.$inject = ['HermesService', '$q', '$routeParams', '$location']; 123 | })(); 124 | -------------------------------------------------------------------------------- /hermes/webapp/src/js/controllers/userHomeCtrl.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | function UserHomeCtrl(hermesService, $q, $routeParams, $location, smoothScroll) { 5 | var vm = this; 6 | 7 | vm.errorMessage = null; 8 | 9 | vm.domain = null; 10 | vm.user = null; 11 | vm.questData = null; 12 | vm.totalQuests = null; 13 | vm.totalUserCreatedQuests = null; 14 | vm.totalLabors = null; 15 | vm.totalUserLabors = null; 16 | 17 | vm.questsUrl = null; 18 | vm.laborsUrl = null; 19 | 20 | vm.goToQuestsPage = goToQuestsPage; 21 | vm.goToLaborsPage = goToLaborsPage; 22 | 23 | hermesService.getCurrentUser().then(function (user) { 24 | if (user) { 25 | vm.user = user; 26 | } 27 | 28 | // find labors and quests for this user 29 | getOpenQuests(); 30 | getOpenLabors(); 31 | }); 32 | 33 | hermesService.getServerConfig().then(function(config) { 34 | vm.domain = config['domain']; 35 | }); 36 | 37 | function goToCreatePage() { 38 | $location.url("/v1/quest/new"); 39 | } 40 | 41 | function goToQuestsPage() { 42 | $location.url(vm.questsUrl); 43 | } 44 | 45 | function goToLaborsPage() { 46 | $location.url(vm.laborsUrl); 47 | } 48 | 49 | /** 50 | * Get open quest information, but we only want basic overview information. 51 | */ 52 | function getOpenQuests() { 53 | vm.errorMessage = null; 54 | 55 | var options = {}; 56 | options['overviewOnly'] = true; 57 | 58 | hermesService.getOpenQuests(options).then(function (questData) { 59 | vm.questData = questData['quests']; 60 | vm.totalQuests = questData['totalQuests']; 61 | 62 | // see which quests are overdue and which are owned by this user 63 | vm.totalUserCreatedQuests = 0; 64 | for (var idx in vm.questData) { 65 | evalDueDate(vm.questData[idx]); 66 | if (vm.questData[idx]['creator'] == vm.user) { 67 | vm.totalUserCreatedQuests++; 68 | } 69 | } 70 | 71 | if (vm.totalUserCreatedQuests == 0) { 72 | vm.questsUrl = "/v1/quests/?byCreator="; 73 | } else { 74 | vm.questsUrl = "/v1/quests?byCreator=" + vm.user; 75 | } 76 | }); 77 | } 78 | 79 | /** 80 | * Get labor information (overview only) for all open labors and labors 81 | * that apply to this user. 82 | */ 83 | function getOpenLabors() { 84 | var options = {}; 85 | options['overviewOnly'] = true; 86 | 87 | hermesService.getOpenLabors(options).then(function (laborData){ 88 | vm.totalLabors = laborData['totalLabors']; 89 | }); 90 | 91 | options['filterByOwner'] = vm.user; 92 | 93 | hermesService.getOpenLabors(options).then(function (laborData) { 94 | vm.totalUserLabors = laborData['totalLabors']; 95 | vm.laborsUrl = "/v1/labors?byOwner=" + vm.user; 96 | }).catch(function(error) { 97 | vm.totalUserLabors = 0; 98 | vm.laborsUrl = "/v1/labors?byOwner="; 99 | }); 100 | } 101 | 102 | /** 103 | * Determine if the quest is overdue and add a property to indicate 104 | * @param quest the quest to analyze 105 | */ 106 | function evalDueDate(quest) { 107 | if (quest.targetTime) { 108 | var dateRegex = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/; 109 | var dateArray = dateRegex.exec(quest.targetTime); 110 | var targetDate = new Date( 111 | (+dateArray[1]), 112 | (+dateArray[2]) - 1, // Careful, month starts at 0! 113 | (+dateArray[3]), 114 | (+dateArray[4]), 115 | (+dateArray[5]), 116 | (+dateArray[6]) 117 | ); 118 | 119 | if (targetDate - new Date() <= 0) quest.overDue = true; 120 | else quest.overDue = false; 121 | } else { 122 | quest.overDue = false; 123 | } 124 | } 125 | } 126 | 127 | angular.module('hermesApp').controller('UserHomeCtrl', UserHomeCtrl); 128 | UserHomeCtrl.$inject = ['HermesService', '$q', '$routeParams', '$location', 'smoothScroll']; 129 | })(); 130 | -------------------------------------------------------------------------------- /hermes/webapp/src/js/directives/boxContinuation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Directive for watching a div and adding a "box-continuation" class when a 3 | * particular height is reached. This is used when we have a max-height for a div 4 | * with a scroll and want to clue the user in to the fact that the box has additional 5 | * items in it. 6 | * 7 | * A "watch" attribute must be specified that let's us know what data 8 | * modifies the contents of the div (and therefore might alter the height). 9 | */ 10 | (function() { 11 | function boxContinuation () { 12 | return { 13 | restrict: 'A', 14 | scope: { 15 | 'watch': '=' 16 | }, 17 | link: function ($scope, $ele, $attrs) { 18 | var triggerHeight = $attrs.triggerHeight || 200; 19 | 20 | $scope.$watch('watch', 21 | classAlteration, true); 22 | 23 | function classAlteration() { 24 | setTimeout(function() { 25 | if ($ele[0].clientHeight >= triggerHeight) { 26 | $ele[0].classList.add('box-continuation'); 27 | } else { 28 | $ele[0].classList.remove('box-continuation'); 29 | } 30 | }, 1); 31 | } 32 | } 33 | } 34 | } 35 | 36 | angular.module('hermesApp').directive('boxContinuation', boxContinuation); 37 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/directives/questProgressBar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * directive for building quest progress bars with Raphael 3 | */ 4 | (function() { 5 | function questProgressBar ($timeout, $window) { 6 | return { 7 | restrict: 'A', 8 | scope: { 9 | data: '=', 10 | onClick: '&' 11 | }, 12 | link: function ($scope, $ele, $attrs) { 13 | var renderTimeout; 14 | var lastData; 15 | var isVisible = true; 16 | var graphHeight = parseInt($attrs.graphHeight) || 30; 17 | var raphael = new Raphael($ele[0], "100%", graphHeight); 18 | 19 | $scope.$watch('data', function (newData) { 20 | lastData = [newData]; 21 | $scope.render([newData]); 22 | }, true); 23 | 24 | $window.onresize = function () { 25 | $scope.$apply(); 26 | }; 27 | 28 | $scope.$watch(function () { 29 | return angular.element($window)[0].innerWidth; 30 | }, function () { 31 | $scope.render([$scope.data]); 32 | }); 33 | 34 | $scope.render = function (data) { 35 | if (!data || !isVisible) return; 36 | if (renderTimeout) clearTimeout(renderTimeout); 37 | 38 | renderTimeout = $timeout(function () { 39 | var width = $ele[0].offsetWidth; 40 | var topPadding = graphHeight * .05; 41 | var barWidth = width; 42 | var barBaseLine = graphHeight * 1; 43 | var barHeight = graphHeight * .3; 44 | var percentXLoc = data[0].percentComplete * barWidth / 100; 45 | 46 | var colors = ['#7b8994','#3d464d']; 47 | var textColor = '#3d464d'; 48 | 49 | if (data[0].overDue) { 50 | colors = ['#944D4D', '#6A1C0E']; 51 | textColor = '#6A1C0E'; 52 | } 53 | 54 | // erase everything 55 | raphael.clear(); 56 | 57 | // draw the full bar, which shows the full extent of the progress bar 58 | var fullBar = raphael.rect( 59 | 0, barBaseLine - barHeight, 60 | barWidth, barHeight 61 | ); 62 | fullBar.attr({'fill': colors[0]}); 63 | fullBar.attr({'stroke-width': 0}); 64 | 65 | // color in the part to represent the percentage complete 66 | var percentBar = raphael.rect( 67 | 0, barBaseLine - barHeight, 68 | percentXLoc, barHeight 69 | ); 70 | percentBar.attr({'fill': colors[1]}); 71 | percentBar.attr({'stroke-width': 0}); 72 | 73 | // draw the little line that points up to the percentage label 74 | var pathStr = "M" + percentXLoc + "," + (barBaseLine - (barHeight * 1.5)) 75 | + " L" + percentXLoc + "," + barBaseLine; 76 | raphael.path(pathStr).attr({'stroke': colors[1]}); 77 | 78 | // add the percentage amount text 79 | var label = raphael.text( 80 | width/2, 81 | (barBaseLine - (barHeight * 2)), 82 | data[0].percentComplete + "%" 83 | ).attr({ 84 | 'font-size': barHeight, 85 | 'fill': textColor, 86 | 'font-family': "Titillium Web", 87 | }); 88 | 89 | var bb = label.getBBox(); 90 | var labelWidth = Math.abs(bb.x2) - Math.abs(bb.x) + 1; 91 | if (percentXLoc - (labelWidth / 2) <= 0) { 92 | label.attr({'x': labelWidth / 2}); 93 | } else if (percentXLoc + (labelWidth / 2) >= barWidth) { 94 | label.attr({'x': barWidth - (labelWidth/2)}); 95 | } else { 96 | label.attr({'x': percentXLoc}); 97 | } 98 | 99 | }, 0); 100 | }; 101 | } 102 | } 103 | } 104 | 105 | angular.module('hermesApp').directive('questProgressBar', questProgressBar); 106 | questProgressBar.$inject = ['$timeout', '$window']; 107 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/directives/questProgressChart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * directive for building quest progress bars with Raphael 3 | */ 4 | (function() { 5 | function questProgressChart (hermesService, $timeout, $window) { 6 | return { 7 | restrict: 'A', 8 | scope: { 9 | data: '=', 10 | types: '=', 11 | colors: '=', 12 | onClick: '&' 13 | }, 14 | link: function ($scope, $ele, $attrs) { 15 | var width = $ele[0].offsetWidth; 16 | var graphHeight = 200; 17 | var legendFontSize = graphHeight * .06; 18 | var legendSpacing = graphHeight * .09; 19 | var titleFontSize = graphHeight * .12; 20 | 21 | 22 | var renderTimeout; 23 | var types = null; 24 | var numberOfTypes = 0; 25 | var rad = Math.PI / 180; 26 | 27 | // we don't pull this from the backend b/c the backend gives a total that 28 | // includes completed intermediate labors 29 | var totalLabors = 0; 30 | var colors; 31 | var raphael = new Raphael($ele[0], "100%", graphHeight); 32 | 33 | var graphData = null; 34 | hermesService.getFatesGraph().then(function(data) { 35 | graphData = data; 36 | }); 37 | 38 | $scope.$watch('data', function (newData) { 39 | $scope.render([newData]); 40 | }, true); 41 | 42 | $scope.$watch('types', function (newData) { 43 | types = newData; 44 | numberOfTypes = 0; 45 | totalLabors = 0; 46 | for (var idx in types) { 47 | numberOfTypes++; 48 | totalLabors += types[idx]; 49 | } 50 | }); 51 | 52 | $window.onresize = function () { 53 | $scope.$apply(); 54 | }; 55 | 56 | $scope.$watch(function () { 57 | return angular.element($window)[0].innerWidth; 58 | }, function () { 59 | $scope.render([$scope.data]); 60 | }); 61 | 62 | 63 | $scope.$watch('colors', function (newData) { 64 | colors = newData; 65 | }, true); 66 | 67 | $scope.render = function (data) { 68 | if (!data) return; 69 | if (renderTimeout) clearTimeout(renderTimeout); 70 | 71 | function wrapText(text, textEle, maxWidth) { 72 | text = text.replace(/\s+/g, ' ').trim(); 73 | text = text.replace('\n', ' '); 74 | var words = text.split(" "); 75 | var wrappedText = ''; 76 | for (var idx in words) { 77 | textEle.attr("text", wrappedText + " " + words[idx]); 78 | if (textEle.getBBox().width > maxWidth) { 79 | wrappedText += '\n' + words[idx]; 80 | } else { 81 | wrappedText += ' ' + words[idx]; 82 | } 83 | } 84 | 85 | var bb = textEle.getBBox(); 86 | var h = Math.abs(bb.y2) - Math.abs(bb.y) + 1; 87 | textEle.attr({ 88 | 'y': bb.y + h 89 | }); 90 | } 91 | 92 | renderTimeout = $timeout(function () { 93 | var width = $ele[0].offsetWidth; 94 | var legendX = width * .70; 95 | var legendY = ((graphHeight *.9) + ((numberOfTypes-1)* legendSpacing)) / 2; 96 | var pieX = width * .5; 97 | var pieY = graphHeight * .5; 98 | var pieR = graphHeight * .45; 99 | 100 | // erase everything 101 | raphael.clear(); 102 | 103 | // add the quest info to the top left 104 | var title = raphael.text(0, titleFontSize, 105 | "Quest " + data[0].id) 106 | .attr({ 107 | 'text-anchor': 'start', 108 | 'font-size': titleFontSize, 109 | 'font-family': "Titillium Web" 110 | }); 111 | 112 | var creator = raphael.text(0, titleFontSize 113 | + legendFontSize * 2, 114 | "Created by: " + data[0].creator) 115 | .attr({ 116 | 'text-anchor': 'start', 117 | 'font-size': legendFontSize, 118 | 'font-family': "Titillium Web" 119 | }); 120 | 121 | // add the quest description 122 | var desc = raphael.text(0, legendY - legendSpacing /1.5) 123 | .attr({ 124 | 'text-anchor': 'start', 125 | 'font-size': legendFontSize, 126 | 'font-family': "Titillium Web" 127 | }); 128 | 129 | wrapText(data[0].description, desc, width *.35); 130 | 131 | if (data[0].overDue) { 132 | raphael.text(legendX, titleFontSize, "OVERDUE") 133 | .attr({ 134 | 'font-size': titleFontSize, 135 | 'font-family': "Titillium Web", 136 | 'fill': "#953D2D", 137 | 'text-anchor': 'start' 138 | }); 139 | } 140 | 141 | // draw out the legend on the right 142 | var i = 0; 143 | var lastAngle = 0; 144 | for (var idx in types) { 145 | var type = idx; 146 | var x = legendX; 147 | var y = legendY + (i * legendSpacing * 1.1); 148 | var text = raphael.text( 149 | x, y, type 150 | ).attr({ 151 | 'font-size': legendFontSize, 152 | 'font-family': "Titillium Web", 153 | 'text-anchor': 'start' 154 | }); 155 | 156 | var boxX = x - legendSpacing- (legendSpacing/4); 157 | var boxY = y - legendSpacing/2; 158 | 159 | var box = raphael.rect(boxX, boxY, legendSpacing, legendSpacing) 160 | .attr('fill', colors[i]) 161 | .attr('stroke-width', '0'); 162 | 163 | var angle = types[idx] / totalLabors * 360; 164 | if (angle == 360) { 165 | var circle = raphael.circle(pieX, pieY, pieR) 166 | .attr({ 167 | 'fill': colors[i], 168 | 'stroke': 'none' 169 | }); 170 | } else { 171 | var pie = sector( 172 | pieX, pieY, pieR, lastAngle, lastAngle + angle, 173 | { 174 | 'fill': colors[i], 175 | 'stroke': 'none' 176 | } 177 | ); 178 | lastAngle += angle; 179 | } 180 | i++; 181 | } 182 | }, 0); 183 | }; 184 | 185 | function sector(cx, cy, r, startAngle, endAngle, params) { 186 | var x1 = cx + r * Math.cos(-startAngle * rad), 187 | x2 = cx + r * Math.cos(-endAngle * rad), 188 | y1 = cy + r * Math.sin(-startAngle * rad), 189 | y2 = cy + r * Math.sin(-endAngle * rad); 190 | return raphael.path(["M", cx, cy, "L", x1, y1, "A", r, r, 0, +(endAngle - startAngle > 180), 0, x2, y2, "z"]).attr(params); 191 | } 192 | } 193 | } 194 | } 195 | 196 | angular.module('hermesApp').directive('questProgressChart', questProgressChart); 197 | questProgressChart.$inject = ['HermesService', '$timeout', '$window']; 198 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/directives/scrollWatch.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function scrollWatch ($anchorScroll, $timeout) { 3 | return { 4 | restrict: 'A', 5 | transclude: true, 6 | link: function ($scope, $ele, $attrs) { 7 | if ($scope.$last === true) { 8 | $timeout(function () { 9 | $anchorScroll(); 10 | }, 100); 11 | } 12 | } 13 | } 14 | } 15 | 16 | angular.module('hermesApp').directive('scrollWatch', scrollWatch); 17 | scrollWatch.inject = ['$anchorScroll', '$timeout']; 18 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/directives/viewportHeight.js: -------------------------------------------------------------------------------- 1 | /** 2 | * directive for having a div's height based on the size of the viewport 3 | */ 4 | (function() { 5 | function viewportHeight ($window) { 6 | return { 7 | restrict: 'A', 8 | scope: { 9 | }, 10 | link: function ($scope, $ele, $attrs) { 11 | var minHeight = $attrs.minHeight || 200; 12 | var padding = $attrs.padding || 120; 13 | 14 | $ele.css('overflow', 'scroll'); 15 | 16 | angular.element($window).bind('resize', function() { 17 | fixHeight(); 18 | }); 19 | 20 | function fixHeight() { 21 | var innerHeight = $window.innerHeight; 22 | var height = minHeight; 23 | if (innerHeight > minHeight) { 24 | height = innerHeight - padding - 80; 25 | } 26 | 27 | $ele.css('height', height + 'px'); 28 | } 29 | 30 | fixHeight(); 31 | } 32 | } 33 | } 34 | 35 | angular.module('hermesApp').directive('viewportHeight', viewportHeight); 36 | viewportHeight.$inject = ['$window']; 37 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/filters/encode.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | 'use strict'; 4 | 5 | function EncodeFilter() { 6 | return window.encodeURIComponent; 7 | } 8 | 9 | angular.module('hermesApp').filter('encode', EncodeFilter); 10 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/filters/num.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | 'use strict'; 4 | 5 | function NumFilter() { 6 | return function (input) { 7 | return parseInt(input, 10); 8 | }; 9 | } 10 | 11 | angular.module('hermesApp').filter('num', NumFilter); 12 | 13 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/hermesApp.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | 'use strict'; 4 | 5 | var app = angular.module('hermesApp', 6 | [ 7 | 'ngAnimate', 8 | 'ngRoute', 9 | 'ngLocationUpdate', 10 | 'smoothScroll', 11 | 'ui.bootstrap', 12 | 'ngCookies', 13 | ]); 14 | 15 | app.config(['$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { 16 | $routeProvider.when('/v1/quests/:questId?', { 17 | templateUrl: '/templates/questStatus.html', 18 | reloadOnSearch: false 19 | }).when('/v1/quest/new', { 20 | templateUrl: '/templates/questCreation.html', 21 | reloadOnSearch: false 22 | }).when('/v1/quests/:questId/edit', { 23 | templateUrl: '/templates/questEdit.html', 24 | reloadOnSearch: false 25 | }).when('/v1/labors/:laborId?', { 26 | templateUrl: '/templates/laborList.html', 27 | reloadOnSearch: false 28 | }).when('/v1/fates', { 29 | templateUrl: '/templates/fateViewer.html', 30 | reloadOnSearch: false 31 | }).when('/home', { 32 | templateUrl: '/templates/userHome.html', 33 | reloadOnSearch: false 34 | }).otherwise({redirectTo: '/home/'}); 35 | 36 | // use the HTML5 History API 37 | $locationProvider.html5Mode({ 38 | enabled: true, 39 | requireBase: false 40 | }); 41 | }]); 42 | 43 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/js/services/skipReload.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | angular.module('hermesApp').factory('skipReload', [ 4 | '$route', 5 | '$rootScope', 6 | function ($route, $rootScope) { 7 | return function () { 8 | var lastRoute = $route.current; 9 | var un = $rootScope.$on('$locationChangeSuccess', function () { 10 | $route.current = lastRoute; 11 | un(); 12 | }); 13 | }; 14 | } 15 | ]); 16 | })(); -------------------------------------------------------------------------------- /hermes/webapp/src/templates/fateViewer.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
-------------------------------------------------------------------------------- /hermes/webapp/src/templates/questCreation.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Create a quest:

6 |
7 |
8 | 9 |
10 |
11 |
For {{qc.hostList.length}} hosts:
12 |
13 |
14 | 16 | 17 | {{host}} 18 |
19 |
20 |
21 |
{{message}}
22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 |
37 |
{{qc.queryErrorMessage}}
38 |
39 |
40 |
41 | 42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 |
51 | 53 | 54 | {{host}} 55 |
56 |
57 |
58 |
59 |   60 |
61 |
62 | 63 |
64 |
65 |
66 |
67 | 68 |
69 |
70 |
76 |
77 |
78 |
79 | Need help picking a quest type? 80 |

Use the explorer to see the quest types and what kind of workflow each implies..

81 | 82 |
83 |
84 |
85 | 86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
96 |
The targeted completion date and time for the quest. As the date gets closer, notifications to service owners will intensify.
97 |
98 |
99 | 100 |
101 |
102 |
103 | 106 |
107 |
108 |
109 |
The Quest description is what most users will see so be as 110 | descriptive as possible. Let users know what is going on 111 | and what action they should take.
112 |
113 |
114 | 115 |
116 |
117 |
{{msg}}
118 |
119 | 123 |
124 |
125 |
126 |   127 |
128 |
129 | 130 |
131 |
132 |   133 |
134 |
135 |   136 |
137 |
138 |
139 | 147 | 155 |
156 | -------------------------------------------------------------------------------- /hermes/webapp/src/templates/questEdit.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 |
12 | 13 | {{qc.quest.creator}} 14 |
15 | 16 |
17 | 19 | 20 | 22 | 23 | 25 | 26 |
27 |
28 |
29 |
The creator/owner of the quest is responsible for managing and closing out the quest.
30 |
31 |
32 | 33 |
34 |
35 |
36 | Current Target Time: {{qc.quest.targetTime}} 37 | 40 | 41 |
42 |
43 |
44 | 45 |
46 |
47 | 48 |
49 |
50 | 52 | 53 | 55 | 56 |
57 |
58 |
59 |
60 |
The targeted completion date and time for the quest. As the date gets closer, notifications to service owners will intensify.
61 |
62 |
63 | 64 |
65 |
66 |
67 | 68 | 70 | 71 |

{{qc.quest.description}}

72 |
73 | 75 |
76 |
77 | 79 | 80 | 82 | 83 |
84 |
85 |
86 |
87 |
The Quest description is what most users will see so be as 88 | descriptive as possible. Let users know what is going on 89 | and what action they should take.
90 |
91 |
92 | 93 |
94 |
95 |   96 |
97 |
98 |   99 |
100 |
101 |
102 | 110 |
111 | -------------------------------------------------------------------------------- /hermes/webapp/src/templates/userHome.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 12 |
13 | Loading
14 | Info 15 |
16 |
17 |
18 | 23 |
24 | Loading
25 | Info 26 |
27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "bower": "^1.5.2", 4 | "del": "^2.0.0", 5 | "gulp": "^3.9.0", 6 | "gulp-bower": "0.0.10", 7 | "gulp-concat": "^2.6.0", 8 | "gulp-csslint": "^0.2.0", 9 | "gulp-jshint": "^1.11.2", 10 | "gulp-less": "^3.0.3", 11 | "gulp-minify-css": "^1.2.1", 12 | "gulp-ng-annotate": "^1.1.0", 13 | "gulp-rename": "^1.2.2", 14 | "gulp-sort": "^1.1.1", 15 | "gulp-uglify": "^1.4.0", 16 | "gulp-watch": "^4.3.5", 17 | "main-bower-files": "^2.9.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | mrproxy==0.3.3 3 | py==1.4.26 4 | pytest==2.6.4 5 | Pygments==2.0.1 6 | Sphinx==1.2.3 7 | argh==0.26.1 8 | docutils==0.12 9 | livereload==2.3.2 10 | pathtools==0.1.2 11 | sphinx-autobuild==0.4.0 12 | watchdog==0.8.2 13 | sphinxcontrib-httpdomain==1.3.0 14 | sphinx-rtd-theme==0.1.6 15 | pytest-capturelog==0.7 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | enum34==1.0.4 2 | cffi==0.9.2 3 | pyasn1==0.1.7 4 | six==1.9.0 5 | pycparser==2.10 6 | cryptography==0.8.1 7 | Jinja2==2.7.3 8 | Mako==1.0.1 9 | MarkupSafe==0.23 10 | PyYAML==3.11 11 | SQLAlchemy==0.9.8 12 | Werkzeug==0.9.6 13 | alembic==0.7.4 14 | argparse==1.4.0 15 | backports.ssl-match-hostname==3.4.0.2 16 | bittle==0.2.1 17 | certifi==14.05.14 18 | ipaddress==1.0.7 19 | tornado==4.0.2 20 | MySQL-python==1.2.5 21 | requests[security]==2.7.0 22 | python-dateutil==2.4.2 23 | annex==0.3.1 24 | pytz==2015.6 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | from setuptools import find_packages 6 | from distutils.core import setup 7 | 8 | execfile('hermes/version.py') 9 | 10 | with open('requirements.txt') as requirements: 11 | required = requirements.read().splitlines() 12 | 13 | package_data = {} 14 | def get_package_data(package, base_dir): 15 | for dirpath, dirnames, filenames in os.walk(base_dir): 16 | dirpath = dirpath[len(package)+1:] # Strip package dir 17 | for filename in filenames: 18 | package_data.setdefault(package, []).append(os.path.join(dirpath, filename)) 19 | for dirname in dirnames: 20 | get_package_data(package, dirname) 21 | 22 | get_package_data("hermes", "hermes/webapp/build") 23 | get_package_data("hermes", "hermes/templates") 24 | 25 | kwargs = { 26 | "name": "hermes", 27 | "version": str(__version__), 28 | "packages": find_packages(exclude=['tests']), 29 | "package_data": package_data, 30 | "scripts": ["bin/hermes-server", "bin/hermes", "bin/hermes-notify"], 31 | "description": "Hermes Event Management and Autotasker", 32 | "author": "Digant C Kasundra", 33 | "maintainer": "Digant C Kasundra", 34 | "author_email": "digant@dropbox.com", 35 | "maintainer_email": "digant@dropbox.com", 36 | "license": "Apache", 37 | "install_requires": required, 38 | "url": "https://github.com/dropbox/hermes", 39 | "download_url": "https://github.com/dropbox/hermes/archive/master.tar.gz", 40 | "classifiers": [ 41 | "Programming Language :: Python", 42 | "Topic :: Software Development", 43 | "Topic :: Software Development :: Libraries", 44 | "Topic :: Software Development :: Libraries :: Python Modules", 45 | ] 46 | } 47 | 48 | setup(**kwargs) 49 | -------------------------------------------------------------------------------- /tests/api_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/tests/api_tests/__init__.py -------------------------------------------------------------------------------- /tests/api_tests/data/set1/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "event1": { 3 | "hostname": "example", 4 | "user": "system", 5 | "eventTypeId": 1, 6 | "note": "example needs a reboot" 7 | }, 8 | "event2": { 9 | "hostname": "example", 10 | "user": "system", 11 | "eventTypeId": 2, 12 | "note": "example needs a rebooted" 13 | }, 14 | "event3": { 15 | "hostname": "example", 16 | "user": "system", 17 | "eventTypeId": 3, 18 | "note": "example needs a reboot" 19 | }, 20 | "event4": { 21 | "hostname": "example", 22 | "user": "system", 23 | "eventTypeId": 4, 24 | "note": "example needs a rebooted" 25 | }, 26 | "event5": { 27 | "hostname": "example", 28 | "user": "system", 29 | "eventTypeId": 5, 30 | "note": "example needs a reboot" 31 | }, 32 | "event6": { 33 | "hostname": "example", 34 | "user": "system", 35 | "eventTypeId": 3, 36 | "note": "example needs a rebooted" 37 | }, 38 | "event7": { 39 | "hostname": "example", 40 | "user": "system", 41 | "eventTypeId": 4, 42 | "note": "example needs a reboot" 43 | }, 44 | "event8": { 45 | "hostname": "example", 46 | "user": "system", 47 | "eventTypeId": 1, 48 | "note": "example needs a rebooted" 49 | }, 50 | "event9": { 51 | "hostname": "sample", 52 | "user": "system", 53 | "eventTypeId": 3, 54 | "note": "sample needs a reboot" 55 | }, 56 | "event10": { 57 | "hostname": "sample", 58 | "user": "system", 59 | "eventTypeId": 4, 60 | "note": "sample needs a rebooted" 61 | }, 62 | "event11": { 63 | "hostname": "sample", 64 | "user": "system", 65 | "eventTypeId": 5, 66 | "note": "sample needs a reboot" 67 | }, 68 | "event12": { 69 | "hostname": "test", 70 | "user": "system", 71 | "eventTypeId": 3, 72 | "note": "test needs a reboot" 73 | }, 74 | "event13": { 75 | "hostname": "test", 76 | "user": "system", 77 | "eventTypeId": 1, 78 | "note": "test needs a reboot" 79 | }, 80 | "event14": { 81 | "hostname": "test", 82 | "user": "system", 83 | "eventTypeId": 2, 84 | "note": "test needs a reboot" 85 | }, 86 | "event15": { 87 | "hostname": "test", 88 | "user": "system", 89 | "eventTypeId": 4, 90 | "note": "test needs a reboot" 91 | } 92 | } -------------------------------------------------------------------------------- /tests/api_tests/data/set1/eventtypes.json: -------------------------------------------------------------------------------- 1 | { 2 | "eventTypes": [ 3 | { 4 | "category": "system-reboot", 5 | "state": "required", 6 | "description": "This system requires a reboot." 7 | }, 8 | { 9 | "category": "system-reboot", 10 | "state": "completed", 11 | "description": "This system rebooted." 12 | }, 13 | { 14 | "category": "system-maintenance", 15 | "state": "required", 16 | "description": "This system requires maintenance." 17 | }, 18 | { 19 | "category": "system-maintenance", 20 | "state": "ready", 21 | "description": "This system is ready for maintenance." 22 | }, 23 | { 24 | "category": "system-maintenance", 25 | "state": "completed", 26 | "description": "System maintenance completed." 27 | }, 28 | { 29 | "category": "system-shutdown", 30 | "state": "required", 31 | "description": "System shutdown required." 32 | }, 33 | { 34 | "category": "system-shutdown", 35 | "state": "completed", 36 | "description": "System shutdown completed." 37 | } 38 | ] 39 | } 40 | 41 | -------------------------------------------------------------------------------- /tests/api_tests/data/set1/fates.json: -------------------------------------------------------------------------------- 1 | { 2 | "fate1": { 3 | "creationEventTypeId": 1, 4 | "description": "A system that needs a reboot can be cleared by rebooting the machine." 5 | }, 6 | "fate2": { 7 | "creationEventTypeId": 2, 8 | "followsId": 1, 9 | "description": "A system that needs a reboot can be cleared by rebooting the machine." 10 | }, 11 | "fate3": { 12 | "creationEventTypeId": 3, 13 | "description": "A system that needs maintenance made ready before maintenance can occur." 14 | }, 15 | "fate4": { 16 | "creationEventTypeId": 4, 17 | "followsId": 3, 18 | "forCreator": true, 19 | "forOwner": false, 20 | "description": "A system that needs maintenance made ready before maintenance can occur." 21 | }, 22 | "fate5": { 23 | "creationEventTypeId": 5, 24 | "followsId": 4, 25 | "description": "Maintenance must be performed on a system that is prepped." 26 | }, 27 | "fate6": { 28 | "creationEventTypeId": 3, 29 | "description": "Maintenance must be performed on a new system." 30 | }, 31 | "fate7": { 32 | "creationEventTypeId": 5, 33 | "followsId": 6, 34 | "description": "Maintenance must be performed on a new system that is prepped." 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /tests/api_tests/data/set1/hosts.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosts": [ 3 | { 4 | "hostname": "example" 5 | }, 6 | { 7 | "hostname": "sample" 8 | }, 9 | { 10 | "hostname": "test" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tests/api_tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import os 5 | import pytest 6 | import requests 7 | import socket 8 | import threading 9 | import tornado 10 | import tornado.httpserver 11 | import tornado.ioloop 12 | from tornado import netutil 13 | 14 | import hermes 15 | from hermes import models 16 | from hermes.models import Model, Session, Host, EventType, Event, Fate, Labor, Quest 17 | from hermes.settings import settings 18 | from hermes.app import Application 19 | from .util import load_json, Client 20 | 21 | 22 | sa_log = logging.getLogger("sqlalchemy.engine.base.Engine") 23 | 24 | # Uncomment to have all queries printed out 25 | # sa_log.setLevel(logging.INFO) 26 | 27 | 28 | class Server(object): 29 | """ Wrapper around Tornado server with test helpers. """ 30 | 31 | def __init__(self, tornado_app): 32 | self.tornado_app = tornado_app 33 | self.server = tornado.httpserver.HTTPServer( 34 | tornado_app 35 | ) 36 | self.server.add_sockets(netutil.bind_sockets( 37 | None, "localhost", family=socket.AF_INET 38 | )) 39 | self.server.start() 40 | self.io_thread = threading.Thread( 41 | target=tornado.ioloop.IOLoop.instance().start 42 | ) 43 | self.io_thread.start() 44 | 45 | @property 46 | def port(self): 47 | return self.server._sockets.values()[0].getsockname()[1] 48 | 49 | 50 | @pytest.fixture() 51 | def tornado_app(request, tmpdir): 52 | db_path = tmpdir.join("nsot.sqlite") 53 | db_engine = models.get_db_engine("sqlite:///%s" % db_path) 54 | 55 | Model.metadata.drop_all(db_engine) 56 | Model.metadata.create_all(db_engine) 57 | Session.configure(bind=db_engine) 58 | 59 | Fate._all_fates = None 60 | 61 | my_settings = { 62 | "db_engine": db_engine, 63 | "db_session": Session, 64 | "domain": "example.com" 65 | } 66 | 67 | tornado_settings = { 68 | "debug": False, 69 | } 70 | 71 | return Application(my_settings=my_settings, **tornado_settings) 72 | 73 | 74 | @pytest.fixture() 75 | def tornado_server(request, tornado_app): 76 | 77 | server = Server(tornado_app) 78 | 79 | def fin(): 80 | tornado.ioloop.IOLoop.instance().stop() 81 | server.io_thread.join() 82 | request.addfinalizer(fin) 83 | 84 | return server 85 | 86 | 87 | @pytest.fixture 88 | def session(request, tmpdir): 89 | db_path = tmpdir.join("nsot.sqlite") 90 | db_engine = models.get_db_engine("sqlite:///%s" % db_path) 91 | 92 | Model.metadata.drop_all(db_engine) 93 | Model.metadata.create_all(db_engine) 94 | Session.configure(bind=db_engine) 95 | session = Session() 96 | 97 | def fin(): 98 | session.close() 99 | request.addfinalizer(fin) 100 | 101 | return session 102 | 103 | @pytest.fixture 104 | def sample_data1_server(tornado_server): 105 | client = Client(tornado_server) 106 | hosts_data = load_json("set1/hosts.json") 107 | client.create("/hosts/", hosts=hosts_data["hosts"]) 108 | 109 | event_types_data = load_json("set1/eventtypes.json") 110 | client.create("/eventtypes/", eventTypes=event_types_data["eventTypes"]) 111 | 112 | events = load_json("set1/event.json") 113 | client.post("/events/", json=events["event1"]) 114 | client.post("/events/", json=events['event2']) 115 | 116 | fates = load_json("set1/fates.json") 117 | client.post("/fates/", json=fates["fate1"]) 118 | client.post("/fates/", json=fates["fate2"]) 119 | client.post("/fates/", json=fates["fate3"]) 120 | client.post("/fates/", json=fates["fate4"]) 121 | client.post("/fates/", json=fates["fate5"]) 122 | 123 | return client 124 | 125 | 126 | @pytest.fixture 127 | def sample_data2_server(tornado_server): 128 | client = Client(tornado_server) 129 | hosts_data = load_json("set1/hosts.json") 130 | client.create("/hosts/", hosts=hosts_data["hosts"]) 131 | 132 | event_types_data = load_json("set1/eventtypes.json") 133 | client.create("/eventtypes/", eventTypes=event_types_data["eventTypes"]) 134 | 135 | events = load_json("set1/event.json") 136 | for x in range(1, len(events) + 1): 137 | client.post("/events/", json=events["event{}".format(x)]) 138 | 139 | fates = load_json("set1/fates.json") 140 | client.post("/fates/", json=fates["fate1"]) 141 | client.post("/fates/", json=fates["fate2"]) 142 | client.post("/fates/", json=fates["fate3"]) 143 | client.post("/fates/", json=fates["fate4"]) 144 | client.post("/fates/", json=fates["fate5"]) 145 | 146 | return client -------------------------------------------------------------------------------- /tests/api_tests/test_eventtypes.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import requests 4 | 5 | from .fixtures import tornado_server, tornado_app, sample_data1_server 6 | from .util import ( 7 | assert_error, assert_success, assert_created, assert_deleted, Client 8 | ) 9 | 10 | 11 | def test_malformed(tornado_server): 12 | client = Client(tornado_server) 13 | assert_error(client.post("/eventtypes", data="Non-JSON"), 400) 14 | 15 | 16 | def test_creation(tornado_server): 17 | client = Client(tornado_server) 18 | assert_success(client.get("/eventtypes"), { 19 | "eventTypes": [], 20 | "limit": 10, 21 | "offset": 0, 22 | "totalEventTypes": 0, 23 | }) 24 | 25 | assert_created( 26 | client.create( 27 | "/eventtypes", 28 | category="foo", 29 | state="bar", 30 | description="This is a test", 31 | ), "/api/v1/eventtypes/1" 32 | ) 33 | assert_error( 34 | client.create( 35 | "/eventtypes", 36 | category="foo", 37 | state="bar", 38 | description="Reject duplicate" 39 | ), 409 40 | ) 41 | 42 | assert_success( 43 | client.get("/eventtypes"), 44 | { 45 | "eventTypes": [{ 46 | "id": 1, 47 | "category": "foo", 48 | "state": "bar", 49 | "description": "This is a test", 50 | "restricted": False, 51 | }], 52 | "limit": 10, 53 | "offset": 0, 54 | "totalEventTypes": 1, 55 | } 56 | ) 57 | 58 | assert_success( 59 | client.get("/eventtypes/1"), 60 | { 61 | "id": 1, 62 | "category": "foo", 63 | "state": "bar", 64 | "description": "This is a test", 65 | "restricted": False, 66 | "events": [], 67 | "limit": 10, 68 | "offset": 0, 69 | } 70 | ) 71 | 72 | assert_created( 73 | client.create( 74 | "/eventtypes", 75 | category="foo", 76 | state="baz", 77 | description="This is a second test" 78 | ), "/api/v1/eventtypes/2" 79 | ) 80 | assert_success( 81 | client.get("/eventtypes?expand=fates", 82 | params={"category": "foo", "state": "baz"}), 83 | { 84 | "eventTypes": [{ 85 | "id": 2, 86 | "category": "foo", 87 | "state": "baz", 88 | "description": "This is a second test", 89 | "restricted": False, 90 | "autoCreates": [] 91 | }], 92 | "limit": 10, 93 | "offset": 0, 94 | "totalEventTypes": 1 95 | } 96 | ) 97 | 98 | 99 | def test_create_multiple(tornado_server): 100 | client = Client(tornado_server) 101 | assert_success(client.get("/eventtypes"), { 102 | "eventTypes": [], 103 | "limit": 10, 104 | "offset": 0, 105 | "totalEventTypes": 0, 106 | }) 107 | 108 | client.create( 109 | "/eventtypes", 110 | eventTypes=[ 111 | { 112 | "category": "foo", 113 | "state": "bar", 114 | "description": "This is a test", 115 | "restricted": False, 116 | }, 117 | { 118 | "category": "foo", 119 | "state": "baz", 120 | "description": "This is a 2nd test", 121 | "restricted": False, 122 | } 123 | ] 124 | ) 125 | 126 | assert_success(client.get("/eventtypes"), { 127 | "limit": 10, 128 | "offset": 0, 129 | "totalEventTypes": 2, 130 | }, strip="eventTypes") 131 | 132 | 133 | def test_update(tornado_server): 134 | client = Client(tornado_server) 135 | assert_success(client.get("/eventtypes"), { 136 | "eventTypes": [], 137 | "limit": 10, 138 | "offset": 0, 139 | "totalEventTypes": 0, 140 | }) 141 | 142 | assert_created( 143 | client.create( 144 | "/eventtypes", 145 | category="foo", 146 | state="bar", 147 | description="This is a test" 148 | ), "/api/v1/eventtypes/1" 149 | ) 150 | 151 | assert_success( 152 | client.update("/eventtypes/1", description="new"), 153 | { 154 | "id": 1, 155 | "category": "foo", 156 | "state": "bar", 157 | "description": "new", 158 | "restricted": False, 159 | } 160 | ) 161 | 162 | assert_error(client.update("/eventtypes/1"), 400) 163 | 164 | 165 | def test_filter_by_creating_types(sample_data1_server): 166 | client = sample_data1_server 167 | 168 | assert_success( 169 | client.get("/eventtypes?startingTypes=true"), { 170 | "limit": 10, 171 | "offset": 0, 172 | "totalEventTypes": 2 173 | }, strip=['eventTypes'] 174 | ) 175 | -------------------------------------------------------------------------------- /tests/api_tests/test_fates.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import requests 4 | 5 | from .fixtures import tornado_server, tornado_app, sample_data1_server 6 | from .util import ( 7 | assert_error, assert_success, assert_created, assert_deleted, Client 8 | ) 9 | 10 | 11 | def test_malformed(sample_data1_server): 12 | client = sample_data1_server 13 | assert_error(client.post("/fates", data="Non-JSON"), 400) 14 | 15 | 16 | def test_bad_creation(sample_data1_server): 17 | """Fate's must be either set to forOwner, forCreator, or both""" 18 | client = sample_data1_server 19 | assert_error( 20 | client.post( 21 | "/fates", 22 | data={ 23 | "creationEventTypeId": 6, 24 | "forOwner": False, 25 | "forCreator": False, 26 | "precedesIds": [], 27 | "description":"New fate" 28 | } 29 | ), 30 | 400 31 | ) 32 | 33 | 34 | def test_creation(sample_data1_server): 35 | client = sample_data1_server 36 | assert_success( 37 | client.get("/eventtypes"), 38 | { 39 | "limit": 10, 40 | "offset": 0, 41 | "totalEventTypes": 7, 42 | }, 43 | strip="eventTypes" 44 | ) 45 | 46 | assert_created( 47 | client.create( 48 | "/fates/", 49 | creationEventTypeId=6, 50 | description="New fate" 51 | ), 52 | "/api/v1/fates/6" 53 | ) 54 | 55 | assert_created( 56 | client.create( 57 | "/fates/", 58 | creationEventTypeId=7, 59 | followsId=6, 60 | description="New fate2" 61 | ), 62 | "/api/v1/fates/7" 63 | ) 64 | 65 | assert_success( 66 | client.get("/fates/6"), 67 | { 68 | "id": 6, 69 | "creationEventTypeId": 6, 70 | "followsId": None, 71 | "forOwner": True, 72 | "forCreator": False, 73 | "precedesIds": [7], 74 | "description": "New fate" 75 | } 76 | ) 77 | 78 | assert_success( 79 | client.get("/fates/7"), 80 | { 81 | "id": 7, 82 | "creationEventTypeId": 7, 83 | "followsId": 6, 84 | "forOwner": True, 85 | "forCreator": False, 86 | "precedesIds": [], 87 | "description": "New fate2" 88 | } 89 | ) 90 | 91 | 92 | def test_update(sample_data1_server): 93 | client = sample_data1_server 94 | assert_created( 95 | client.create( 96 | "/fates/", 97 | creationEventTypeId=6, 98 | description="New fate" 99 | ), 100 | "/api/v1/fates/6" 101 | ) 102 | 103 | assert_success( 104 | client.get("/fates/6"), 105 | { 106 | "id": 6, 107 | "creationEventTypeId": 6, 108 | "followsId": None, 109 | "forOwner": True, 110 | "forCreator": False, 111 | "precedesIds": [], 112 | "description": "New fate" 113 | } 114 | ) 115 | 116 | assert_success( 117 | client.update( 118 | "/fates/6", 119 | followsId=1 120 | ), 121 | { 122 | "id": 6, 123 | "creationEventTypeId": 6, 124 | "followsId": 1, 125 | "forOwner": True, 126 | "forCreator": False, 127 | "precedesIds": [], 128 | "description": "New fate" 129 | } 130 | ) 131 | 132 | assert_success( 133 | client.update( 134 | "/fates/6", 135 | description="New desc" 136 | ), 137 | { 138 | "id": 6, 139 | "creationEventTypeId": 6, 140 | "followsId": 1, 141 | "forOwner": True, 142 | "forCreator": False, 143 | "precedesIds": [], 144 | "description": "New desc" 145 | } 146 | ) 147 | 148 | assert_success( 149 | client.update( 150 | "/fates/6", 151 | followsId=None, 152 | description="Another desc" 153 | ), 154 | { 155 | "id": 6, 156 | "creationEventTypeId": 6, 157 | "followsId": None, 158 | "forOwner": True, 159 | "forCreator": False, 160 | "precedesIds": [], 161 | "description": "Another desc" 162 | } 163 | ) 164 | 165 | assert_success( 166 | client.get("/fates/6"), 167 | { 168 | "id": 6, 169 | "creationEventTypeId": 6, 170 | "followsId": None, 171 | "forOwner": True, 172 | "forCreator": False, 173 | "precedesIds": [], 174 | "description": "Another desc" 175 | } 176 | ) 177 | 178 | -------------------------------------------------------------------------------- /tests/api_tests/test_hosts.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import requests 4 | 5 | from .fixtures import tornado_server, tornado_app 6 | from .util import ( 7 | assert_error, assert_success, assert_created, assert_deleted, Client 8 | ) 9 | 10 | 11 | def test_malformed(tornado_server): 12 | client = Client(tornado_server) 13 | assert_error(client.post("/hosts", data="Non-JSON"), 400) 14 | 15 | 16 | def test_creation(tornado_server): 17 | client = Client(tornado_server) 18 | assert_success(client.get("/hosts"), { 19 | "hosts": [], 20 | "limit": 10, 21 | "offset": 0, 22 | "totalHosts": 0, 23 | }) 24 | 25 | assert_created( 26 | client.create("/hosts", hostname="example"), "/api/v1/hosts/example" 27 | ) 28 | assert_error(client.create("/hosts", hostname="example"), 409) 29 | 30 | assert_success( 31 | client.get("/hosts"), 32 | { 33 | "hosts": [{ 34 | "id": 1, 35 | "hostname": "example" 36 | }], 37 | "limit": 10, 38 | "offset": 0, 39 | "totalHosts": 1, 40 | } 41 | ) 42 | 43 | assert_success( 44 | client.get("/hosts/example"), 45 | { 46 | "id": 1, 47 | "hostname": "example", 48 | "events": [], 49 | "labors": [], 50 | "quests": [], 51 | "lastEvent": None, 52 | "limit": 10, 53 | "offset": 0, 54 | } 55 | ) 56 | 57 | assert_created(client.create("/hosts", hostname="sample"), "/api/v1/hosts/sample") 58 | assert_success( 59 | client.get("/hosts", params={"hostname": "sample"}), 60 | { 61 | "hosts": [{ 62 | "id": 2, 63 | "hostname": "sample" 64 | }], 65 | "limit": 10, 66 | "offset": 0, 67 | "totalHosts": 1 68 | } 69 | ) 70 | 71 | def test_create_multiple(tornado_server): 72 | client = Client(tornado_server) 73 | assert_success(client.get("/hosts"), { 74 | "hosts": [], 75 | "limit": 10, 76 | "offset": 0, 77 | "totalHosts": 0, 78 | }) 79 | 80 | client.create( 81 | "/hosts", 82 | hosts=[ 83 | {"hostname":"example"}, 84 | {"hostname":"sample"}, 85 | {"hostname":"test"} 86 | ] 87 | ) 88 | 89 | assert_success(client.get("/hosts"), { 90 | "limit": 10, 91 | "offset": 0, 92 | "totalHosts": 3, 93 | }, strip="hosts") 94 | 95 | 96 | def test_update(tornado_server): 97 | client = Client(tornado_server) 98 | 99 | client.create("/hosts", hostname="testname") 100 | 101 | assert_success( 102 | client.update("/hosts/testname", hostname="newname"), 103 | { 104 | "id": 1, 105 | "hostname": "newname" 106 | } 107 | ) 108 | 109 | # test failure of empty update calls 110 | assert_error(client.update("/hosts/newname"), 400) 111 | 112 | 113 | def test_merging(tornado_server): 114 | """When renaming a server to an existing servername, just merge them""" 115 | client = Client(tornado_server) 116 | 117 | client.create("/hosts", hostname="testname") 118 | client.create("/hosts", hostname="newname") 119 | 120 | assert_success( 121 | client.update("/hosts/testname", hostname="newname"), 122 | { 123 | "id": 2, 124 | "hostname": "newname" 125 | } 126 | ) -------------------------------------------------------------------------------- /tests/api_tests/test_labors.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import requests 4 | 5 | from datetime import datetime, timedelta 6 | 7 | from .fixtures import tornado_server, tornado_app, sample_data1_server 8 | from .util import ( 9 | assert_error, assert_success, assert_created, assert_deleted, Client 10 | ) 11 | 12 | 13 | def test_malformed(sample_data1_server): 14 | client = sample_data1_server 15 | assert_error(client.post("/quests", data="Non-JSON"), 400) 16 | 17 | 18 | def test_creation(sample_data1_server): 19 | client = sample_data1_server 20 | assert_success( 21 | client.get("/events"), 22 | { 23 | "limit": 10, 24 | "offset": 0, 25 | "totalEvents": 2 26 | }, 27 | strip=["timestamp", "events"] 28 | ) 29 | 30 | assert_success( 31 | client.get("/quests"), 32 | { 33 | "limit": 10, 34 | "offset": 0, 35 | "totalQuests": 0, 36 | "quests": [] 37 | } 38 | ) 39 | 40 | assert_success( 41 | client.get("/labors"), 42 | { 43 | "limit": 10, 44 | "offset": 0, 45 | "totalLabors": 0, 46 | "labors": [] 47 | } 48 | ) 49 | 50 | target_time = datetime.utcnow() + timedelta(days=7) 51 | 52 | assert_created( 53 | client.create( 54 | "/quests", 55 | creator="johnny", 56 | fateId=1, 57 | targetTime=str(target_time), 58 | description="This is a quest almighty", 59 | hostnames=["example", "sample", "test"] 60 | ), 61 | "/api/v1/quests/1" 62 | ) 63 | 64 | assert_success( 65 | client.get("/labors"), 66 | { 67 | "limit": 10, 68 | "offset": 0, 69 | "totalLabors": 3, 70 | "labors": [{"ackTime": None, 71 | "ackUser": None, 72 | "fateId": 1, 73 | "closingFateId": None, 74 | "completionEventId": None, 75 | "creationEventId": 3, 76 | "targetTime": str(target_time), 77 | "hostId": 1, 78 | "forOwner": True, 79 | "forCreator": False, 80 | "id": 1, 81 | "startingLaborId": None, 82 | "questId": 1}, 83 | {"ackTime": None, 84 | "ackUser": None, 85 | "completionEventId": None, 86 | "creationEventId": 4, 87 | "targetTime": str(target_time), 88 | "hostId": 2, 89 | "forOwner": True, 90 | "forCreator": False, 91 | "fateId": 1, 92 | "closingFateId": None, 93 | "id": 2, 94 | "startingLaborId": None, 95 | "questId": 1}, 96 | {"ackTime": None, 97 | "ackUser": None, 98 | "completionEventId": None, 99 | "creationEventId": 5, 100 | "targetTime": str(target_time), 101 | "hostId": 3, 102 | "forOwner": True, 103 | "forCreator": False, 104 | "fateId": 1, 105 | "closingFateId": None, 106 | "id": 3, 107 | "startingLaborId": None, 108 | "questId": 1}], 109 | }, 110 | strip=["creationTime", "completionTime"] 111 | ) 112 | 113 | 114 | def test_update(sample_data1_server): 115 | client = sample_data1_server 116 | 117 | # create a quest without a target_time 118 | assert_created( 119 | client.create( 120 | "/quests", 121 | creator="johnny", 122 | fateId=1, 123 | description="This is a quest almighty", 124 | hostnames=["example", "sample", "test"] 125 | ), 126 | "/api/v1/quests/1" 127 | ) 128 | 129 | # make sure 3 labors was created for this quest 130 | assert_success( 131 | client.get("/labors"), 132 | { 133 | "limit": 10, 134 | "offset": 0, 135 | "totalLabors": 3 136 | }, 137 | strip=["creationTime", "labors"] 138 | ) 139 | 140 | # create a new event that would create another labor 141 | assert_created( 142 | client.create( 143 | "/events", 144 | hostname="example", 145 | user="testman@example.com", 146 | eventTypeId=1, 147 | note="This is a test event" 148 | ), 149 | "/api/v1/events/6" 150 | ) 151 | 152 | # make sure the labor is not attached to a quest 153 | assert_success( 154 | client.get("/labors/4"), 155 | { 156 | "ackTime": None, 157 | "ackUser": None, 158 | "completionEventId": None, 159 | "completionTime": None, 160 | "creationEventId": 6, 161 | "hostId": 1, 162 | "forOwner": True, 163 | "forCreator": False, 164 | "fateId": 1, 165 | "closingFateId": None, 166 | "id": 4, 167 | "startingLaborId": None, 168 | "questId": None 169 | }, 170 | strip=["creationTime"] 171 | ) 172 | 173 | # attach the labor to a quest 174 | response = client.update( 175 | "/labors/4", 176 | ackUser="johnny@example.com", 177 | questId=1 178 | ) 179 | 180 | # make sure the labor is attached to the quest 181 | assert_success( 182 | response, 183 | { 184 | "ackUser": "johnny@example.com", 185 | "completionEventId": None, 186 | "completionTime": None, 187 | "creationEventId": 6, 188 | "targetTime": None, 189 | "hostId": 1, 190 | "fateId": 1, 191 | "closingFateId": None, 192 | "forOwner": True, 193 | "forCreator": False, 194 | "id": 4, 195 | "startingLaborId": None, 196 | "questId": 1 197 | }, 198 | strip=["creationTime", "ackTime"] 199 | ) 200 | 201 | assert response.json()['ackTime'] is not None 202 | 203 | 204 | def test_labor_filter_by_eventttype(sample_data1_server): 205 | client = sample_data1_server 206 | 207 | assert_success( 208 | client.get("/labors"), 209 | { 210 | "limit": 10, 211 | "offset": 0, 212 | "totalLabors": 0, 213 | "labors": [] 214 | } 215 | ) 216 | 217 | # create a quest without a target_time 218 | assert_created( 219 | client.create( 220 | "/quests", 221 | creator="johnny", 222 | fateId=1, 223 | description="This is a quest almighty", 224 | hostnames=["example", "sample", "test"] 225 | ), 226 | "/api/v1/quests/1" 227 | ) 228 | 229 | # create a quest without a target_time 230 | assert_created( 231 | client.create( 232 | "/quests", 233 | creator="johnny", 234 | fateId=3, 235 | description="This is a 2nd quest almighty", 236 | hostnames=["example", "sample", "test"] 237 | ), 238 | "/api/v1/quests/2" 239 | ) 240 | 241 | assert_success( 242 | client.get("/labors"), 243 | { 244 | "limit": 10, 245 | "offset": 0, 246 | "totalLabors": 6, 247 | }, 248 | strip=["labors"] 249 | ) 250 | 251 | assert_success( 252 | client.get("/labors?hostname=example"), 253 | { 254 | "limit": 10, 255 | "offset": 0, 256 | "totalLabors": 2 257 | }, 258 | strip=["labors"] 259 | ) 260 | 261 | assert_success( 262 | client.get("/labors?category=system-reboot&state=required"), 263 | { 264 | "limit": 10, 265 | "offset": 0, 266 | "totalLabors": 3 267 | }, 268 | strip=["labors"] 269 | ) 270 | 271 | assert_success( 272 | client.get("/labors?category=system-maintenance"), 273 | { 274 | "limit": 10, 275 | "offset": 0, 276 | "totalLabors": 3 277 | }, 278 | strip=["labors"] 279 | ) 280 | 281 | 282 | def test_quest_expansion(sample_data1_server): 283 | client = sample_data1_server 284 | 285 | # create a quest without a target_time 286 | assert_created( 287 | client.create( 288 | "/quests", 289 | creator="johnny", 290 | fateId=1, 291 | description="This is a quest almighty", 292 | hostnames=["example"] 293 | ), 294 | "/api/v1/quests/1" 295 | ) 296 | 297 | assert_created( 298 | client.create( 299 | "/events", 300 | eventTypeId=1, 301 | hostname="sample", 302 | user="testman@example.com", 303 | ), 304 | "/api/v1/events/4" 305 | ) 306 | 307 | assert_success( 308 | client.get("/labors?expand=quests"), 309 | { 310 | "limit": 10, 311 | "offset": 0, 312 | "totalLabors": 2, 313 | "labors": [ 314 | {'ackTime': None, 315 | 'ackUser': None, 316 | 'completionEventId': None, 317 | 'completionTime': None, 318 | 'creationEventId': 3, 319 | 'forCreator': False, 320 | 'forOwner': True, 321 | 'hostId': 1, 322 | 'id': 1, 323 | 'fateId': 1, 324 | "closingFateId": None, 325 | 'quest': { 326 | 'completionTime': None, 327 | 'creator': 'johnny@example.com', 328 | 'description': 'This is a quest almighty', 329 | 'id': 1, 330 | 'targetTime': None 331 | }, 332 | 'questId': 1, 333 | 'startingLaborId': None, 334 | 'targetTime': None 335 | }, 336 | {'ackTime': None, 337 | 'ackUser': None, 338 | 'completionEventId': None, 339 | 'completionTime': None, 340 | 'creationEventId': 4, 341 | 'forCreator': False, 342 | 'forOwner': True, 343 | 'hostId': 2, 344 | 'id': 2, 345 | 'fateId': 1, 346 | "closingFateId": None, 347 | 'quest': None, 348 | 'questId': None, 349 | 'startingLaborId': None 350 | } 351 | ] 352 | }, 353 | strip=["embarkTime", "creationTime"] 354 | ) -------------------------------------------------------------------------------- /tests/api_tests/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import requests 4 | import urllib 5 | import urlparse 6 | 7 | 8 | def _deep_sort(obj): 9 | if isinstance(obj, dict): 10 | return { 11 | key: _deep_sort(value) 12 | for key, value in obj.iteritems() 13 | } 14 | elif isinstance(obj, list): 15 | return sorted(_deep_sort(elem) for elem in obj) 16 | return obj 17 | 18 | 19 | def _stripper(obj, strip): 20 | if isinstance(obj, dict): 21 | json = {} 22 | for key, value in obj.iteritems(): 23 | if isinstance(value, basestring): 24 | value = str(value) 25 | if key not in strip: 26 | json[str(key)] = _stripper(value, strip) 27 | 28 | return json 29 | elif isinstance(obj, list): 30 | return [_stripper(elem, strip) for elem in obj] 31 | return obj 32 | 33 | 34 | def assert_error(response, code): 35 | output = response.json() 36 | assert output["status"] == "error" 37 | assert output["error"]["code"] == code 38 | 39 | 40 | def assert_success(response, data=None, ignore_order=True, strip=[]): 41 | output = response.json() 42 | if isinstance(strip, basestring): 43 | strip = [strip, "href"] 44 | else: 45 | strip.append("href") 46 | output = _stripper(output, strip) 47 | assert response.status_code == 200 48 | assert output["status"] == "ok" 49 | 50 | data["status"] = "ok" 51 | if ignore_order: 52 | assert _deep_sort(output) == _deep_sort(data) 53 | else: 54 | assert output == data 55 | 56 | 57 | def assert_created(response, location, data=None): 58 | output = response.json() 59 | assert response.status_code == 201 60 | assert output["status"] == "created" 61 | assert response.headers.get("Location") == location 62 | if data is not None: 63 | assert output == data 64 | 65 | 66 | def assert_deleted(response): 67 | output = response.json() 68 | assert response.status_code == 200 69 | assert output["status"] == "ok" 70 | 71 | 72 | class Client(object): 73 | def __init__(self, tornado_server, user="user"): 74 | self.tornado_server = tornado_server 75 | self.user = "{}@localhost".format(user) 76 | 77 | @property 78 | def base_url(self): 79 | return "http://localhost:{}/api/v1".format(self.tornado_server.port) 80 | 81 | def request(self, method, url, **kwargs): 82 | 83 | headers = { 84 | "X-NSoT-Email": self.user 85 | } 86 | 87 | if method.lower() in ("put", "post"): 88 | headers["Content-type"] = "application/json" 89 | 90 | return requests.request( 91 | method, self.base_url + url, 92 | headers=headers, **kwargs 93 | ) 94 | 95 | def get(self, url, **kwargs): 96 | return self.request("GET", url, **kwargs) 97 | 98 | def post(self, url, **kwargs): 99 | return self.request("POST", url, **kwargs) 100 | 101 | def put(self, url, **kwargs): 102 | return self.request("PUT", url, **kwargs) 103 | 104 | def delete(self, url, **kwargs): 105 | return self.request("DELETE", url, **kwargs) 106 | 107 | def create(self, url, **kwargs): 108 | return self.post(url, data=json.dumps(kwargs)) 109 | 110 | def update(self, url, **kwargs): 111 | return self.put(url, data=json.dumps(kwargs)) 112 | 113 | 114 | def load_json(relpath): 115 | """ 116 | Load JSON files relative to this directory. 117 | 118 | Files are loaded from the 'data' directory. So for example for 119 | ``/path/to/data/devices/foo.json`` the ``relpath`` would be 120 | ``devices/foo.json``. 121 | 122 | :param relpath: 123 | Relative path to our directory's "data" dir 124 | """ 125 | our_path = os.path.dirname(os.path.abspath(__file__)) 126 | data_dir = os.path.join(our_path, 'data') 127 | filepath = os.path.join(data_dir, relpath) 128 | with open(filepath, 'rb') as f: 129 | return json.load(f) 130 | 131 | 132 | def run_set_queries(resource_name, client, device_queries): 133 | """ 134 | Run set queries on the specified resource. 135 | 136 | The directory structure is expected to match the resource path. So 137 | "devices/query" would map to both the data directory for JSON response 138 | files, and the API URL of "/api/sites/1/devices/query". 139 | 140 | The ``device_queries is expected to be a list of 2-tuples of (query, 141 | filename) where query is a set query and filename is a file containing the 142 | expected response JSON. Example:: 143 | 144 | [ 145 | ('foo=bar', 'test1.json'), 146 | ('bar=baz', 'test2.json'), 147 | ] 148 | 149 | :param resource_name: 150 | The resource for which to run query tests 151 | 152 | :param client: 153 | A ``Client`` instance 154 | 155 | :param devices_queries: 156 | A list of 2-tuples of (query, filename) 157 | """ 158 | base_path = '/sites/1' 159 | path = os.path.join(resource_name, 'query') 160 | base_uri = os.path.join(base_path, path) 161 | uri = base_uri + '?query=' # '/sites/1/devices/query?query=' 162 | 163 | # Walk the query and filename, construct a URL w/ the query, load up the 164 | # file, call the URL w/ the client, and compare the return data to the 165 | # loaded data. 166 | for query, filename in device_queries: 167 | qs = urllib.quote_plus(query) # 'foo=bar' => 'foo%3Dbar' 168 | url = uri + qs # => /sites/1/devices/query?query=foo%3Dbar 169 | filepath = os.path.join(path, filename) # devices/query/query1.json 170 | output = load_json(filepath) 171 | assert_success( 172 | client.get(url), 173 | output['data'] 174 | ) 175 | -------------------------------------------------------------------------------- /tests/model_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/hermes/36e5c0532571eed42d5f6edea35f755b837a5b2d/tests/model_tests/__init__.py -------------------------------------------------------------------------------- /tests/model_tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hermes import models 4 | 5 | @pytest.fixture 6 | def db_engine(tmpdir): 7 | db_path = tmpdir.join("hermes.sqlite") 8 | db_engine = models.get_db_engine("sqlite:///%s" % db_path) 9 | 10 | return db_engine 11 | 12 | @pytest.fixture 13 | def session(db_engine, request, tmpdir): 14 | models.Model.metadata.drop_all(db_engine) 15 | models.Model.metadata.create_all(db_engine) 16 | models.Session.configure(bind=db_engine) 17 | session = models.Session() 18 | 19 | models.Fate._all_fates = None 20 | 21 | def fin(): 22 | session.close() 23 | request.addfinalizer(fin) 24 | 25 | return session 26 | 27 | @pytest.fixture 28 | def sample_data1(session): 29 | sql_file = open('tests/sample_data/sample_data1.sql') 30 | sql = sql_file.read() 31 | for statement in sql.split(";"): 32 | session.execute(statement) 33 | 34 | return session 35 | 36 | @pytest.fixture 37 | def sample_data2(session): 38 | sql_file = open('tests/sample_data/sample_data2.sql') 39 | sql = sql_file.read() 40 | for statement in sql.split(";"): 41 | session.execute(statement) 42 | 43 | return session -------------------------------------------------------------------------------- /tests/model_tests/test_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from hermes import exc 5 | from hermes import models 6 | 7 | from .fixtures import db_engine, session, sample_data1 8 | 9 | 10 | def test_creation(sample_data1): 11 | event_types = sample_data1.query(models.EventType).all() 12 | assert len(event_types) == 7 13 | event_type1 = event_types[0] 14 | assert len(event_type1.events) == 1 15 | 16 | hosts = sample_data1.query(models.Host).all() 17 | assert len(hosts) == 3 18 | host = hosts[0] 19 | assert len(host.events) == 2 20 | 21 | models.Event.create( 22 | sample_data1, host, "testman", event_type1, note="This is a test event" 23 | ) 24 | sample_data1.commit() 25 | 26 | events = sample_data1.query(models.Event).all() 27 | 28 | # the total number of events should be 3 now. We care about the new one 29 | assert len(events) == 3 30 | event = events[2] 31 | assert event.id == 3 32 | assert event.host == host 33 | assert event.user == "testman" 34 | assert event.event_type == event_type1 35 | assert event.note == "This is a test event" 36 | 37 | assert len(host.events) == 3 38 | assert len(event_type1.events) == 2 39 | 40 | 41 | def test_duplicate(sample_data1): 42 | """Test to ensure duplicate events are fine b/c there can be multiple identical events""" 43 | event_types = sample_data1.query(models.EventType).all() 44 | assert len(event_types) == 7 45 | event_type1 = event_types[0] 46 | 47 | hosts = sample_data1.query(models.Host).all() 48 | assert len(hosts) == 3 49 | host = hosts[0] 50 | 51 | models.Event.create( 52 | sample_data1, host, "testman", event_type1, note="This is a test event" 53 | ) 54 | sample_data1.commit() 55 | 56 | models.Event.create( 57 | sample_data1, host, "testman", event_type1, note="This is another test event" 58 | ) 59 | sample_data1.commit() 60 | 61 | 62 | def test_very_large_note(sample_data1): 63 | """Test to ensure notes can be larger than 1024 bytes.""" 64 | event_types = sample_data1.query(models.EventType).all() 65 | assert len(event_types) == 7 66 | event_type1 = event_types[0] 67 | 68 | hosts = sample_data1.query(models.Host).all() 69 | assert len(hosts) == 3 70 | host = hosts[0] 71 | 72 | very_large_note = "x" * 60000 73 | models.Event.create( 74 | sample_data1, host, "testman", event_type1, note=very_large_note 75 | ) 76 | sample_data1.commit() 77 | 78 | events = sample_data1.query(models.Event).all() 79 | 80 | # the total number of events should be 3 now. We care about the new one 81 | assert len(events) == 3 82 | event = events[2] 83 | assert event.id == 3 84 | assert event.host == host 85 | assert event.user == "testman" 86 | assert event.event_type == event_type1 87 | assert event.note == very_large_note -------------------------------------------------------------------------------- /tests/model_tests/test_eventtypes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from hermes import exc 5 | from hermes.models import EventType, Host, Event 6 | 7 | from .fixtures import db_engine, session, sample_data1 8 | 9 | 10 | def test_creation(session): 11 | EventType.create(session, "foo", "bar", "This is a test") 12 | session.commit() 13 | 14 | event_types = session.query(EventType).all() 15 | 16 | assert len(event_types) == 1 17 | assert event_types[0].id == 1 18 | assert event_types[0].category == "foo" 19 | assert event_types[0].state == "bar" 20 | assert event_types[0].description == "This is a test" 21 | 22 | event_type = EventType.get_event_type(session, "foo", "bar") 23 | assert event_type.id == 1 24 | assert event_type.category == "foo" 25 | assert event_type.state == "bar" 26 | assert event_type.description == "This is a test" 27 | 28 | assert event_type.href('/test') == '/test/eventtypes/1' 29 | 30 | 31 | def test_duplicate(session): 32 | EventType.create(session, "foo", "bar", "This is a test") 33 | 34 | with pytest.raises(IntegrityError): 35 | EventType.create(session, "foo", "bar", "Desc ignored") 36 | 37 | EventType.create(session, "foo", "bar2", "This is second test") 38 | EventType.create(session, "foo2", "bar", "This is second test") 39 | 40 | 41 | def test_required(session): 42 | EventType.create(session, "foo", "bar", "This is a test") 43 | EventType.create(session, "foo", "bar2") 44 | 45 | with pytest.raises(exc.ValidationError): 46 | EventType.create(session, "foo", None) 47 | 48 | with pytest.raises(exc.ValidationError): 49 | EventType.create(session, None, "bar") 50 | 51 | 52 | def test_get_latest_events(session): 53 | event_type1 = EventType.create(session, "foo", "bar", "test type 1") 54 | event_type2 = EventType.create(session, "foo", "baz", "test type 2") 55 | 56 | host1 = Host.create(session, "server1") 57 | host2 = Host.create(session, "server2") 58 | 59 | Event.create(session, host1, "testman", event_type1) 60 | Event.create(session, host1, "testman", event_type2) 61 | Event.create(session, host2, "testman", event_type1) 62 | Event.create(session, host1, "testman", event_type1) 63 | Event.create(session, host1, "testman", event_type2) 64 | last_type2 = Event.create(session, host2, "testman", event_type2) 65 | last_type1 = Event.create(session, host2, "testman", event_type1) 66 | 67 | events1 = event_type1.get_latest_events().all() 68 | events2 = event_type2.get_latest_events().all() 69 | 70 | assert len(events1) == 4 71 | assert len(events2) == 3 72 | 73 | assert events1[0] == last_type1 74 | assert events2[0] == last_type2 75 | 76 | -------------------------------------------------------------------------------- /tests/model_tests/test_fates.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from hermes import exc 5 | from hermes.models import Fate, EventType 6 | 7 | from .fixtures import db_engine, session, sample_data1 8 | 9 | 10 | def test_creation(sample_data1): 11 | event_types = sample_data1.query(EventType).all() 12 | fates = sample_data1.query(Fate).all() 13 | assert len(event_types) == 7 14 | assert len(fates) == 6 15 | 16 | event_type6 = event_types[5] 17 | event_type7 = event_types[6] 18 | 19 | Fate.create( 20 | sample_data1, event_type6, description="New fate" 21 | ) 22 | Fate.create( 23 | sample_data1, event_type7, follows_id=7, description="New fate" 24 | ) 25 | sample_data1.commit() 26 | 27 | fates = sample_data1.query(Fate).all() 28 | 29 | # the total number of fates should be 8 now. We care about the new one 30 | assert len(fates) == 8 31 | fate = fates[6] 32 | assert fate.id == 7 33 | assert fate.creation_event_type == event_type6 34 | assert fate.description == "New fate" 35 | assert fate.for_creator is False 36 | assert fate.for_owner is True 37 | 38 | assert len(event_type6.auto_creates) == 1 39 | assert event_type6.auto_creates[0] == fate 40 | 41 | assert len(event_type7.auto_creates) == 1 42 | assert event_type7.auto_creates[0] == fates[7] 43 | 44 | 45 | def test_creation2(sample_data1): 46 | event_types = sample_data1.query(EventType).all() 47 | fates = sample_data1.query(Fate).all() 48 | assert len(event_types) == 7 49 | assert len(fates) == 6 50 | 51 | event_type6 = event_types[5] 52 | event_type7 = event_types[6] 53 | 54 | Fate.create( 55 | sample_data1, event_type6, for_creator=True, 56 | for_owner=False, description="New fate" 57 | ) 58 | Fate.create( 59 | sample_data1, event_type7, follows_id=7, description="New fate" 60 | ) 61 | sample_data1.commit() 62 | 63 | fates = sample_data1.query(Fate).all() 64 | 65 | # the total number of fates should be 8 now. We care about the new one 66 | assert len(fates) == 8 67 | fate = fates[6] 68 | assert fate.id == 7 69 | assert fate.creation_event_type == event_type6 70 | assert fate.description == "New fate" 71 | assert fate.for_creator is True 72 | assert fate.for_owner is False 73 | 74 | assert len(event_type6.auto_creates) == 1 75 | assert event_type6.auto_creates[0] == fate 76 | 77 | assert len(event_type7.auto_creates) == 1 78 | assert event_type7.auto_creates[0] == fates[7] 79 | 80 | 81 | def test_designation_constraint(sample_data1): 82 | """Fates must be set to for_owner or for_creator or both""" 83 | 84 | event_type1 = sample_data1.query(EventType).get(1) 85 | 86 | with pytest.raises(exc.ValidationError): 87 | Fate.create( 88 | sample_data1, event_type1, description="Wrong fate", 89 | for_creator=False, for_owner=False, follows_id=2 90 | ) 91 | 92 | 93 | def test_follows_id_valid(sample_data1): 94 | event_type1 = sample_data1.query(EventType).get(1) 95 | 96 | # There is no Fate 20 97 | with pytest.raises(exc.ValidationError): 98 | Fate.create( 99 | sample_data1, event_type1, description="Coolio", 100 | follows_id=20 101 | ) -------------------------------------------------------------------------------- /tests/model_tests/test_host.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from hermes import exc 5 | from hermes.models import Host, EventType, Labor, Event 6 | 7 | from .fixtures import db_engine, session, sample_data1 8 | 9 | 10 | def test_creation(session): 11 | Host.create(session, "abc-123") 12 | session.commit() 13 | 14 | hosts = session.query(Host).all() 15 | 16 | assert len(hosts) == 1 17 | assert hosts[0].id == 1 18 | assert hosts[0].hostname == "abc-123" 19 | 20 | host = Host.get_host(session, "abc-123") 21 | assert host.id == 1 22 | assert host.hostname == "abc-123" 23 | 24 | 25 | def test_duplicate(session): 26 | Host.create(session, "abc-123") 27 | 28 | with pytest.raises(IntegrityError): 29 | Host.create(session, "abc-123") 30 | 31 | Host.create(session, "abc-456") 32 | 33 | 34 | def test_required(session): 35 | Host.create(session, "abc-123") 36 | 37 | with pytest.raises(exc.ValidationError): 38 | Host.create(session, None) 39 | 40 | 41 | def test_get_latest_events(sample_data1): 42 | host = Host.get_host(sample_data1, "example.dropbox.com") 43 | assert host.id == 1 44 | assert host.hostname == "example.dropbox.com" 45 | 46 | events = host.get_latest_events().all() 47 | 48 | assert len(events) == 2 49 | assert events[0].note == "example.dropbox.com rebooted." 50 | 51 | 52 | def test_get_labors(sample_data1): 53 | host = Host.get_host(sample_data1, "example.dropbox.com") 54 | assert host.id == 1 55 | assert len(host.labors) == 0 56 | 57 | event_type1 = sample_data1.query(EventType).get(1) 58 | event_type3 = sample_data1.query(EventType).get(3) 59 | event_type4 = sample_data1.query(EventType).get(4) 60 | 61 | print "Creating event1" 62 | Event.create(sample_data1, host, "testman", event_type1) 63 | 64 | print "Creating event2" 65 | Event.create(sample_data1, host, "testman", event_type3) 66 | 67 | print "Creating event3" 68 | closing_event = Event.create(sample_data1, host, "testman", event_type4) 69 | 70 | print "Get labor info" 71 | all_labors = host.get_labors().all() 72 | open_labors = host.get_open_labors().all() 73 | 74 | assert len(all_labors) == 3 75 | assert len(host.labors) == 3 76 | assert len(open_labors) == 1 77 | 78 | assert all_labors[0].completion_time is None 79 | assert all_labors[0].completion_event is None 80 | assert all_labors[1].completion_time is not None 81 | assert all_labors[1].completion_event == closing_event 82 | assert all_labors[0].creation_event == closing_event 83 | 84 | 85 | -------------------------------------------------------------------------------- /tests/model_tests/test_labors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy import desc 4 | from sqlalchemy.exc import IntegrityError 5 | 6 | from hermes import exc 7 | from hermes.models import Event, EventType, Fate, Host, Labor 8 | 9 | from .fixtures import db_engine, session, sample_data1, sample_data2 10 | 11 | 12 | def test_lifecycle1(sample_data1): 13 | """Test the automatic creation and closing of labors based on Events and Fates 14 | 15 | Throw event A, and see that it creates Labor A. 16 | Throw event B, and see that it closes Labor A and creates no new Labors. 17 | """ 18 | labors = sample_data1.query(Labor).all() 19 | assert len(labors) == 0 20 | 21 | event_type_a = sample_data1.query(EventType).get(1) 22 | event_type_b = sample_data1.query(EventType).get(2) 23 | host = sample_data1.query(Host).first() 24 | assert len(host.labors) == 0 25 | 26 | # Throw event A 27 | Event.create(sample_data1, host, "system", event_type_a) 28 | 29 | event = ( 30 | sample_data1.query(Event) 31 | .order_by(desc(Event.id)).first() 32 | ) 33 | 34 | assert event.host == host 35 | assert event.event_type == event_type_a 36 | 37 | labors = sample_data1.query(Labor).all() 38 | assert len(labors) == 1 39 | assert labors[0].completion_time is None 40 | assert labors[0].completion_event is None 41 | assert labors[0].creation_event == event 42 | assert labors[0].for_creator is False 43 | assert len(host.labors) == 1 44 | assert len(event.created_labors) == 1 45 | assert len(event.completed_labors) == 0 46 | 47 | # Throw event B 48 | Event.create( 49 | sample_data1, host, "system", event_type_b 50 | ) 51 | 52 | event = ( 53 | sample_data1.query(Event) 54 | .order_by(desc(Event.id)).first() 55 | ) 56 | 57 | assert event.host == host 58 | assert event.event_type == event_type_b 59 | 60 | labors = sample_data1.query(Labor).all() 61 | assert len(labors) == 1 62 | assert labors[0].completion_time is not None 63 | assert labors[0].completion_event == event 64 | assert labors[0].for_creator is False 65 | assert len(event.created_labors) == 0 66 | assert len(event.completed_labors) == 1 67 | 68 | assert len(host.labors) == 1 69 | 70 | 71 | def test_lifecycle_simple2(sample_data1): 72 | """Test another simple lifecycle 73 | 74 | Throw event A, and see that it creates Labor A. 75 | Throw event D, and see that it closes Labor A and creates no new labors. 76 | """ 77 | labors = sample_data1.query(Labor).all() 78 | assert len(labors) == 0 79 | 80 | host = sample_data1.query(Host).first() 81 | assert len(host.labors) == 0 82 | 83 | event_a = sample_data1.query(EventType).get(1) 84 | event_d = sample_data1.query(EventType).get(4) 85 | 86 | # Throw event A 87 | Event.create(sample_data1, host, "system", event_a) 88 | 89 | event = ( 90 | sample_data1.query(Event) 91 | .order_by(desc(Event.id)).first() 92 | ) 93 | 94 | assert event.host == host 95 | assert event.event_type == event_a 96 | 97 | labors = sample_data1.query(Labor).all() 98 | assert len(labors) == 1 99 | assert labors[0].completion_time is None 100 | assert labors[0].completion_event is None 101 | assert labors[0].creation_event == event 102 | assert labors[0].for_creator is False 103 | assert len(host.labors) == 1 104 | assert len(event.created_labors) == 1 105 | assert len(event.completed_labors) == 0 106 | 107 | 108 | # Throw event D 109 | Event.create( 110 | sample_data1, host, "system", event_d 111 | ) 112 | 113 | event = ( 114 | sample_data1.query(Event) 115 | .order_by(desc(Event.id)).first() 116 | ) 117 | 118 | assert event.host == host 119 | assert event.event_type == event_d 120 | 121 | labors = sample_data1.query(Labor).all() 122 | assert len(labors) == 1 123 | assert labors[0].completion_time is not None 124 | assert labors[0].completion_event == event 125 | assert labors[0].for_creator is False 126 | assert len(event.created_labors) == 0 127 | assert len(event.completed_labors) == 1 128 | 129 | assert len(host.labors) == 1 130 | 131 | 132 | def test_lifecycle_complex2(sample_data1): 133 | """Test the automatic creation and closing of labors based on Events and Fates. 134 | This version is a bit more complex in that we make sure unaffiliated labors 135 | are left untouched. 136 | 137 | Throw event A, creates Labor A. 138 | Throw event C, creates Labor C. 139 | Throw event B, closes Labor A, but does nothing to Labor C. 140 | """ 141 | labors = sample_data1.query(Labor).all() 142 | assert len(labors) == 0 143 | 144 | fates = sample_data1.query(Fate).all() 145 | fate1 = fates[0] 146 | fate2 = fates[1] 147 | fate4 = fates[3] 148 | 149 | hosts = sample_data1.query(Host).all() 150 | host1 = hosts[0] 151 | host2 = hosts[1] 152 | 153 | # Throw event A and C 154 | Event.create(sample_data1, host1, "system", fate1.creation_event_type) 155 | Event.create(sample_data1, host2, "system", fate4.creation_event_type) 156 | 157 | event = ( 158 | sample_data1.query(Event) 159 | .order_by(desc(Event.id)).first() 160 | ) 161 | 162 | assert event.host == host2 163 | assert event.event_type == fate4.creation_event_type 164 | 165 | labors = sample_data1.query(Labor).all() 166 | assert len(labors) == 2 167 | assert labors[0].completion_time is None 168 | assert labors[0].completion_event is None 169 | assert labors[0].for_creator is False 170 | assert labors[1].completion_time is None 171 | assert labors[1].completion_event is None 172 | assert labors[1].for_creator is False 173 | 174 | # Throw event B 175 | Event.create( 176 | sample_data1, host1, "system", fate2.creation_event_type 177 | ) 178 | 179 | event = ( 180 | sample_data1.query(Event) 181 | .order_by(desc(Event.id)).first() 182 | ) 183 | 184 | assert event.host == host1 185 | assert event.event_type == fate2.creation_event_type 186 | 187 | labors = sample_data1.query(Labor).all() 188 | assert len(labors) == 2 189 | assert labors[0].completion_time is not None 190 | assert labors[0].completion_event is not None 191 | assert labors[0].for_creator is False 192 | assert labors[1].completion_time is None 193 | assert labors[1].completion_event is None 194 | assert labors[1].for_creator is False 195 | 196 | labors = Labor.get_open_labors(sample_data1).all() 197 | assert len(labors) == 1 198 | 199 | labors = Labor.get_open_unacknowledged(sample_data1) 200 | assert len(labors) == 1 201 | 202 | 203 | def test_acknowledge(sample_data1): 204 | """Test to ensure that acknowledgement correctly flags Labors as such""" 205 | 206 | labors = sample_data1.query(Labor).all() 207 | assert len(labors) == 0 208 | 209 | fate = sample_data1.query(Fate).get(1) 210 | host = sample_data1.query(Host).get(1) 211 | 212 | Event.create(sample_data1, host, "system", fate.creation_event_type) 213 | 214 | event = ( 215 | sample_data1.query(Event) 216 | .order_by(desc(Event.id)).first() 217 | ) 218 | 219 | assert event.host == host 220 | assert event.event_type == fate.creation_event_type 221 | 222 | labors = Labor.get_open_unacknowledged(sample_data1) 223 | assert len(labors) == 1 224 | assert labors[0].completion_time is None 225 | assert labors[0].completion_event is None 226 | assert labors[0].ack_time is None 227 | assert labors[0].ack_user is None 228 | assert labors[0].creation_event == event 229 | assert labors[0].for_creator is False 230 | 231 | labors[0].acknowledge("testman") 232 | 233 | labors = sample_data1.query(Labor).all() 234 | assert len(labors) == 1 235 | assert labors[0].completion_time is None 236 | assert labors[0].completion_event is None 237 | assert labors[0].ack_time is not None 238 | assert labors[0].ack_user == "testman" 239 | assert labors[0].creation_event == event 240 | assert labors[0].for_creator is False 241 | 242 | labors = Labor.get_open_unacknowledged(sample_data1) 243 | assert len(labors) == 0 244 | 245 | 246 | def test_cannot_start_in_midworkflow(sample_data1): 247 | """Ensures that intermediate fates do not create labors when no labor 248 | exists. 249 | 250 | Given a Fate C -> D, and intermediate Fate D -> E, 251 | Throw event D and ensure Labor D is not created since Labor C does not exist. 252 | 253 | """ 254 | 255 | labors = sample_data1.query(Labor).all() 256 | assert len(labors) == 0 257 | 258 | event_type_d = sample_data1.query(EventType).get(4) 259 | host = sample_data1.query(Host).get(1) 260 | 261 | Event.create(sample_data1, host, "system", event_type_d) 262 | 263 | event = ( 264 | sample_data1.query(Event) 265 | .order_by(desc(Event.id)).first() 266 | ) 267 | 268 | assert event.host == host 269 | assert event.event_type == event_type_d 270 | 271 | labors = Labor.get_open_unacknowledged(sample_data1) 272 | assert len(labors) == 0 273 | 274 | 275 | def test_longer_chain(sample_data2): 276 | """Test chained labors A->B->C->D""" 277 | labors = sample_data2.query(Labor).all() 278 | assert len(labors) == 0 279 | 280 | # system-maintenance audit 281 | event_type_a = sample_data2.query(EventType).get(1) 282 | # system-maintenance needed 283 | event_type_b = sample_data2.query(EventType).get(2) 284 | # system-maintenance ready 285 | event_type_c = sample_data2.query(EventType).get(3) 286 | # system-maintenance completed 287 | event_type_d = sample_data2.query(EventType).get(4) 288 | 289 | host = sample_data2.query(Host).get(1) 290 | 291 | event_a = Event.create(sample_data2, host, "system", event_type_a) 292 | 293 | # We will aggressively validate the events created only for event A 294 | event = ( 295 | sample_data2.query(Event) 296 | .order_by(desc(Event.id)).first() 297 | ) 298 | assert event == event_a 299 | assert event.host == host 300 | assert event.event_type == event_type_a 301 | 302 | labors = Labor.get_open_unacknowledged(sample_data2) 303 | assert len(labors) == 1 304 | assert len(host.labors) == 1 305 | assert labors[0].starting_labor_id is None 306 | assert labors[0].for_creator is False 307 | starting_labor_id = labors[0].id 308 | 309 | event_b = Event.create(sample_data2, host, "system", event_type_b) 310 | labors = Labor.get_open_unacknowledged(sample_data2) 311 | assert len(labors) == 1 312 | assert len(host.labors) == 2 313 | assert labors[0].starting_labor_id == starting_labor_id 314 | assert labors[0].for_creator is False 315 | 316 | event_c = Event.create(sample_data2, host, "system", event_type_c) 317 | labors = Labor.get_open_unacknowledged(sample_data2) 318 | assert len(labors) == 1 319 | assert len(host.labors) == 3 320 | assert labors[0].starting_labor_id == starting_labor_id 321 | assert labors[0].for_creator is True 322 | 323 | # This last event closes the final labor but does not create a new labor 324 | event_d = Event.create(sample_data2, host, "system", event_type_d) 325 | labors = Labor.get_open_unacknowledged(sample_data2) 326 | assert len(labors) == 0 327 | assert len(host.labors) == 3 328 | 329 | 330 | 331 | 332 | 333 | -------------------------------------------------------------------------------- /tests/sample_data/sample_data1.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO event_types 2 | VALUES 3 | (1,'system-reboot','required','This system requires a reboot.', 0); 4 | 5 | INSERT INTO event_types 6 | VALUES 7 | (2,'system-reboot','completed','This system rebooted.', 0); 8 | 9 | INSERT INTO event_types 10 | VALUES 11 | (3,'system-maintenance','required','This system requires maintenance.', 0); 12 | 13 | INSERT INTO event_types 14 | VALUES 15 | (4,'system-maintenance','ready','This system is ready for maintenance.', 0); 16 | 17 | INSERT INTO event_types 18 | VALUES 19 | (5,'system-maintenance','completed','System maintenance completed.', 0); 20 | 21 | INSERT INTO event_types 22 | VALUES 23 | (6,'system-shutdown','required','System shutdown required.', 0); 24 | 25 | INSERT INTO event_types 26 | VALUES 27 | (7,'system-shutdown','completed','System shutdown completed.', 0); 28 | 29 | INSERT INTO fates 30 | VALUES 31 | (1,1,NULL, 0, 1, 'Reboot or release the system'); 32 | 33 | INSERT INTO fates 34 | VALUES 35 | (2,2,1, 0, 1, 'System rebooted'); 36 | 37 | INSERT INTO fates 38 | VALUES 39 | (3,4,1, 0, 1, 'System released'); 40 | 41 | INSERT INTO fates 42 | VALUES 43 | (4,3,NULL, 0, 1, 'Release or acknowledge downtime'); 44 | 45 | INSERT INTO fates 46 | VALUES 47 | (5,4,4, 1, 0, 'Perform maintenance'); 48 | 49 | INSERT INTO fates 50 | VALUES 51 | (6,5,5, 1, 0, 'Maintenance completed'); 52 | 53 | 54 | INSERT INTO hosts 55 | VALUES (1, 'example.dropbox.com'); 56 | 57 | INSERT INTO hosts 58 | VALUES (2, 'sample.dropbox.com'); 59 | 60 | INSERT INTO hosts 61 | VALUES (3, 'test.dropbox.com'); 62 | 63 | INSERT INTO events ('id', 'host_id', 'timestamp', 'user', 'event_type_id', 'note') 64 | VALUES (1, 1, "2015-04-31 10:00:00", "system", 1, "example.dropbox.com needs a reboot"); 65 | 66 | INSERT INTO events ('id', 'host_id', 'timestamp', 'user', 'event_type_id', 'note') 67 | VALUES (2, 1, "2015-05-01 22:34:03", "system", 2, "example.dropbox.com rebooted."); 68 | -------------------------------------------------------------------------------- /tests/sample_data/sample_data2.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO event_types 2 | VALUES 3 | (1,'system-maintenance','audit','Audit required', 0); 4 | 5 | INSERT INTO event_types 6 | VALUES 7 | (2,'system-maintenance','needed','This system needs maintenance.', 0); 8 | 9 | INSERT INTO event_types 10 | VALUES 11 | (3,'system-maintenance','ready','This is ready for maintenance.', 0); 12 | 13 | INSERT INTO event_types 14 | VALUES 15 | (4,'system-maintenance','completed','System maintenance completed.', 0); 16 | 17 | INSERT INTO event_types 18 | VALUES 19 | (5,'system-reboot','needed','System reboot required.', 0); 20 | 21 | INSERT INTO event_types 22 | VALUES 23 | (6,'system-reboot','completed','System has rebooted.', 0); 24 | 25 | INSERT INTO event_types 26 | VALUES 27 | (7,'puppet','restart','Puppet has restarted.', 0); 28 | 29 | INSERT INTO fates 30 | VALUES 31 | (1,1,null, 0, 1, 'Audit the system.'); 32 | 33 | INSERT INTO fates 34 | VALUES 35 | (2,2,1, 0, 1, 'Reboot, release the system, or authorize downtime.'); 36 | 37 | INSERT INTO fates 38 | VALUES 39 | (3,3,2, 1, 0, 'Perform maintenance'); 40 | 41 | INSERT INTO fates 42 | VALUES 43 | (4,4,3, 1, 0, 'Maintenance completed'); 44 | 45 | INSERT INTO fates 46 | VALUES 47 | (5,6,2, 0, 1, 'System rebooted to finish maintenance'); 48 | 49 | INSERT INTO fates 50 | VALUES 51 | (6,5, NULL, 0, 1, 'Reboot the system.'); 52 | 53 | INSERT INTO fates 54 | VALUES 55 | (7,6, 6, 0, 1, 'Restart puppet'); 56 | 57 | INSERT INTO fates 58 | VALUES 59 | (8,7,7, 1, 0, 'Puppet restarted'); 60 | 61 | INSERT INTO fates 62 | VALUES 63 | (9,1,null, 0, 1, 'Release or restart puppet'); 64 | 65 | INSERT INTO fates 66 | VALUES 67 | (10,7,9, 0, 1, 'System is fine. Puppet restarted.'); 68 | 69 | INSERT INTO hosts 70 | VALUES (1, 'example.dropbox.com'); 71 | 72 | INSERT INTO hosts 73 | VALUES (2, 'sample.dropbox.com'); 74 | 75 | INSERT INTO hosts 76 | VALUES (3, 'test.dropbox.com'); --------------------------------------------------------------------------------