Skip to content
Closed
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
34 changes: 34 additions & 0 deletions runbook/api/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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 functools

import flask

from runbook import config


def check_regions(func):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that we should move this over time to oss-lib

regions = set(config.get_config()["regions"])

@functools.wraps(func)
def checker(region, *args, **kwargs):
if region not in regions:
return flask.jsonify(
{"error": "Region {} Not Found".format(region)}), 404
return func(region, *args, **kwargs)

return checker
108 changes: 106 additions & 2 deletions runbook/api/v1/runbook_.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,142 @@
# 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

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"},
},
"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=["GET", "POST"])
@utils.check_regions
def handle_runbooks(region):
return flask.jsonify("fixme!")
es = storage.get_elasticsearch()
index_name = "ms_runbooks_{}".format(region)

if flask.request.method == "POST":
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 document"}), 500
else:
result = es.search(index=index_name, doc_type="runbook")
hit_list = [_convert(hit) for hit in result['hits']['hits']]
return flask.jsonify(hit_list)


@bp.route("/<region>/runbooks/<book_id>",
methods=["GET", "PUT", "DELETE"])
@utils.check_regions
def handle_single_runbook(region, book_id):
return flask.jsonify("fixme!")
es = storage.get_elasticsearch()
index_name = "ms_runbooks_{}".format(region)

if flask.request.method == "GET":
try:
result = es.get(
index=index_name,
doc_type="runbook",
id=book_id
)
return flask.jsonify(_convert(result))
except elasticsearch.NotFoundError:
flask.abort(404)

elif flask.request.method == "DELETE":
try:
es.delete(
index=index_name,
doc_type="runbook",
id=book_id
)
return flask.jsonify({}), 204
except elasticsearch.NotFoundError:
flask.abort(404)

elif flask.request.method == "PUT":
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

probably it makes sense to move code related PUT, POST, GET to helpers so it will be way simpler to write unittests

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we can use smth like flask-restfull if this becomes a problem. However since we're splitting API into ro/wo the functions would get simpler.

http://flask-restful-cn.readthedocs.io/en/0.3.5/

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

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"]})
return flask.jsonify({"error": "Was unable to update document"}), 500
return flask.jsonify({"error": "Unreachable"}), 500


@bp.route("/<region>/runbooks/<book_id>/run",
methods=["POST"])
@utils.check_regions
def run_runbook(region, book_id):
return flask.jsonify("fixme!")


@bp.route("/<region>/runbooks/<book_id>/runs")
@utils.check_regions
def runbook_runs(region, book_id):
return flask.jsonify("fixme!")


@bp.route("/<region>/runbooks/<book_id>/runs/<run_id>")
@utils.check_regions
def single_runbook_run(region, book_id, run_id):
return flask.jsonify("fixme!")
Empty file added tests/unit/api/__init__.py
Empty file.
Empty file added tests/unit/api/v1/__init__.py
Empty file.
60 changes: 60 additions & 0 deletions tests/unit/api/v1/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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 mock
import testtools


TEST_CONFIG = {
"flask": {
"PORT": 5000,
"HOST": "0.0.0.0",
"DEBUG": True
},
"backend": {
"type": "elastic",
"connection": [{"host": "127.0.0.1", "port": 9200}]
},
"regions": [
"region_one",
"region_two"
]
}


class APITestCase(testtools.TestCase):

def setUp(self):
super(APITestCase, self).setUp()
self.addCleanup(mock.patch.stopall)

# NOTE(kzaitsev): mock all get_config for a ll the tests
self.patcher = mock.patch('runbook.config.get_config')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we mock config in place othewise it's really hard for newbies to understand where it is mocked?

self.get_config = self.patcher.start()
self.get_config.return_value = TEST_CONFIG

# NOTE(kzaitsev): importing this module calls get_config, so I'm
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Module should not import config.
We should not have any long running operations (like reading files) during the module imports

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is not the case for all our projects currently — main.py calls get_config at module level. should be trivial to change that though

# importing it after I've mocked get_config itself
import runbook.main
self.client = runbook.main.app.test_client()
self.app = runbook.main.app

def test_not_found(self):
resp = self.client.get('/404')
self.assertEqual({"error": "Not Found"},
json.loads(resp.data.decode()))
self.assertEqual(404, resp.status_code)
Loading