diff --git a/mimic/core.py b/mimic/core.py index f3a0ad8610..89d6e34ebd 100644 --- a/mimic/core.py +++ b/mimic/core.py @@ -22,6 +22,7 @@ from mimic.model.ironic_objects import IronicNodeStore from mimic.model.glance_objects import GlanceAdminImageStore from mimic.model.valkyrie_objects import ValkyrieStore +from mimic.rest.cloudfeedscap import CustomerAccessEventStore class MimicCoreException(Exception): @@ -112,6 +113,7 @@ def __init__(self, clock, apis, domains=()): self.ironic_node_store = IronicNodeStore() self.glance_admin_image_store = GlanceAdminImageStore() self.valkyrie_store = ValkyrieStore() + self.cloudfeeds_ca_store = CustomerAccessEventStore() self.domains = list(domains) for api in apis: diff --git a/mimic/resource.py b/mimic/resource.py index f61792ca15..35a7ec7ea9 100644 --- a/mimic/resource.py +++ b/mimic/resource.py @@ -23,6 +23,7 @@ from mimic.rest.noit_api import NoitApi from mimic.rest import (fastly_api, mailgun_api, customer_api, ironic_api, glance_api, valkyrie_api) +from mimic.rest.cloudfeedscap import CloudFeedsCAPRoutes from mimic.util.helper import json_from_request from mimic.util.helper import seconds_to_timestamp @@ -115,6 +116,15 @@ def valkyrie_api(self, request): """ return valkyrie_api.ValkyrieApi(self.core).app.resource() + @app.route("/cloudfeeds_cap", branch=True) + def customer_access_cloudfeeds(self, request): + """ + Customer Access policy events as cloudfeeds service. This is seperarate + from `otter.rest.cloudfeeds` as this is not tenant specific produdct + events. Instead is a global list of events about all accounts + """ + return CloudFeedsCAPRoutes(self.core, self.clock).app.resource() + @app.route('/mimic/v1.0/presets', methods=['GET']) def get_mimic_presets(self, request): """ diff --git a/mimic/rest/cloudfeedscap.py b/mimic/rest/cloudfeedscap.py new file mode 100644 index 0000000000..f5ee92ae88 --- /dev/null +++ b/mimic/rest/cloudfeedscap.py @@ -0,0 +1,165 @@ +""" +Cloudfeeds customer access events +""" +from __future__ import absolute_import, division, unicode_literals + +from uuid import uuid4 +from six import text_type +from mimic.rest.mimicapp import MimicApp +from mimic.util.helper import json_from_request, seconds_to_timestamp + +import attr + +from twisted.internet.interfaces import IReactorTime +from twisted.web.template import Tag, flattenString +from twisted.python.url import URL + + +@attr.s(frozen=True) +class CustomerAccessEvent(object): + """ + A customer access event + """ + tenant_id = attr.ib() + status = attr.ib() + updated = attr.ib() + id = attr.ib(default=attr.Factory(lambda: str(uuid4()))) + + @classmethod + def from_dict(cls, d, clock): + """ + Return new CustomerAccessEvent from given dict and updated from clock + """ + return CustomerAccessEvent(d["tenant_id"], d["status"], clock.seconds()) + + +@attr.s +class CustomerAccessEventStore(object): + """ + Collection of :obj:`CustomerAccessEvent` + """ + # List of :obj:`CustomerAccessEvent` + events = attr.ib(default=attr.Factory(list)) + # mapping from CustomerAccessEvent.id to index in events list + events_index = attr.ib(default=attr.Factory(dict)) + + +@attr.s +class CloudFeedsCAPRoutes(object): + """ + This class implements routes for cloud feeds customer access policy events API + """ + core = attr.ib() + clock = attr.ib(validator=attr.validators.provides(IReactorTime)) + # Number of entries to return if not provided + BATCH_LIMIT = 10 + + app = MimicApp() + + def __attrs_post_init__(self): + """ + Cache store for easy access + """ + self.store = self.core.cloudfeeds_ca_store + + @app.route("/events", methods=["POST"]) + def add_customer_access_event(self, request): + """ + Add customer access event. Return 201 on success. Sample request:: + + {"events": [ + {"tenant_id": "23535", "status": "SUSPENDED"}, + {"tenant_id": "463423", "status": "TERMINATED"} + ]} + + NOTE: This is control API. + """ + content = json_from_request(request) + events = [CustomerAccessEvent.from_dict(d, self.clock) for d in content["events"]] + self.store.events = events + self.store.events + for i, event in enumerate(self.store.events): + self.store.events_index[event.id] = i + request.setResponseCode(201) + + @app.route("/customer_access_policy/events", methods=["GET"]) + def get_customer_access_events(self, request): + """ + Return customer access events atom feed format + """ + marker = request.args.get(b"marker", [b""])[0].decode("ascii") or None + direction = (request.args.get(b"direction", [b"forward"])[0] + .decode("ascii")) + limit = int(request.args.get(b"limit", + [text_type(self.BATCH_LIMIT) + .encode("ascii")])[0].decode("ascii")) + index = self.store.events_index.get(marker, None) + if direction == u"forward": + events = self.store.events[:index][:limit] + elif direction == u"backward": + events = self.store.events[index + 1:][:limit] + else: + raise ValueError("Unknown direction " + direction) + request.setHeader(b"Content-Type", b"application/atom+xml") + return generate_feed_xml(events) + + +def feed_tag(): + """ + Return new tag + """ + feed = Tag("feed")(xmlns="http://www.w3.org/2005/Atom") + feed(Tag("title")(type="text")("customer_access_policy/events")) + return feed + + +def entry_tag(): + """ + Return new , and tag + """ + entry = Tag("entry") + for term in ["rgn:GLOBAL", "dc:GLOBAL", "customerservice.access_policy.info", + "type:customerservice.access_policy.info"]: + entry(Tag("category")(term=term)) + entry(Tag("title")(type="text")("CustomerService")) + content = Tag("content")(type="application/xml") + entry(content) + event = Tag("event")(xmlns="http://docs.rackspace.com/core/event", dataCenter="GLOBAL", + environment="PROD", region="GLOBAL", type="INFO", version="2") + product = Tag("product")(xmlns="http://docs.rackspace.com/event/customer/access_policy", + previousEvent="", serviceCode="CustomerService", version="1") + event(product) + content(event) + return entry, event, product + + +def generate_feed_xml(events): + """ + Generate ATOM feed XML for given events + + :param list events: List of :obj:`CustomerAccessEvent` + + :return: XML text as bytes + """ + root = URL.fromText( + "https://mimic-host-port/cloudfeeds_cap/customer_access_events" + ) + feed = feed_tag() + if events: + feed(Tag("link")(href=root + .add("direction", "forward") + .add("marker", text_type(events[0].id)) + .asText(), + rel="previous")) + feed(Tag("link")(href=root + .add("direction", "backward") + .add("marker", text_type(events[-1].id)) + .asText(), rel="next")) + for event in events: + entry, event_tag, product = entry_tag() + entry(Tag("category")(term="tid:{}".format(event.tenant_id))) + event_tag(id=event.id, tenantId=event.tenant_id) + product(status=event.status) + entry(Tag("updated")(seconds_to_timestamp(event.updated))) + entry(Tag("published")(seconds_to_timestamp(event.updated))) + feed(entry) + return flattenString(None, feed).result diff --git a/mimic/test/test_cloudfeedscap.py b/mimic/test/test_cloudfeedscap.py new file mode 100644 index 0000000000..f8e7269b40 --- /dev/null +++ b/mimic/test/test_cloudfeedscap.py @@ -0,0 +1,210 @@ +""" +Tests for :obj:`mimic.rest.cloudfeedscap` +""" +import json +from six.moves.urllib.parse import urlencode + +import xmltodict + +from mimic.core import MimicCore +from mimic.resource import MimicRoot +from mimic.rest.cloudfeedscap import CustomerAccessEvent, generate_feed_xml +from mimic.test.helpers import request, request_with_content +from mimic.util.helper import seconds_to_timestamp + +from testtools.matchers import MatchesDict, MatchesListwise, Equals, ContainsDict +from testtools.assertions import assert_that + +from twisted.trial.unittest import SynchronousTestCase +from twisted.internet.task import Clock + + +empty_feed = (b'' + b'customer_access_policy/events' + b'') + + +def assert_has_events(testcase, xml, events, prev, next): + """ + Assert that xml has given events with previous and next link. Currently + only checks if event info i.e. (tenant_id, status, links and updated) are + correct. Ideally, it should ideally check against XML schema also. Will + probably add that later. + """ + feed_match = MatchesDict({ + "feed": ContainsDict({ + "link": MatchesListwise([ + MatchesDict({"@href": Equals(prev), + "@rel": Equals("previous")}), + MatchesDict({"@href": Equals(next), + "@rel": Equals("next")}) + ]), + "entry": MatchesListwise([ + ContainsDict({ + "content": ContainsDict({ + "event:event": ContainsDict({ + "@tenantId": Equals(event.tenant_id), + "@id": Equals(event.id), + "ap:product": ContainsDict({"@status": Equals(event.status)}) + }) + }), + "updated": Equals(seconds_to_timestamp(event.updated)), + "published": Equals(seconds_to_timestamp(event.updated)) + }) + for event in events + ]) + }) + }) + namespaces = {"http://www.w3.org/2005/Atom": None, + "http://docs.rackspace.com/core/event": "event", + "http://docs.rackspace.com/event/customer/access_policy": "ap"} + d = xmltodict.parse(xml, process_namespaces=True, namespaces=namespaces) + assert_that(d, feed_match) + + +def link(params): + """ + Return full URL with given query params + """ + return (u"https://mimic-host-port/cloudfeeds_cap/customer_access_events?{}" + .format(urlencode(sorted(params.items())))) + + +class GenFeedTests(SynchronousTestCase): + """ + Tests for :func:`generate_feed` + """ + + def test_no_entries(self): + """ + Generates empty feed when given empty list of events. Does not provide next and previous links + """ + self.assertEqual(generate_feed_xml([]), empty_feed) + + def test_entries(self): + """ + Generates feed with proper next and previous link and "entry" nodes with event info in them. + """ + events = [CustomerAccessEvent("t1", "FULL", 0.0, "1"), + CustomerAccessEvent("t2", "TERMINATED", 100.0, "2")] + xml = generate_feed_xml(events) + assert_has_events(self, xml, events, link(dict(marker="1", direction="forward")), + link(dict(marker="2", direction="backward"))) + + +class RoutesTests(SynchronousTestCase): + """ + Test for routes in :obj:`CloudFeedsCAPRoutes` + """ + def setUp(self): + self.clock = Clock() + self.core = MimicCore(self.clock, []) + self.root = MimicRoot(self.core, clock=self.clock).app.resource() + from mimic.rest import cloudfeedscap as cf + self.ids = ["id2", "id1"] + self.patch(cf, "uuid4", lambda: self.ids.pop()) + + def test_add_events_empty(self): + """ + Calling `POST ../events` first time will add events in event store and setup + indexes + """ + events = [{"tenant_id": "1234", "status": "SUSPENDED"}, + {"tenant_id": "2345", "status": "FULL"}] + d = request(self, self.root, b"POST", b"/cloudfeeds_cap/events", + body=json.dumps({"events": events}).encode("utf-8")) + self.assertEqual(self.successResultOf(d).code, 201) + self.assertEqual( + self.core.cloudfeeds_ca_store.events, + [CustomerAccessEvent(u"1234", u"SUSPENDED", 0.0, u"id1"), + CustomerAccessEvent(u"2345", u"FULL", 0.0, u"id2")]) + self.assertEqual( + self.core.cloudfeeds_ca_store.events_index, {"id1": 0, "id2": 1}) + + def test_add_events_update(self): + """ + Calling `POST .../events` with existing events will prepend the events + to the store.events list and update the store.events_index too + """ + self.test_add_events_empty() + self.clock.advance(200) + self.ids = ["id5", "id4"] + events = [{"tenant_id": "t1", "status": "TERMINATED"}, + {"tenant_id": "t2", "status": "SUSPENDED"}] + d = request(self, self.root, b"POST", b"/cloudfeeds_cap/events", + body=json.dumps({"events": events}).encode("utf-8")) + self.assertEqual(self.successResultOf(d).code, 201) + exp_events = [CustomerAccessEvent(u"t1", u"TERMINATED", 200.0, u"id4"), + CustomerAccessEvent(u"t2", u"SUSPENDED", 200.0, u"id5"), + CustomerAccessEvent(u"1234", u"SUSPENDED", 0.0, u"id1"), + CustomerAccessEvent(u"2345", u"FULL", 0.0, u"id2")] + self.assertEqual(self.core.cloudfeeds_ca_store.events, exp_events) + self.assertEqual( + self.core.cloudfeeds_ca_store.events_index, + {"id4": 0, "id5": 1, "id1": 2, "id2": 3}) + return exp_events + + def test_get_events_empty(self): + """ + `GET ../customer_access_policy/events` returns empty xml feed when there + are no events stored. No previous and next links are provided. + """ + d = request_with_content( + self, self.root, b"GET", b"/cloudfeeds_cap/customer_access_policy/events") + resp, body = self.successResultOf(d) + self.assertEqual(resp.headers.getRawHeaders("Content-Type"), + ["application/atom+xml"]) + self.assertEqual(body, empty_feed) + + def test_get_events(self): + """ + `GET ../customer_access_policy/events` returns events stored in xml feed + format with proper next and previous link + """ + events = self.test_add_events_update() + resp, body = self.successResultOf(request_with_content( + self, self.root, b"GET", b"/cloudfeeds_cap/customer_access_policy/events")) + assert_has_events( + self, body, events, link(dict(marker="id4", direction="forward")), + link(dict(marker="id2", direction="backward"))) + + def test_get_events_marker_forward(self): + """ + `GET ../customer_access_policy/events?marker=m&direction=forward` returns events occurring + after given marker + """ + events = self.test_add_events_update() + resp, body = self.successResultOf(request_with_content( + self, self.root, b"GET", + b"/cloudfeeds_cap/customer_access_policy/events?marker=id1&direction=forward")) + assert_has_events( + self, body, events[:2], + link(dict(marker="id4", direction="forward")), + link(dict(marker="id5", direction="backward"))) + + def test_get_events_marker_backward(self): + """ + `GET ../customer_access_policy/events?marker=m&direction=backward` returns events occurring + before given marker + """ + events = self.test_add_events_update() + resp, body = self.successResultOf(request_with_content( + self, self.root, b"GET", + b"/cloudfeeds_cap/customer_access_policy/events?marker=id5&direction=backward")) + assert_has_events( + self, body, events[2:], + link(dict(marker="id1", direction="forward")), + link(dict(marker="id2", direction="backward"))) + + def test_get_events_limit(self): + """ + `GET ../customer_access_policy/events?limit=3` returns events <= 3 + """ + events = self.test_add_events_update() + resp, body = self.successResultOf(request_with_content( + self, self.root, b"GET", + b"/cloudfeeds_cap/customer_access_policy/events?limit=3")) + assert_has_events( + self, body, events[:3], + link(dict(marker="id4", direction="forward")), + link(dict(marker="id1", direction="backward")))