From 6c39b5751e96d0fed8dc763a4486f62ae87c5c32 Mon Sep 17 00:00:00 2001 From: Anton Tolchanov Date: Sun, 17 Jan 2016 14:17:20 +0000 Subject: [PATCH] Initial commit --- .gitignore | 61 +++++ README.rst | 79 ++++++ docs/Makefile | 192 ++++++++++++++ docs/conf.py | 10 + docs/index.rst | 18 ++ docs/urconf.rst | 30 +++ requirements.txt | 6 + setup.py | 17 ++ tests/__init__.py | 0 tests/test_uptimerobot.py | 357 ++++++++++++++++++++++++++ tests/test_uptimerobot/contacts_one | 5 + tests/test_uptimerobot/contacts_two | 6 + tests/test_uptimerobot/monitors_none | 1 + tests/test_uptimerobot/monitors_three | 5 + tests/test_uptimerobot_syncable.py | 29 +++ tox.ini | 16 ++ urconf/__init__.py | 5 + urconf/uptimerobot.py | 351 +++++++++++++++++++++++++ urconf/uptimerobot_syncable.py | 201 +++++++++++++++ 19 files changed, 1389 insertions(+) create mode 100644 .gitignore create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/urconf.rst create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_uptimerobot.py create mode 100644 tests/test_uptimerobot/contacts_one create mode 100644 tests/test_uptimerobot/contacts_two create mode 100644 tests/test_uptimerobot/monitors_none create mode 100644 tests/test_uptimerobot/monitors_three create mode 100644 tests/test_uptimerobot_syncable.py create mode 100644 tox.ini create mode 100644 urconf/__init__.py create mode 100644 urconf/uptimerobot.py create mode 100644 urconf/uptimerobot_syncable.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aac90cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..178d626 --- /dev/null +++ b/README.rst @@ -0,0 +1,79 @@ +Declarative configuration library for Uptime Robot +-------------------------------------------------- + +``urconf`` is a Python library for Uptime Robot () +API. It expects definition of all your contacts and monitors, and then issues +API calls required to configure your Uptime Robot accordingly. + +Usage +----- + +Install urconf using pip: ``pip install urconf`` + +Write your monitoring configuration as a Python script: + +.. code:: python + + import logging + import urconf + + # urconf logs all operations that change configuration at the INFO level. + # Use DEBUG to see API call contents. + logging.basicConfig(level=logging.INFO) + + config = urconf.UptimeRobot("api-key") # dry_run=True enables dry mode + + # Define contacts + email = config.email_contact("me@example.com") + boxcar = config.boxcar_contact("my boxcar", "boxcar-api-key") + + # Define monitors + ssh = config.port_monitor("ssh on server1", "server1.example.com", 22) + web = config.keyword_monitor( + "my site", "https://example.com/", "welcome to example.com!") + # More complex example with HTTP auth and non-standard monitoring interval + backend = config.keyword_monitor( + "my backend", "https://admin.example.com", "Cannot connect to database", + should_exist=False, http_username="admin", http_password="password", + interval=20) + + # Associate contacts with monitors + for monitor in (ssh, web, backend): + monitor.add_contacts(email, boxcar) + + # Sync configuration to Uptime Robot + config.sync() + +Run the script to sync configuration. + +Functionality +------------- + +Currently implemented: + +- email and boxcar contacts; +- keyword and port monitors. + +Pull requests extending supported types of contacts or monitors are very +welcome. + +Caveats +------- + +Since uptimerobot API does not support modifying contacts, when contact +modification is detected, ``urconf`` has to remove the old contact and re-add +it. This means that e-mail contacts have to be re-verified manually again. + +Development notes +----------------- + +- refer to API documentation while implementing + additional functionality; +- run ``tox`` to run the tests in Python 2.7 and 3.4 environments; +- run ``make html`` in ``docs/`` to build documentation in HTML. It can be + viewed in ``docs/_build/html/`` afterwards. + +License +------- + +``urconf`` is licensed under the MIT license. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5790f27 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# @generated + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(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/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/urconf.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/urconf.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/urconf" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/urconf" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..985d1d8 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,10 @@ +import os +import sys +import pkg_resources + +sys.path.insert(0, os.path.abspath('..')) + +release = pkg_resources.get_distribution('urconf').version + +master_doc = 'index' +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..4c49657 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +urconf +====== + +Contents: + +.. toctree:: + :maxdepth: 2 + + urconf + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/urconf.rst b/docs/urconf.rst new file mode 100644 index 0000000..b5597b9 --- /dev/null +++ b/docs/urconf.rst @@ -0,0 +1,30 @@ +urconf package +============== + +Submodules +---------- + +urconf.uptimerobot module +------------------------- + +.. automodule:: urconf.uptimerobot + :members: + :undoc-members: + :show-inheritance: + +urconf.uptimerobot_syncable module +---------------------------------- + +.. automodule:: urconf.uptimerobot_syncable + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: urconf + :members: + :undoc-members: + :show-inheritance: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b56ef10 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flake8 +pytest +requests +responses +sphinx +typedecorator diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a60f24c --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + +import urconf + +setup( + name="urconf", + version=urconf.__version__, + url="http://github.com/knyar/urconf", + license="MIT", + author="Anton Tolchanov", + description="Declarative configuration library for Uptime Robot", + long_description=open("README.rst", "r").read(), + packages=find_packages(), + platforms="any", + install_requires=["requests", "typedecorator"], + keywords=["monitoring", "api", "uptime robot"], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_uptimerobot.py b/tests/test_uptimerobot.py new file mode 100644 index 0000000..4ec2df5 --- /dev/null +++ b/tests/test_uptimerobot.py @@ -0,0 +1,357 @@ +import os +try: + from urllib.parse import urlparse, parse_qs +except ImportError: + from urlparse import urlparse, parse_qs + +import pytest +import requests +import responses +import urconf + + +def read_data(filename): + """Reads data from file and returns it as a string. + + File will be read from the directory named after this file (test_foo/ if + the file is test_foo.py). + """ + basename, _ = os.path.splitext(__file__) + with open(os.path.join(basename, filename)) as f: + return f.read() + + +def assert_query_params(request, **kwargs): + """Asserts that given query parameters have expected values. + + Args: + request: a PreparedRequest object. + kwargs: key/value pairs that should be present in the query of + request URL. + + Raises: + AssertionError if a parameter has unexpected value. + KeyError if a parameter does not exist. + """ + params = parse_qs(urlparse(request.url).query, keep_blank_values=True) + for key in kwargs: + assert params[key][0] == str(kwargs[key]), "Invalid {}".format(key) + + +class TestUptimeRobot(object): + @responses.activate + def test_get_raises_on_invalid_json(self): + responses.add( + responses.GET, "https://fake/none", body="omg this is not json") + + config = urconf.UptimeRobot("", url="https://fake") + with pytest.raises(urconf.uptimerobot.UptimeRobotAPIError): + config._api_get("none", {}) + + @responses.activate + def test_get_raises_on_404(self): + responses.add( + responses.GET, "https://fake/none", body="404", status=404) + + config = urconf.UptimeRobot("", url="https://fake") + with pytest.raises(urconf.uptimerobot.UptimeRobotAPIError): + config._api_get("none", {}) + + @responses.activate + def test_get_raises_on_api_errors(self): + responses.add(responses.GET, "https://fake/none", + body='{"stat": "error", "message": "error", "id": 99}') + + config = urconf.UptimeRobot("", url="https://fake/") + with pytest.raises(urconf.uptimerobot.UptimeRobotAPIError): + config._api_get("none", {}) + + @responses.activate + def test_api_get_paginated(self): + def callback(request): + params = parse_qs(urlparse(request.url).query) + limit = params["limit"][0] if "limit" in params else 1 + offset = params["offset"][0] if "offset" in params else 0 + resp = """{{"stat": "ok", "offset": "{offset}", "limit": "{limit}", + "total": "10","fake":["fakedata{offset}"]}}""".format( + offset=offset, limit=limit) + return (200, {}, resp) + responses.add_callback(responses.GET, "https://fake/getFake", + callback=callback) + + config = urconf.UptimeRobot("", url="https://fake/") + result = config._api_get_paginated("getFake", {}, lambda x: x["fake"]) + + assert len(responses.calls) == 10 + for i in (range(10)): + assert "fakedata{}".format(i) in result + + @responses.activate + def test_add_email_contact(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add( + responses.GET, "https://fake/newAlertContact", + body='{"stat": "ok","alertcontact":{"id":"0725","status":"0"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + config.email_contact("email1", "e@mail") + config.email_contact("XYZ") + config._sync_contacts() + + assert config._contacts["XYZ"]["id"] == "0725" + assert len(responses.calls) == 2 + assert_query_params( + responses.calls[1].request, alertContactType=2, + alertContactFriendlyName="XYZ", alertContactValue="XYZ") + + @responses.activate + def test_add_boxcar_contact(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add( + responses.GET, "https://fake/newAlertContact", + body='{"stat": "ok","alertcontact":{"id":"12344","status":"0"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + config.email_contact("email1", "e@mail") + config.boxcar_contact("boxcar1", "XYZ") + config._sync_contacts() + + assert config._contacts["boxcar1"]["id"] == "12344" + assert len(responses.calls) == 2 + assert_query_params( + responses.calls[1].request, alertContactType=4, + alertContactFriendlyName="boxcar1", alertContactValue="XYZ") + + @responses.activate + def test_delete_email_contact(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_two")) + responses.add( + responses.GET, "https://fake/deleteAlertContact", + body='{"stat": "ok","alertcontact":{"id":"9876352"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + config.email_contact("email1", "e@mail") + config._sync_contacts() + + assert len(responses.calls) == 2 + assert_query_params( + responses.calls[1].request, alertContactID="9876352") + + @responses.activate + def test_add_port_monitor(self): + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_none")) + responses.add( + responses.GET, "https://fake/newMonitor", + body='{"stat": "ok","monitor":{"id":"515","status":"1"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + config.port_monitor("my mail", "servername", 25), + config._sync_monitors() + + assert len(responses.calls) == 2 + assert_query_params( + responses.calls[1].request, monitorFriendlyName="my mail", + monitorURL="servername", monitorType=4, monitorSubType=4, + monitorPort=25, monitorAlertContacts="", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + + @responses.activate + def test_add_keyword_monitor_and_change_contact_threshold(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + responses.add( + responses.GET, "https://fake/editMonitor", + body='{"stat": "ok","monitor":{"id":"123403"}}') + responses.add( + responses.GET, "https://fake/newMonitor", + body='{"stat": "ok","monitor":{"id":"6969","status":"1"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + email = config.email_contact("email1", "e@mail") + config.keyword_monitor( + "kw1", "http://fake", "test1", http_username="user1", + http_password="pass1").add_contacts(email) + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor( + "smtp2", "host2", 25).add_contacts(email, threshold=5) + config.keyword_monitor( + "kw2", "http://fake2", "test2").add_contacts(email) + config.sync() + + assert len(responses.calls) == 4 + assert_query_params( + responses.calls[2].request, monitorFriendlyName="smtp2", + monitorURL="host2", monitorType=4, monitorSubType=4, + monitorKeywordType=0, monitorKeywordValue="", + monitorAlertContacts="012345_5_0", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + assert_query_params( + responses.calls[3].request, monitorFriendlyName="kw2", + monitorURL="http://fake2", monitorType=2, monitorSubType=0, + monitorKeywordType=2, monitorKeywordValue="test2", + monitorHTTPUsername="", monitorHTTPPassword="", + monitorAlertContacts="012345_0_0", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + + @responses.activate + def test_remove_monitor(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + responses.add( + responses.GET, "https://fake/deleteMonitor", + body='{"stat": "ok","monitor":{"id":"123403"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + email = config.email_contact("email1", "e@mail") + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor("smtp2", "host2", 25).add_contacts(email) + config.sync() + + assert len(responses.calls) == 3 + assert_query_params(responses.calls[2].request, monitorID=123401) + + @responses.activate + def test_edit_monitor_type(self): + """API does not allow editing monitor type, so urconf should + remove the old monitor and create the new one. + """ + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + responses.add( + responses.GET, "https://fake/deleteMonitor", + body='{"stat": "ok","monitor":{"id":"123403"}}') + responses.add( + responses.GET, "https://fake/newMonitor", + body='{"stat": "ok","monitor":{"id":"120011","status":"1"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + email = config.email_contact("email1", "e@mail") + # change keyword monitor to a port monitor + config.port_monitor("kw1", "fake", 80).add_contacts(email) + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor("smtp2", "host2", 25).add_contacts(email) + config.sync() + + assert len(responses.calls) == 4 + assert_query_params(responses.calls[2].request, monitorID=123401) + assert_query_params( + responses.calls[3].request, monitorFriendlyName="kw1", + monitorURL="fake", monitorType=4, monitorSubType=1, + monitorAlertContacts="012345_0_0", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + + @responses.activate + def test_remove_http_auth(self): + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + responses.add( + responses.GET, "https://fake/editMonitor", + body='{"stat": "ok","monitor":{"id":"123401"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + email = config.email_contact("email1", "e@mail") + config.keyword_monitor( + "kw1", "http://fake", "test1").add_contacts(email) + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor("smtp2", "host2", 25).add_contacts(email) + config.sync() + + assert len(responses.calls) == 3 + assert_query_params( + responses.calls[2].request, monitorFriendlyName="kw1", + monitorURL="http://fake", monitorType=2, monitorSubType=0, + monitorKeywordType=2, monitorKeywordValue="test1", + monitorHTTPUsername="", monitorHTTPPassword="", + monitorAlertContacts="012345_0_0", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + + @responses.activate + def test_change_email_address(self): + """Tests contact updates. + + Since API does not allow editing a contact, this verifies that the + contact gets removed and then re-added. New contact ID will be + allocated, so all monitors using the old contact will need to be + updated as well. + """ + responses.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_one")) + responses.add( + responses.GET, "https://fake/deleteAlertContact", + body='{"stat": "ok","alertcontact":{"id":"012345"}}') + responses.add( + responses.GET, "https://fake/newAlertContact", + body='{"stat": "ok","alertcontact":{"id":"144444","status":"0"}}') + responses.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + responses.add( + responses.GET, "https://fake/editMonitor", + body='{"stat": "ok","monitor":{"id":"123401"}}') + responses.add( + responses.GET, "https://fake/editMonitor", + body='{"stat": "ok","monitor":{"id":"123402"}}') + responses.add( + responses.GET, "https://fake/editMonitor", + body='{"stat": "ok","monitor":{"id":"123403"}}') + + config = urconf.UptimeRobot("", url="https://fake/") + email = config.email_contact("email1", "new@mail") + config.keyword_monitor( + "kw1", "http://fake", "test1", http_username="user1", + http_password="pass1").add_contacts(email) + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor("smtp2", "host2", 25).add_contacts(email) + config.sync() + + assert len(responses.calls) == 7 + assert_query_params( + responses.calls[1].request, alertContactID="012345") + assert_query_params( + responses.calls[2].request, alertContactFriendlyName="email1", + alertContactType=2, alertContactValue="new@mail") + assert_query_params( + responses.calls[4].request, monitorFriendlyName="kw1", + monitorURL="http://fake", monitorType=2, monitorSubType=0, + monitorKeywordType=2, monitorKeywordValue="test1", + monitorHTTPUsername="user1", monitorHTTPPassword="pass1", + monitorAlertContacts="144444_0_0", + monitorInterval=urconf.uptimerobot.DEFAULT_INTERVAL) + + @responses.activate + def test_change_email_address_dry_run(self): + """Tests dry run mode, confirming that no objects get changed.""" + with responses.RequestsMock(assert_all_requests_are_fired=False) \ + as resp: + resp.add(responses.GET, "https://fake/getAlertContacts", + body=read_data("contacts_two")) + resp.add(responses.GET, "https://fake/getMonitors", + body=read_data("monitors_three")) + exception = requests.exceptions.HTTPError( + "dry_run should not mutate state") + for method in ("deleteAlertContact", "newAlertContact", + "editMonitor", "deleteMonitor", "newMonitor"): + resp.add(responses.GET, "https://fake/{}".format(method), + body=exception) + + config = urconf.UptimeRobot("", url="https://fake/", dry_run=True) + email = config.email_contact("email1", "e@mail") + email = config.email_contact("email2", "new@mail") + config.keyword_monitor( + "kw1", "http://fake", "test1").add_contacts(email) + config.port_monitor("ssh1", "host1", 22).add_contacts(email) + config.port_monitor("smtp3", "host3", 25).add_contacts(email) + config.sync() + + assert len(resp.calls) == 2 diff --git a/tests/test_uptimerobot/contacts_one b/tests/test_uptimerobot/contacts_one new file mode 100644 index 0000000..623198c --- /dev/null +++ b/tests/test_uptimerobot/contacts_one @@ -0,0 +1,5 @@ +{"stat": "ok", "offset": "0", "limit": "50", "total": "1","alertcontacts": { + "alertcontact":[ + {"id":"012345","value":"e@mail","friendlyname":"email1", "type":"2","status":"2"} + ] +}} diff --git a/tests/test_uptimerobot/contacts_two b/tests/test_uptimerobot/contacts_two new file mode 100644 index 0000000..42d7c21 --- /dev/null +++ b/tests/test_uptimerobot/contacts_two @@ -0,0 +1,6 @@ +{"stat": "ok", "offset": "0", "limit": "50", "total": "1","alertcontacts": { + "alertcontact":[ + {"id":"012345","value":"e@mail","friendlyname":"email1","type":"2","status":"2"}, + {"id":"9876352","value":"v2","friendlyname":"v2","type":"4","status":"2"} + ] +}} diff --git a/tests/test_uptimerobot/monitors_none b/tests/test_uptimerobot/monitors_none new file mode 100644 index 0000000..a6ded83 --- /dev/null +++ b/tests/test_uptimerobot/monitors_none @@ -0,0 +1 @@ +{"stat": "fail","id":"212","message":"The account has no monitors"} diff --git a/tests/test_uptimerobot/monitors_three b/tests/test_uptimerobot/monitors_three new file mode 100644 index 0000000..1de549e --- /dev/null +++ b/tests/test_uptimerobot/monitors_three @@ -0,0 +1,5 @@ +{"stat": "ok", "offset": "0", "limit": "50", "total": "3","monitors":{"monitor":[ + {"id":"123401","friendlyname":"kw1","url":"http://fake","type":"2","subtype":"","keywordtype":"2","keywordvalue":"test1","httpusername":"user1","httppassword":"pass1","port":"","interval":"300","status":"2","alltimeuptimeratio":"100","alertcontact":[{"id":"012345","type":"2","value":"e@mail","threshold":"0","recurrence":"0"}]}, + {"id":"123402","friendlyname":"ssh1","url":"host1","type":"4","subtype":"99","keywordtype":"0","keywordvalue":"","httpusername":"","httppassword":"","port":"22","interval":"300","status":"2","alltimeuptimeratio":"99.97","alertcontact":[{"id":"012345","type":"2","value":"e@mail","threshold":"0","recurrence":"0"}]}, + {"id":"123403","friendlyname":"smtp2","url":"host2","type":"4","subtype":"4","keywordtype":"0","keywordvalue":"","httpusername":"","httppassword":"","port":"25","interval":"300","status":"2","alltimeuptimeratio":"100","alertcontact":[{"id":"012345","type":"2","value":"e@mail","threshold":"0","recurrence":"0"}]} +]}} diff --git a/tests/test_uptimerobot_syncable.py b/tests/test_uptimerobot_syncable.py new file mode 100644 index 0000000..42cfc8e --- /dev/null +++ b/tests/test_uptimerobot_syncable.py @@ -0,0 +1,29 @@ +import pytest + +from urconf.uptimerobot_syncable import Contact, Monitor + + +class TestUptimeRobotSyncable(object): + def test_required_fields(self): + with pytest.raises(RuntimeError): + Contact(friendlyname="name") + + def test_id_does_not_affect_equality(self): + contact1 = Contact(friendlyname="c1", type=2, value="v1", id="0213") + contact2 = Contact(friendlyname="c1", type=2, value="v1", id="1444") + assert contact1 == contact2 + # __repr__ includes `id`, so string representations are not equal. + assert str(contact1) != str(contact2) + + def test_monitor_contacts_affect_equality(self): + contact = Contact(friendlyname="c1", type=2, value="v1", id="0213") + mon1 = Monitor(friendlyname="m1", url="u1", type="1") + mon1.add_contacts(contact) + mon2 = Monitor(friendlyname="m1", url="u1", type="1", + alertcontact=[{"id": "0213", "type": "2", "value": "v1", + "threshold": "0", "recurrence": "0"}]) + mon3 = Monitor(friendlyname="m1", url="u1", type="1") # no contacts + assert mon1 != mon3 + assert mon2 != mon3 + assert mon1 == mon2 + assert str(mon1) == str(mon1) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ed961a4 --- /dev/null +++ b/tox.ini @@ -0,0 +1,16 @@ +; vim: ts=2:sw=2:sts=2:expandtab +[tox] +envlist = flake8,docs,py27,py34 + +[testenv] +deps = -rrequirements.txt +commands = py.test --basetemp={envtmpdir} {posargs} + +[testenv:flake8] +commands = flake8 + +[testenv:docs] +basepython = python +commands = + sphinx-apidoc -T -f -o docs/ urconf/ + sphinx-build -W -b html -d {envtmpdir}/doctrees docs/ {envtmpdir}/html diff --git a/urconf/__init__.py b/urconf/__init__.py new file mode 100644 index 0000000..30c3288 --- /dev/null +++ b/urconf/__init__.py @@ -0,0 +1,5 @@ +import urconf.uptimerobot + +__version__ = "2016.1" + +UptimeRobot = urconf.uptimerobot.UptimeRobot diff --git a/urconf/uptimerobot.py b/urconf/uptimerobot.py new file mode 100644 index 0000000..e564dee --- /dev/null +++ b/urconf/uptimerobot.py @@ -0,0 +1,351 @@ +import json +import logging +import types + +import requests +import typedecorator + +from urconf.uptimerobot_syncable import Contact, Monitor + +logger = logging.getLogger("urconf") +typedecorator.setup_typecheck() + +DEFAULT_INTERVAL = 5 # minutes + +# There are some error codes that mean that mean there is no objects of a given +# type (alert contacts or monitors) defined in the account yet. They are: +# 212: The account has no monitors +# 221: The account has no alert contacts +NO_OBJECTS_ERROR_CODES = ["212", "221"] + + +class UptimeRobotAPIError(Exception): + """An exception which is raised when Uptime Robot API returns an error.""" + pass + + +class UptimeRobot(object): + """UptimeRobot is the main object used to sync configuration. + + It keeps alert contacts and monitors defined by the user in + `self._contacts` and `self_monitors` lists. + """ + @typedecorator.params(self=object, api_key=str, url=str, dry_run=bool) + def __init__(self, api_key, url="https://api.uptimerobot.com/", + dry_run=False): + """Initializes the configuration. + + Args: + api_key: (string) Uptime Robot API key. This should be the + "Main API Key", not one of the monitor-specific API keys. + url: (string) Base URL for Uptime Robot API. + dry_run: (bool) Flag that can be set to True to prevent urconf + from changing Uptime Robot configuration. + """ + self._url = url.rstrip("/") + "/" + self._dry_run = dry_run + # These are HTTP query parameters that will be passed to the API with + # all requests. + self.params = { + "apiKey": api_key, + "format": "json", + "noJsonCallback": 1, + } + self._contacts = {} + self._monitors = {} + # `requests` logs at INFO by default, which is annoying. + logging.getLogger("requests").setLevel(logging.WARNING) + + @typedecorator.params(self=object, method=str, + params={str: typedecorator.Union(str, int)}) + def _api_get(self, method, params): + """Issues a GET request to the API and returns the result. + + Args: + method: (string) API method to call. + params: ({string: string}) A dictionary containing key/value + pairs that will be used in the URL query string. + + Returns: + Unmarshalled API response as a Python object. + + Raises: + UptimeRobotAPIError: when API returns an unexpected error. + """ + url = self._url + method + resp = requests.get(url, params=params) + if resp.status_code != 200: + raise UptimeRobotAPIError("Got HTTP error {} fetching {}".format( + resp.status_code, url)) + logger.debug("GET {} {}: {}".format(url, params, resp.text)) + try: + data = json.loads(resp.text) + except ValueError as e: + raise UptimeRobotAPIError( + "Error decoding JSON of {}: {}. Got: {}".format( + method, e, resp.text)) + if data["stat"] != "ok" and data["id"] not in NO_OBJECTS_ERROR_CODES: + raise UptimeRobotAPIError("{} returned error: {} (code {})".format( + method, data["message"], data["id"])) + return data + + @typedecorator.params( + self=object, method=str, params={str: typedecorator.Union(str, int)}, + element_func=types.FunctionType) + def _api_get_paginated(self, method, params, element_func): + """Fetches all elements from a given API method. + + This function gets all elements that a given API method returns, + issuing multiple GET calls if results do not fit in a single page. + + Args: + method: (string) API method to call. + params: ({string: string}) A dictionary containing key/value + pairs that will be used in the URL query string. + element_func: function that extracts a list of results from the + object returned by the API call in question. For example, if + returned JSON is `{"result": [...]}`, the function can be + `lambda x: x["result"]`. + + Returns: + A list of Python objects corresponding to API response. + """ + params = params.copy() + result = [] + while True: + response = self._api_get(method, params) + if "id" in response and response["id"] in NO_OBJECTS_ERROR_CODES: + # No objects of given type exist yet, return empty list. + return [] + result.extend(element_func(response)) + for field in ("total", "offset", "limit"): + response[field] = int(response[field]) + if response["total"] > response["offset"] + response["limit"]: + params["offset"] = response["offset"] + response["limit"] + else: + break + return result + + def _sync_monitors(self): + """Synchronizes locally defined list of monitors with the server. + + This method compares locally defined monitors with the result of the + `getMonitors` API method and synchronizes them by creating missing + monitors, deleting obsolete ones, and updating the ones that changed. + + Note: creating and updating monitors requires server-side contact IDs, + so `_sync_monitors` should only be executed after `_sync_contacts`. + """ + existing = {} + params = self.params.copy() + params.update({"showMonitorAlertContacts": 1}) + fetched = self._api_get_paginated( + "getMonitors", params, lambda x: x["monitors"]["monitor"]) + for monitor_dict in fetched: + # getMonitors returns interval in seconds, while editMonitor + # expects minutes. Exciting, I know. + monitor_dict["interval"] = int(monitor_dict["interval"]) / 60 + m = Monitor(**monitor_dict) + if m.name in self._monitors: + existing[m.name] = True + if not m == self._monitors[m.name]: + self._api_update_monitor(m, self._monitors[m.name]) + else: + self._api_delete_monitor(m) + for name in self._monitors: + if name not in existing: + self._api_create_monitor(self._monitors[name]) + + @typedecorator.params(self=object, old="Monitor", new="Monitor") + def _api_update_monitor(self, old, new): + logger.info("Updating monitor {}".format(new.name)) + if old["type"] != new["type"]: + logger.info("Monitor type updates are not possible, " + "will remove and re-add {}".format(new.name)) + self._api_delete_monitor(old) + self._api_create_monitor(new) + return + if self._dry_run: + return + params = self.params.copy() + params.update(new._params_update) + params["monitorID"] = old["id"] + self._api_get("editMonitor", params) + + @typedecorator.params(self=object, monitor="Monitor") + def _api_delete_monitor(self, monitor): + logger.info("Deleting monitor {}".format(monitor.name)) + if self._dry_run: + return + params = self.params.copy() + params.update(monitor._params_delete) + self._api_get("deleteMonitor", params) + + @typedecorator.params(self=object, monitor="Monitor") + def _api_create_monitor(self, monitor): + logger.info("Creating monitor {}".format(monitor.name)) + if self._dry_run: + return + params = self.params.copy() + params.update(monitor._params_create) + self._api_get("newMonitor", params) + + def _sync_contacts(self): + """Synchronizes locally defined list of contacts with the server. + + This method compares locally defined contacts with the result of the + `getAlertContacts` API method and synchronizes them by creating missing + contacts and deleting obsolete ones. + + This also populates server-side contact IDs that are required to create + and update monitors, so `_sync_contacts` should be executed before + `_sync_monitors`. + """ + existing = {} + fetched = self._api_get_paginated( + "getAlertContacts", self.params, + lambda x: x["alertcontacts"]["alertcontact"]) + for contact_dict in fetched: + c = Contact(**contact_dict) + if c.name in self._contacts: + if c != self._contacts[c.name]: + # There is no editContact call, we have to delete the old + # contact (and let it be added again by the code below). + self._api_delete_contact(c) + else: + existing[c.name] = True + # Populate the `id` field based on the contact information + # we got from the server. This id will be required for the + # newMonitor / editMonitor calls we make later. + self._contacts[c.name]["id"] = c["id"] + else: + self._api_delete_contact(c) + for name in self._contacts: + if name not in existing: + contact_id = self._api_create_contact(self._contacts[name]) + self._contacts[name]["id"] = contact_id + + @typedecorator.params(self=object, contact="Contact") + def _api_delete_contact(self, contact): + logger.info("Deleting contact {}".format(contact.name)) + if self._dry_run: + return + params = self.params.copy() + params.update(contact._params_delete) + self._api_get("deleteAlertContact", params) + + @typedecorator.params(self=object, contact="Contact") + def _api_create_contact(self, contact): + logger.info("Creating contact {}".format(contact.name)) + if self._dry_run: + return + params = self.params.copy() + params.update(contact._params_create) + result = self._api_get("newAlertContact", params) + return result["alertcontact"]["id"] + + @typedecorator.returns("Contact") + @typedecorator.params(self=object, name=str, email=str) + def email_contact(self, name, email=None): + """Defines an email contact. + + Args: + name: (string) name used for this contact in the Uptime Robot web + interface. + email: (string) e-mail address (defaults to `name` if not + specified) + + Returns: + Contact object which can later be used in add_contacts method + of a monitor. + """ + assert name not in self._contacts, "Duplicate name: {}".format(name) + c = Contact( + friendlyname=name, type=Contact.TYPE_EMAIL, value=email or name) + self._contacts[c.name] = c + return c + + @typedecorator.returns("Contact") + @typedecorator.params(self=object, name=str, key=str) + def boxcar_contact(self, name, key=None): + """Defines a Boxcar contact. + + Args: + name: (string) name used for this contact in the Uptime Robot web + interface. + key: (string) boxcar API key (defaults to `name` if not specified). + + Returns: + Contact object which can later be used in add_contacts method + of a monitor. + """ + assert name not in self._contacts, "Duplicate name: {}".format(name) + c = Contact( + friendlyname=name, type=Contact.TYPE_BOXCAR, value=key or name) + self._contacts[c.name] = c + return c + + @typedecorator.returns("Monitor") + @typedecorator.params( + self=object, name=str, url=str, keyword=str, should_exist=bool, + http_username=str, http_password=str, interval=int) + def keyword_monitor(self, name, url, keyword, should_exist=True, + http_username="", http_password="", + interval=DEFAULT_INTERVAL): + """Defines a keyword monitor. + + Args: + name: (string) name used for this monitor in the Uptime Robot web + interface. + url: (string) URL to check. + keyword: (string) Keyword to check. + should_exist: (string) Whether the keyword should exist or not + (defaults to True). + http_username: (string) Username to use for HTTP authentification. + http_password: (string) Password to use for HTTP authentification. + interval: (int) Monitoring interval in minutes. + + Returns: + Monitor object. + """ + assert name not in self._monitors, "Duplicate name: {}".format(name) + keywordtype = 2 if should_exist else 1 + m = Monitor(friendlyname=name, type=Monitor.TYPE_KEYWORD, url=url, + keywordvalue=keyword, keywordtype=keywordtype, + httpusername=http_username, httppassword=http_password, + interval=interval) + self._monitors[m.name] = m + return m + + @typedecorator.returns("Monitor") + @typedecorator.params(self=object, name=str, hostname=str, port=int, + interval=int) + def port_monitor(self, name, hostname, port, interval=DEFAULT_INTERVAL): + """Defines a port monitor. + + Args: + name: (string) name used for this monitor in the Uptime Robot web + interface. + hostname: (string) Host name to check. + port: (int) TCP port. + interval: (int) Monitoring interval in minutes. + + Returns: + Monitor object. + """ + assert name not in self._monitors, "Duplicate name: {}".format(name) + # Port to subtype map from https://uptimerobot.com/api + port_to_subtype = {80: 1, 443: 2, 21: 3, 25: 4, 110: 5, 143: 6} + subtype = port_to_subtype.setdefault(port, 99) + m = Monitor(friendlyname=name, type=Monitor.TYPE_PORT, url=hostname, + subtype=subtype, port=port, interval=interval) + self._monitors[m.name] = m + return m + + def sync(self): + """Synchronizes configuration with the Uptime Robot API. + + This method should be called after all contacts and monitors have been + defined and will sync defined configuration to the Uptime Robot.""" + self._sync_contacts() + self._sync_monitors() diff --git a/urconf/uptimerobot_syncable.py b/urconf/uptimerobot_syncable.py new file mode 100644 index 0000000..0f58ba3 --- /dev/null +++ b/urconf/uptimerobot_syncable.py @@ -0,0 +1,201 @@ +class Syncable(object): + """Syncable is the parent class for objects that urconf keeps in sync. + + All values are stored in the `self._values` dictionary. + """ + def __init__(self, **kwargs): + """Construct the object. + + This is designed to be called either with kwargs specified manually or + with a dict of parameters as returned by getAlertContacts or + getMonitors. + + Parameter names (self._values keys) correspond to the "Parameters" + list at https://uptimerobot.com/api + """ + self._values = {} + for f in self._REQUIRED_FIELDS: + if f not in kwargs: + raise RuntimeError("Contact requires {}; got {}".format( + f, kwargs)) + + # `id` is not part of FIELDS, because it"s auto-generated on the server + # rather than passed by the user. However, it's useful to have it, + # so it's added into the self._values if it exists in kwargs. + for f in self._FIELDS + ["id"]: + if f in kwargs and kwargs[f]: + self[f] = kwargs[f] + + def __eq__(self, other): + for f in self._FIELDS: + if self[f] != other[f]: + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) + + def __setitem__(self, key, value): + self._values[key] = self._TYPES[key](value) + + def __getitem__(self, key): + if key in self._values: + return self._values[key] + if self._TYPES[key] == str: + return "" + if self._TYPES[key] == int: + return 0 + + def __repr__(self): + return self._values.__repr__() + + @property + def name(self): + """Defines primary identificator for this object used by urconf.""" + return self[self._FIELDS[0]] # friendlyname + + +class Monitor(Syncable): + _FIELDS = [ + "friendlyname", "url", "type", "subtype", "keywordtype", + "keywordvalue", "httpusername", "httppassword", "port", "interval", + ] + _TYPES = { + # leading zeroes matter, so `id` should be treated is a string. + "id": str, + "friendlyname": str, + "url": str, + "type": int, + "subtype": int, + "keywordtype": int, + "keywordvalue": str, + "httpusername": str, + "httppassword": str, + "port": int, + "interval": int, + } + _REQUIRED_FIELDS = ["friendlyname", "url", "type"] + + # Possible monitor types, copied from https://uptimerobot.com/api + TYPE_KEYWORD = 2 + TYPE_PORT = 4 + + def __init__(self, **kwargs): + super(Monitor, self).__init__(**kwargs) + self._added_contacts = [] + self._contacts_str = None + if "alertcontact" in kwargs: + # This means that this Monitor object has been created based on + # the data returned by getMonitors API call, which includes contact + # IDs and options, which can be placed in the right format into + # self._contacts_str. + contacts = [ + self._contact_str(c["id"], c["threshold"], c["recurrence"]) + for c in kwargs["alertcontact"]] + self._contacts_str = "-".join(sorted(contacts)) + + def _contact_str(self, *args): + return "_".join([str(a) for a in args]) + + @property + def _contacts(self): + """Returns contact information for this monitor. + + Information is returned in the format expected by editMonitor or + newMonitor API calls. For monitors that come from the server (i.e. + initialized based on getMonitors data) we can read the + self._contacts_str directly. Otherwise we look at all contacts + added using self.add_contacts. + """ + if self._contacts_str: + return self._contacts_str + contacts = [self._contact_str(c[0]["id"], c[1], c[2]) + for c in self._added_contacts] + return "-".join(sorted(contacts)) + + def __repr__(self): + return "<{} {}>".format(self._values, self._contacts) + + def __eq__(self, other): + if not super(Monitor, self).__eq__(other): + return False + return self._contacts == other._contacts + + @property + def _params_delete(self): + """Generates URL parameters for the deleteMonitor API call.""" + return {"monitorID": self["id"]} + + @property + def _params_create(self): + """Generates URL parameters for the newMonitor API call.""" + create_params = { + "friendlyname": "monitorFriendlyName", + "url": "monitorURL", + "type": "monitorType", + "subtype": "monitorSubType", + "port": "monitorPort", + "keywordtype": "monitorKeywordType", + "keywordvalue": "monitorKeywordValue", + "httpusername": "monitorHTTPUsername", + "httppassword": "monitorHTTPPassword", + "interval": "monitorInterval", + } + params = {create_params[f]: self[f] for f in self._FIELDS} + params["monitorAlertContacts"] = self._contacts + return params + + @property + def _params_update(self): + """Generates URL parameters for the editMonitor API call.""" + return self._params_create + + def add_contacts(self, *args, **kwargs): + """Defines contacts for a monitor. + + Args: + args: one or more Contact objects (returned by functions like + `email_contact` or `boxcar_contact`). + threshold: alert threshold (the x value that is set to define "if + down for x minutes, alert every y minutes). + recurrence: alert recurrence (the y value that is set to define "if + down for x minutes, alert every y minutes). + """ + for key in kwargs: + assert key in ("threshold", "recurrence"), \ + "invalid keyword argument to add_contacts: {}".format(key) + threshold = kwargs.get("threshold", 0) + recurrence = kwargs.get("recurrence", 0) + for c in args: + assert type(c) == Contact, "{} is not a Contact".format(c) + self._added_contacts.append((c, threshold, recurrence)) + + +class Contact(Syncable): + _FIELDS = ["friendlyname", "type", "value"] + _TYPES = { + # leading zeroes matter, so `id` should be treated is a string. + "id": str, + "friendlyname": str, + "type": int, + "value": str, + } + _REQUIRED_FIELDS = _FIELDS + + # Possible contact types, copied from https://uptimerobot.com/api + TYPE_EMAIL = 2 + TYPE_BOXCAR = 4 + + @property + def _params_delete(self): + """Generates URL parameters for the deleteAlertContact API call.""" + return {"alertContactID": self["id"]} + + @property + def _params_create(self): + """Generates URL parameters for the newAlertContact API call.""" + return { + "alertContactType": self["type"], + "alertContactValue": self["value"], + "alertContactFriendlyName": self["friendlyname"], + }