Api performance enhancements

- Topology code refactored to be more efficient
 - Backend APIs returning large data sets compress the response
 - Garbage Collector configured to collect when there are few
   collectable objects, rather then wait till there are many.
 - Garbage Collector called after every API call.

Change-Id: Ieda233932aff7e6621845544d94f73960ece834c
Depends-On: I5a908238cfd02616bd4a75470057157338530917
This commit is contained in:
Idan Hefetz 2018-11-22 16:55:00 +00:00
parent 5409f4adbe
commit a21a32ccfa
15 changed files with 135 additions and 172 deletions

View File

@ -38,6 +38,7 @@ APPCONFIGS = {}
def setup_app(root, conf=None):
app_hooks = [hooks.ConfigHook(conf),
hooks.TranslationHook(),
hooks.GCHook(),
hooks.RPCHook(conf),
hooks.ContextHook(),
hooks.DBHook(conf)]

View File

@ -13,7 +13,6 @@
# under the License.
import json
from oslo_log import log
from oslo_utils.strutils import bool_from_string
import pecan
@ -23,7 +22,7 @@ from vitrage.api.controllers.rest import RootRestController
from vitrage.api.policy import enforce
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as Vprops
from vitrage.common.utils import decompress_obj
LOG = log.getLogger(__name__)
@ -53,7 +52,7 @@ class BaseAlarmsController(RootRestController):
enforce("list alarms", pecan.request.headers,
pecan.request.enforcer, {})
alarms_json = \
alarms = \
pecan.request.client.call(pecan.request.context,
'get_alarms',
vitrage_id=vitrage_id,
@ -71,7 +70,7 @@ class BaseAlarmsController(RootRestController):
)
try:
alarms_list = json.loads(alarms_json)['alarms']
alarms_list = decompress_obj(alarms)['alarms']
return alarms_list
except Exception:

View File

@ -19,7 +19,7 @@ from pecan.core import abort
from vitrage.api.controllers.rest import RootRestController
from vitrage.api.policy import enforce
from vitrage.common.utils import decompress_obj
LOG = log.getLogger(__name__)
@ -55,13 +55,12 @@ class ResourcesController(RootRestController):
LOG.info('get_resources with type: %s, all_tenants: %s',
resource_type, all_tenants)
try:
resources_json = \
resources = \
pecan.request.client.call(pecan.request.context,
'get_resources',
resource_type=resource_type,
all_tenants=all_tenants)
LOG.info(resources_json)
resources = json.loads(resources_json)['resources']
resources = decompress_obj(resources)['resources']
return resources
except Exception:
LOG.exception('Failed to get resources.')

View File

@ -9,6 +9,7 @@
# 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 gc
from vitrage.api.controllers.v1 import alarm
from vitrage.api.controllers.v1 import event
@ -20,6 +21,9 @@ from vitrage.api.controllers.v1 import webhook
class V1Controller(object):
gc.set_threshold(1, 1, 1)
topology = topology.TopologyController()
resources = resource.ResourcesController()
alarm = alarm.AlarmsController()

View File

@ -12,7 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import networkx as nx
@ -27,6 +26,7 @@ from vitrage.api.policy import enforce
from vitrage.common.constants import VertexProperties as VProps
# noinspection PyProtectedMember
from vitrage.common.utils import decompress_obj
from vitrage.datasources.transformer_base import CLUSTER_ID
@ -77,8 +77,7 @@ class TopologyController(RootRestController):
query=query,
root=root,
all_tenants=all_tenants)
LOG.debug(graph_data)
graph = json.loads(graph_data)
graph = decompress_obj(graph_data)
if graph_type == 'graph':
return graph
if graph_type == 'tree':

View File

@ -9,7 +9,7 @@
# 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 gc
import oslo_messaging
from oslo_context import context
@ -89,3 +89,9 @@ class DBHook(hooks.PecanHook):
def before(self, state):
state.request.storage = self.storage
class GCHook(hooks.PecanHook):
def after(self, state):
gc.collect()

View File

