From 9d9e3a90707c671fd303819cd92542024b1e75f3 Mon Sep 17 00:00:00 2001 From: Kirill Zaitsev Date: Mon, 19 Dec 2016 16:56:34 +0300 Subject: [PATCH] Add writer API and tests --- runbook/api/v1/writer/__init__.py | 0 runbook/api/v1/writer/runbook_.py | 129 ++++++++++++++++++++++ runbook/main_writer.py | 2 +- runbook/storage.py | 3 +- tests/unit/api/v1/writer/__init__.py | 0 tests/unit/api/v1/writer/test_runbook_.py | 126 +++++++++++++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 runbook/api/v1/writer/__init__.py create mode 100644 runbook/api/v1/writer/runbook_.py create mode 100644 tests/unit/api/v1/writer/__init__.py create mode 100644 tests/unit/api/v1/writer/test_runbook_.py diff --git a/runbook/api/v1/writer/__init__.py b/runbook/api/v1/writer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/runbook/api/v1/writer/runbook_.py b/runbook/api/v1/writer/runbook_.py new file mode 100644 index 0000000..c210448 --- /dev/null +++ b/runbook/api/v1/writer/runbook_.py @@ -0,0 +1,129 @@ +# Copyright 2016: Mirantis Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import elasticsearch +import flask +import jsonschema + +from runbook.api import utils +from runbook import storage + +API_TYPE = "writer" +bp = flask.Blueprint("runbooks", __name__) + + +def get_blueprints(): + return [["/region", bp]] + + +RUNBOOK_SCHEMA = { + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "type": {"type": "string"}, + "runbook": {"type": "string"}, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 0, + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "default": {"type": "string"}, + "type": {"type": "string"}, + }, + "required": ["name"], + }, + "minItems": 0, + }, + }, + "required": ["name", "description", "runbook", "type"], + "additionalProperties": False +} + + +def _convert(hit): + body = {k: v for k, v in hit["_source"].items()} + body["_id"] = hit["_id"] + return body + + +@bp.route("//runbooks", methods=["POST"]) +@utils.check_regions(API_TYPE) +def handle_runbooks(region): + es = storage.get_elasticsearch(API_TYPE) + index_name = "ms_runbooks_{}".format(region) + + runbook = flask.request.get_json(silent=True) + try: + jsonschema.validate(runbook, RUNBOOK_SCHEMA) + except jsonschema.ValidationError as e: + # NOTE(kzaitsev): jsonschema exception has really good unicode + # error representation + return flask.jsonify( + {"error": u"{}".format(e)}), 400 + + resp = es.index( + index=index_name, + doc_type="runbook", + body=runbook, + ) + if resp['_shards']['successful']: + # at least 1 means we're good + return flask.jsonify({"id": resp["_id"]}), 201 + # should not really be here + return flask.jsonify({"error": "Was unable to save the document"}), 500 + + +@bp.route("//runbooks/", methods=["PUT", "DELETE"]) +@utils.check_regions(API_TYPE) +def handle_single_runbook(region, book_id): + es = storage.get_elasticsearch(API_TYPE) + index_name = "ms_runbooks_{}".format(region) + + if flask.request.method == "DELETE": + runbook = {"deleted": True} + success_code = 204 + else: # PUT + runbook = flask.request.get_json(silent=True) + try: + jsonschema.validate(runbook, RUNBOOK_SCHEMA) + except jsonschema.ValidationError as e: + return flask.jsonify( + {"error": u"{}".format(e)}), 400 + success_code = 200 + + try: + resp = es.update( + index=index_name, + doc_type="runbook", + id=book_id, + body={"doc": runbook}, + ) + except elasticsearch.NotFoundError: + flask.abort(404) + + if resp['_shards']['successful'] or resp['result'] == 'noop': + # noop means nothing to update, also ok + return flask.jsonify({"_id": resp["_id"]}), success_code + return flask.jsonify({"error": "Was unable to update the document"}), 500 diff --git a/runbook/main_writer.py b/runbook/main_writer.py index a72f263..1145b2e 100644 --- a/runbook/main_writer.py +++ b/runbook/main_writer.py @@ -18,7 +18,7 @@ import flask from flask_helpers import routing -from runbook.api.v1 import runbook as runbook_api +from runbook.api.v1.writer import runbook_ as runbook_api from runbook import config diff --git a/runbook/storage.py b/runbook/storage.py index ffc7c4e..a525401 100644 --- a/runbook/storage.py +++ b/runbook/storage.py @@ -39,7 +39,8 @@ "default": {"type": "keyword"}, "type": {"type": "keyword"}, } - } + }, + "deleted": {"type": "boolean"}, } }, "run": { diff --git a/tests/unit/api/v1/writer/__init__.py b/tests/unit/api/v1/writer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/v1/writer/test_runbook_.py b/tests/unit/api/v1/writer/test_runbook_.py new file mode 100644 index 0000000..4c680f7 --- /dev/null +++ b/tests/unit/api/v1/writer/test_runbook_.py @@ -0,0 +1,126 @@ +# Copyright 2016: Mirantis Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +import elasticsearch +import mock + +from tests.unit.api.v1 import base + + +class WriterRunbookTestCase(base.APITestCase): + api_type = "writer" + + correct_runbook = { + "name": "test", + "description": "test", + "type": "bash", + "runbook": "echo", + } + + incorrect_runbook = { + "name": "test", + "description": "test", + "type": "bash", + } + + def test_post_new_runbook_bad_input(self): + resp = self.client.post("/api/v1/region/region_one/runbooks") + self.assertEqual(400, resp.status_code) + + resp = self.client.post("/api/v1/region/region_one/runbooks", + data=json.dumps(self.incorrect_runbook), + content_type="application/json") + self.assertEqual(400, resp.status_code) + + resp = self.client.post("/api/v1/region/region_one/runbooks", + data=json.dumps(self.correct_runbook), + ) + self.assertEqual(400, resp.status_code) + + @mock.patch.object(elasticsearch.Elasticsearch, "index") + def test_post_new_runbook(self, es_index): + es_index.return_value = { + "_shards": {"successful": 1}, + "_id": "123", + } + resp = self.client.post("/api/v1/region/region_one/runbooks", + data=json.dumps(self.correct_runbook), + content_type="application/json") + self.assertEqual(201, resp.status_code) + resp_json = json.loads(resp.data.decode()) + self.assertEqual(resp_json["id"], "123") + + es_index.assert_called_with(index="ms_runbooks_region_one", + doc_type="runbook", + body=self.correct_runbook) + + @mock.patch.object(elasticsearch.Elasticsearch, "update") + def test_del_single_runbook(self, es_update): + es_update.return_value = { + "_shards": {"successful": 1}, + "_id": "123", + } + resp = self.client.delete("/api/v1/region/region_one/runbooks/123", + content_type="application/json") + self.assertEqual(204, resp.status_code) + es_update.assert_called_with(index="ms_runbooks_region_one", + doc_type="runbook", + id="123", + body={'doc': {'deleted': True}}) + + @mock.patch.object(elasticsearch.Elasticsearch, "update") + def test_del_single_runbook_bad_id(self, es_update): + es_update.side_effect = elasticsearch.NotFoundError + resp = self.client.delete("/api/v1/region/region_one/runbooks/123", + content_type="application/json") + self.assertEqual(404, resp.status_code) + es_update.assert_called_with(index="ms_runbooks_region_one", + doc_type="runbook", + id="123", + body={'doc': {'deleted': True}}) + + @mock.patch.object(elasticsearch.Elasticsearch, "update") + def test_put_single_runbook_bad_id(self, es_update): + es_update.side_effect = elasticsearch.NotFoundError + resp = self.client.put("/api/v1/region/region_one/runbooks/123", + data=json.dumps(self.correct_runbook), + content_type="application/json") + self.assertEqual(404, resp.status_code) + expected_body = {"doc": self.correct_runbook} + es_update.assert_called_with(index="ms_runbooks_region_one", + doc_type="runbook", + id="123", + body=expected_body) + + @mock.patch.object(elasticsearch.Elasticsearch, "update") + def test_put_single_runbook(self, es_update): + es_update.return_value = { + "_shards": {"successful": 1}, + "_id": "123", + } + resp = self.client.put("/api/v1/region/region_one/runbooks/123", + data=json.dumps(self.correct_runbook), + content_type="application/json") + self.assertEqual(200, resp.status_code) + resp_json = json.loads(resp.data.decode()) + self.assertEqual({"_id": "123"}, resp_json) + + expected_body = {"doc": self.correct_runbook} + es_update.assert_called_with(index="ms_runbooks_region_one", + doc_type="runbook", + id="123", + body=expected_body)