Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
129 changes: 129 additions & 0 deletions runbook/api/v1/writer/runbook_.py
Original file line number Diff line number Diff line change
@@ -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("/<region>/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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation (57-64 lines) looks pretty generic and can be re-used in 96-103 lines if will be created as a decorator.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is generic, but writing it as a decorator — I believe would only hurt readability. it's only used in PUT branch of the 2d endpoint, so this decorator would need to somehow handle that. Looks like more code to me


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("/<region>/runbooks/<book_id>", methods=["PUT", "DELETE"])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather prefer to have two separate handlers: one for PUT and one for DELETE. Right now it looks like you have one big function which contains only one if ... else ... statement, moreover two clauses of this if share only es and index_name variables.

@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
2 changes: 1 addition & 1 deletion runbook/main_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion runbook/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"default": {"type": "keyword"},
"type": {"type": "keyword"},
}
}
},
"deleted": {"type": "boolean"},
}
},
"run": {
Expand Down
Empty file.
126 changes: 126 additions & 0 deletions tests/unit/api/v1/writer/test_runbook_.py
Original file line number Diff line number Diff line change
@@ -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)