@ -22,6 +22,7 @@ from vitrage.common.constants import EntityCategory as ECategory
from vitrage.common.constants import HistoryProps as HProps
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
from vitrage.common.utils import compress_obj
from vitrage.datasources.alarm_properties import AlarmProperties as AProps
from vitrage.entity_graph.mappings.operational_alarm_severity import \
OperationalAlarmSeverity
@ -54,7 +55,8 @@ class AlarmApis(EntityGraphApisBase):
kwargs.get('filter_vals', []).append(vitrage_id)
alarms = self._get_alarms(*args, **kwargs)
return json.dumps({'alarms': [v.payload for v in alarms]})
data = {'alarms': [v.payload for v in alarms]}
return compress_obj(data, level=1)
# TODO(annarez): add db support
def show_alarm(self, ctx, vitrage_id):

View File

@ -21,7 +21,8 @@ from vitrage.api_handler.apis.base import RESOURCES_ALL_QUERY
from vitrage.common.constants import EntityCategory
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
from vitrage.common.utils import compress_obj
from vitrage.common.utils import timed_method
LOG = log.getLogger(__name__)
@ -34,6 +35,7 @@ class ResourceApis(EntityGraphApisBase):
self.entity_graph = entity_graph
self.conf = conf
@timed_method(log_results=True)
def get_resources(self, ctx, resource_type=None, all_tenants=False):
LOG.debug('ResourceApis get_resources - resource_type: %s,'
'all_tenants: %s', str(resource_type), all_tenants)
@ -55,8 +57,8 @@ class ResourceApis(EntityGraphApisBase):
query['and'].append(type_query)
resources = self.entity_graph.get_vertices(query_dict=query)
return json.dumps({'resources': [resource.properties
for resource in resources]})
data = {'resources': [r.properties for r in resources]}
return compress_obj(data, level=1)
def show_resource(self, ctx, vitrage_id):

View File

@ -11,6 +11,7 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from networkx.algorithms.shortest_paths.generic import shortest_path
from oslo_log import log
from osprofiler import profiler
@ -24,7 +25,8 @@ from vitrage.common.constants import EntityCategory
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
from vitrage.common.exception import VitrageError
from vitrage.datasources.nova.instance import NOVA_INSTANCE_DATASOURCE
from vitrage.common.utils import compress_obj
from vitrage.common.utils import timed_method
from vitrage.datasources import OPENSTACK_CLUSTER
LOG = log.getLogger(__name__)
@ -38,13 +40,13 @@ class TopologyApis(EntityGraphApisBase):
self.entity_graph = entity_graph
self.conf = conf
@timed_method(log_results=True)
def get_topology(self, ctx, graph_type, depth, query, root, all_tenants):
LOG.debug("TopologyApis get_topology - root: %s, all_tenants=%s",
str(root), all_tenants)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
ga = self.entity_graph.algo
LOG.debug('project_id = %s, is_admin_project %s',
project_id, is_admin_project)
@ -63,29 +65,30 @@ class TopologyApis(EntityGraphApisBase):
{'==': {VProps.PROJECT_ID: None}}]}
current_query = {'and': [query, project_query]}
graph = ga.graph_query_vertices(root_id,
query_dict=current_query,
depth=depth,
edge_query_dict=EDGE_QUERY)
graph = self.entity_graph.algo.graph_query_vertices(
root_id,
query_dict=current_query,
depth=depth,
edge_query_dict=EDGE_QUERY)
# By default the graph_type is 'graph'
else:
if all_tenants:
q = query if query else TOPOLOGY_AND_ALARMS_QUERY
graph = ga.create_graph_from_matching_vertices(
query_dict=q,
edge_attr_filter={VProps.VITRAGE_IS_DELETED: False})
graph = \
self.entity_graph.algo.create_graph_from_matching_vertices(
query_dict=q,
edge_attr_filter={VProps.VITRAGE_IS_DELETED: False})
else:
graph = self._get_topology_for_specific_project(
ga,
query,
project_id,
is_admin_project,
root_id)
return graph.json_output_graph()
data = graph.json_output_graph(raw=True)
return compress_obj(data, level=1)
def _get_topology_for_specific_project(self,
ga,
query,
project_id,
is_admin_project,
@ -95,7 +98,6 @@ class TopologyApis(EntityGraphApisBase):
Finds all the entities which has project_id. In case the tenant is
admin then project_id can also be None.
:type ga: NXAlgorithm
:type query: dictionary
:type project_id: string
:type is_admin_project: boolean
@ -118,25 +120,24 @@ class TopologyApis(EntityGraphApisBase):
default_query = {'or': [resource_query, alarm_query]}
q = default_query
tmp_graph = ga.create_graph_from_matching_vertices(query_dict=q)
graph = self._create_graph_of_connected_components(ga, tmp_graph, root)
vertices_ids = self.entity_graph.get_vertices_ids(query_dict=q)
vertices_ids = self._all_paths_from_node(self.entity_graph,
source_node=root,
targets=vertices_ids)
graph = self.entity_graph.algo.subgraph(vertices_ids).copy()
edge_query = {EProps.VITRAGE_IS_DELETED: False}
self._remove_unnecessary_elements(ga,
graph,
project_id,
is_admin_project,
edge_attr_filter=edge_query)
self._remove_unnecessary_elements(
graph, project_id, is_admin_project, edge_attr_filter=edge_query)
return graph
def _remove_unnecessary_elements(self,
ga,
graph,
project_id,
is_admin_project,
edge_attr_filter):
# delete non matching edges
ga._apply_edge_attr_filter(graph, edge_attr_filter)
self.entity_graph.algo.apply_edge_attr_filter(graph, edge_attr_filter)
self._remove_alarms_of_other_projects(graph,
project_id,
@ -173,44 +174,19 @@ class TopologyApis(EntityGraphApisBase):
if cond1 or cond2:
graph.remove_vertex(alarm)
def _create_graph_of_connected_components(self, ga, tmp_graph, root):
return ga.subgraph(self._topology_for_unrooted_graph(ga,
tmp_graph,
root)).copy()
@staticmethod
def _all_paths_from_node(graph, source_node, targets):
"""Find all nodes on a (shortest) path from source to targets
def _topology_for_unrooted_graph(self, ga, subgraph, root):
"""Finds topology for unrooted subgraph
Return all the node ids that are either in targets
or are in a path from source node to any of targets
1. Finds all the connected component subgraphs in subgraph.
2. For each component, finds the path from one of the VMs (if exists)
to the root entity.
3. Unify all the entities found and return them
:type ga: NXAlgorithm
:type subgraph: networkx graph
:type root: string
:rtype: list
"""
entities = []
root_vertex = \
self.entity_graph.get_vertex(root)
local_connected_component_subgraphs = \
ga.connected_component_subgraphs(subgraph)
for component_subgraph in local_connected_component_subgraphs:
entities += list(component_subgraph.nodes())
instance_in_component_subgraph = \
self._find_instance_in_graph(component_subgraph)
if instance_in_component_subgraph:
paths = ga.all_simple_paths(root_vertex.vertex_id,
instance_in_component_subgraph)
for path in paths:
entities += path
return set(entities)
vertices_ids = targets
paths = shortest_path(graph._g, source=source_node)
vertices_ids.update(*[set(paths.get(n, [])) for n in targets])
return vertices_ids
def _default_root_id(self):
tmp_vertices = self.entity_graph.get_vertices(
@ -221,13 +197,3 @@ class TopologyApis(EntityGraphApisBase):
if len(tmp_vertices) > 1:
raise VitrageError("Multiple root vertices found")
return tmp_vertices[0].vertex_id
@staticmethod
def _find_instance_in_graph(graph):
for node, node_data in graph.nodes(data=True):
if node_data[VProps.VITRAGE_CATEGORY] == \
EntityCategory.RESOURCE \
and node_data[VProps.VITRAGE_TYPE] == \
NOVA_INSTANCE_DATASOURCE:
return node
return None

View File

@ -16,18 +16,25 @@
# 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 base64
from collections import defaultdict
import copy
import hashlib
import itertools
import random
import six
from six.moves import cPickle
import threading
import time
import zlib
from oslo_config import cfg
from oslo_log import log
import cProfile
LOG = log.getLogger(__name__)
def recursive_keypairs(d, separator='.'):
# taken from ceilometer and gnocchi
@ -116,3 +123,35 @@ def fmt(docstr):
docstr = docstr.strip()
return docstr
def timed_method(log_results=False, warn_above_sec=-1):
def _decorator(function):
def wrapper(*args, **kwargs):
t1 = time.time()
result = function(*args, **kwargs)
t2 = time.time()
if warn_above_sec > 0 and warn_above_sec < t2 - t1:
LOG.warning(
'Function %s runtime crossed limit %s seconds.',
function.__name__, t2 - t1)
elif log_results:
LOG.info('Function %s timed %s', function.__name__, t2 - t1)
return result
return wrapper
return _decorator
def compress_obj(obj, level=9):
str_data = cPickle.dumps(obj)
data = base64.b64encode(zlib.compress(str_data, level))
return data
def decompress_obj(blob):
decoded_blob = base64.standard_b64decode(blob)
str_data = zlib.decompress(decoded_blob)
obj = cPickle.loads(str_data)
del decoded_blob
del str_data
return obj

View File

@ -79,29 +79,8 @@ class GraphAlgorithm(object):
"""
pass
@staticmethod
def connected_component_subgraphs(subgraph):
"""Generate connected components as subgraphs.
:type subgraph: NetworkX graph.
:rtype: list of NXGraphs
"""
pass
def all_simple_paths(self, source, target):
"""Generate all simple paths in the graph G from source to target.
A simple path is a path with no repeated nodes.
:type source: Starting node for path
:type target: Ending node for path
:rtype: lists of simple paths
"""
pass
@abc.abstractmethod
def create_graph_from_matching_vertices(self,
vertex_attr_filter=None,
query_dict=None,
edge_attr_filter=None):
"""Generate graph using the query
@ -109,7 +88,6 @@ class GraphAlgorithm(object):
Finds all the vertices in the graph matching the query, and returns
a subgraph consisted from the vertices
:type vertex_attr_filter: dictionary
:type query_dict: dictionary
:type edge_attr_filter: dictionary
:rtype: NXGraph

View File

@ -12,9 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from networkx.algorithms import components
from networkx.algorithms import simple_paths
from oslo_log import log as logging
from vitrage.common.constants import EdgeProperties as EProps
@ -23,8 +20,6 @@ from vitrage.graph.algo_driver.algorithm import Mapping
from vitrage.graph.algo_driver.sub_graph_matching import NEG_CONDITION
from vitrage.graph.algo_driver.sub_graph_matching import subgraph_matching
from vitrage.graph.driver import Direction
from vitrage.graph.driver import Edge
from vitrage.graph.driver import Vertex
from vitrage.graph.filter import check_filter
from vitrage.graph.query import create_predicate
@ -84,10 +79,11 @@ class NXAlgorithm(GraphAlgorithm):
e_result.extend(e_list)
nodes_q.extend([(v_id, curr_depth + 1) for v_id, data in n_list])
graph = self._create_new_graph(
graph.name,
vertices=self._vertex_result_to_list(n_result),
edges=self._edge_result_to_list(e_result))
graph = self._create_new_graph(graph.name)
for v_id, data in n_result:
graph._g.add_node(v_id, **data)
for source_id, target_id, label, data in e_result:
graph._g.add_edge(source_id, target_id, label, **data)
return graph
@ -128,32 +124,15 @@ class NXAlgorithm(GraphAlgorithm):
validate)
def create_graph_from_matching_vertices(self,
vertex_attr_filter=None,
query_dict=None,
edge_attr_filter=None):
if query_dict:
vertices = self.graph.get_vertices(query_dict=query_dict)
elif vertex_attr_filter:
vertices = self.graph.get_vertices(
vertex_attr_filter=vertex_attr_filter)
else:
vertices = self.graph.get_vertices()
vertices_ids = [vertex.vertex_id for vertex in vertices]
vertices_ids = self.graph.get_vertices_ids(query_dict=query_dict)
graph = self._create_new_graph('graph')
graph._g = self.graph._g.subgraph(vertices_ids).copy()
graph._g = self.graph._g.subgraph(vertices_ids)
# delete non matching edges
if edge_attr_filter:
self._apply_edge_attr_filter(graph, edge_attr_filter)
LOG.debug('match query, find graph: nodes %s, edges %s',
str(list(graph._g.nodes(data=True))),
str(list(graph._g.edges(data=True))))
LOG.debug('match query, real graph: nodes %s, edges %s',
str(list(self.graph._g.nodes(data=True))),
str(list(self.graph._g.edges(data=True))))
self.apply_edge_attr_filter(graph, edge_attr_filter)
return graph
@ -162,15 +141,6 @@ class NXAlgorithm(GraphAlgorithm):
subgraph._g = self.graph._g.subgraph(entities)
return subgraph
def connected_component_subgraphs(self, subgraph):
return components.connected_component_subgraphs(
subgraph._g.to_undirected(), copy=False)
def all_simple_paths(self, source, target):
return simple_paths.all_simple_paths(self.graph._g,
source=source,
target=target)
def _filtered_subgraph_matching(self,
ge_v_id,
sge_v_id,
@ -191,21 +161,6 @@ class NXAlgorithm(GraphAlgorithm):
return []
@staticmethod
def _edge_result_to_list(edge_result):
d = dict()
for source_id, target_id, label, data in edge_result:
d[(source_id, target_id, label)] = \
Edge(source_id, target_id, label, properties=data)
return d.values()
@staticmethod
def _vertex_result_to_list(vertex_result):
d = dict()
for v_id, data in vertex_result:
d[v_id] = Vertex(vertex_id=v_id, properties=data)
return d.values()
@staticmethod
def _list_union(list_1, list_2):
"""Union of list that aren't hashable
@ -223,7 +178,7 @@ class NXAlgorithm(GraphAlgorithm):
return list_1
@staticmethod
def _apply_edge_attr_filter(graph, edge_attr_filter):
def apply_edge_attr_filter(graph, edge_attr_filter):
edges = graph._g.edges(data=True, keys=True)
edges_to_remove = [(u, v, k) for (u, v, k, d) in edges
if not check_filter(d, edge_attr_filter)]

View File

@ -250,6 +250,17 @@ class NXGraph(Graph):
else:
return []
def get_vertices_ids(self, query_dict):
if not query_dict:
return list(self._g.nodes())
vertices_ids = set()
match_func = create_predicate(query_dict)
for node, node_data in self._g.nodes(data=True):
if match_func(node_data):
vertices_ids.add(node)
return vertices_ids
def get_vertices_by_key(self, key_values_hash):
if key_values_hash in self.key_to_vertex_ids:

View File

@ -18,6 +18,7 @@
from datetime import datetime
# noinspection PyPackageRequirements
from mock import mock
from vitrage.common.utils import compress_obj
from vitrage.storage.sqlalchemy import models
from vitrage.tests.functional.api.v1 import FunctionalTest
@ -54,7 +55,7 @@ class NoAuthTest(FunctionalTest):
def test_noauth_mode_get_topology(self):
with mock.patch('pecan.request') as request:
request.client.call.return_value = '{}'
request.client.call.return_value = compress_obj({})
params = dict(depth=None, graph_type='graph', query=None,
root=None,
all_tenants=False)
@ -66,7 +67,7 @@ class NoAuthTest(FunctionalTest):
def test_noauth_mode_list_alarms(self):
with mock.patch('pecan.request') as request:
request.client.call.return_value = '{"alarms": []}'
request.client.call.return_value = compress_obj({"alarms": []})
params = dict(vitrage_id='all', all_tenants=False)
data = self.get_json('/alarm/', params=params)
@ -95,7 +96,7 @@ class NoAuthTest(FunctionalTest):
def test_noauth_mode_list_resources(self):
with mock.patch('pecan.request') as request:
request.client.call.return_value = '{"resources": []}'
request.client.call.return_value = compress_obj({"resources": []})
params = dict(resource_type='all', all_tenants=False)
data = self.get_json('/resources/', params=params)

View File

@ -26,6 +26,7 @@ from vitrage.common.constants import EdgeLabel
from vitrage.common.constants import EdgeProperties
from vitrage.common.constants import EntityCategory
from vitrage.common.constants import VertexProperties as VProps
from vitrage.common.utils import decompress_obj
from vitrage.datasources import NOVA_HOST_DATASOURCE
from vitrage.datasources import NOVA_INSTANCE_DATASOURCE
from vitrage.datasources import NOVA_ZONE_DATASOURCE
@ -60,7 +61,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
# Action
alarms = apis.get_alarms(ctx, vitrage_id='all', all_tenants=False)
alarms = json.loads(alarms)['alarms']
alarms = decompress_obj(alarms)['alarms']
# Test assertions
self.assertThat(alarms, matchers.HasLength(3))
@ -74,7 +75,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
# Action
alarms = apis.get_alarms(ctx, vitrage_id='all', all_tenants=False)
alarms = json.loads(alarms)['alarms']
alarms = decompress_obj(alarms)['alarms']
# Test assertions
self.assertThat(alarms, matchers.HasLength(2))
@ -105,7 +106,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
# Action
alarms = apis.get_alarms(ctx, vitrage_id='all', all_tenants=True)
alarms = json.loads(alarms)['alarms']
alarms = decompress_obj(alarms)['alarms']
# Test assertions
self.assertThat(alarms, matchers.HasLength(5))
@ -200,7 +201,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
query=None,
root=None,
all_tenants=False)
graph_topology = json.loads(graph_topology)
graph_topology = decompress_obj(graph_topology)
# Test assertions
self.assertThat(graph_topology['nodes'], matchers.HasLength(8))
@ -222,7 +223,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
query=None,
root=None,
all_tenants=False)
graph_topology = json.loads(graph_topology)
graph_topology = decompress_obj(graph_topology)
# Test assertions
self.assertThat(graph_topology['nodes'], matchers.HasLength(7))
@ -244,7 +245,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
query=None,
root=None,
all_tenants=True)
graph_topology = json.loads(graph_topology)
graph_topology = decompress_obj(graph_topology)
# Test assertions
self.assertThat(graph_topology['nodes'], matchers.HasLength(12))
@ -260,7 +261,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
ctx,
resource_type=None,
all_tenants=False)
resources = json.loads(resources)['resources']
resources = decompress_obj(resources)['resources']
# Test assertions
self.assertThat(resources, matchers.HasLength(5))
@ -276,7 +277,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
ctx,
resource_type=None,
all_tenants=False)
resources = json.loads(resources)['resources']
resources = decompress_obj(resources)['resources']
# Test assertions
self.assertThat(resources, matchers.HasLength(2))
@ -292,7 +293,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
ctx,
resource_type=NOVA_HOST_DATASOURCE,
all_tenants=False)
resources = json.loads(resources)['resources']
resources = decompress_obj(resources)['resources']
# Test assertions
self.assertThat(resources, IsEmpty())
@ -308,7 +309,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
ctx,
resource_type=NOVA_INSTANCE_DATASOURCE,
all_tenants=False)
resources = json.loads(resources)['resources']
resources = decompress_obj(resources)['resources']
# Test assertions
self.assertThat(resources, matchers.HasLength(2))
@ -324,7 +325,7 @@ class TestApis(TestEntityGraphUnitBase, TestConfiguration):
ctx,
resource_type=None,
all_tenants=True)
resources = json.loads(resources)['resources']
resources = decompress_obj(resources)['resources']
# Test assertions
self.assertThat(resources, matchers.HasLength(7))