diff --git a/docs/index.rst b/docs/index.rst index 892094048ce4..79e6616a8ae4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -128,6 +128,7 @@ Client monitoring-metric monitoring-resource + monitoring-group monitoring-query monitoring-timeseries monitoring-label diff --git a/docs/monitoring-group.rst b/docs/monitoring-group.rst new file mode 100644 index 000000000000..0187dad0099f --- /dev/null +++ b/docs/monitoring-group.rst @@ -0,0 +1,7 @@ +Groups +====== + +.. automodule:: gcloud.monitoring.group + :members: + :show-inheritance: + diff --git a/docs/monitoring-usage.rst b/docs/monitoring-usage.rst index adfd93cefe67..93979009e7d0 100644 --- a/docs/monitoring-usage.rst +++ b/docs/monitoring-usage.rst @@ -154,6 +154,86 @@ before you call projects.metricDescriptors +Groups +------ + +A group is a dynamic collection of *monitored resources* whose membership is +defined by a `filter`_. These groups are usually created via the +`Stackdriver dashboard`_. You can list all the groups in a project with the +:meth:`~gcloud.monitoring.client.Client.list_groups` method:: + + >>> for group in client.list_groups(): + ... print(group.id, group.display_name, group.parent_id) + ('a001', 'Production', None) + ('a002', 'Front-end', 'a001') + ('1003', 'Back-end', 'a001') + +See :class:`~gcloud.monitoring.group.Group` and the API documentation for +`Groups`_ and `Group members`_ for more information. + +You can get a specific group based on it's ID as follows:: + + >>> group = client.fetch_group('a001') + +You can get the current members of this group using the +:meth:`~gcloud.monitoring.group.Group.list_members` method:: + + >>> for member in group.list_members(): + ... print(member) + +Passing in ``end_time`` and ``start_time`` to the above method will return +historical members based on the current filter of the group. The group +membership changes over time, as *monitored resources* come and go, and as they +change properties. + +You can create new groups to define new collections of *monitored resources*. +You do this by creating a :class:`~gcloud.monitoring.group.Group` object using +the client's :meth:`~gcloud.monitoring.client.Client.group` factory and then +calling the object's :meth:`~gcloud.monitoring.group.Group.create` method:: + + >>> filter_string = 'resource.zone = "us-central1-a"' + >>> group = client.group( + ... display_name='My group', + ... filter_string=filter_string, + ... parent_id='a001', + ... is_cluster=True) + >>> group.create() + >>> group.id + '1234' + +You can further manipulate an existing group by first initializing a Group +object with it's ID or name, and then calling various methods on it. + +Delete a group:: + + >>> group = client.group('1234') + >>> group.exists() + True + >>> group.delete() + + +Update a group:: + + >>> group = client.group('1234') + >>> group.exists() + True + >>> group.reload() + >>> group.display_name = 'New Display Name' + >>> group.update() + +.. _Stackdriver dashboard: + https://support.stackdriver.com/customer/portal/articles/\ + 1535145-creating-groups +.. _filter: + https://cloud.google.com/monitoring/api/v3/filters#group-filter +.. _Groups: + https://cloud.google.com/monitoring/api/ref_v3/rest/v3/\ + projects.groups +.. _Group members: + https://cloud.google.com/monitoring/api/ref_v3/rest/v3/\ + projects.groups.members + + Time Series Queries ------------------- diff --git a/gcloud/monitoring/__init__.py b/gcloud/monitoring/__init__.py index 26b92da74e40..d155c8a492cf 100644 --- a/gcloud/monitoring/__init__.py +++ b/gcloud/monitoring/__init__.py @@ -16,6 +16,7 @@ from gcloud.monitoring.client import Client from gcloud.monitoring.connection import Connection +from gcloud.monitoring.group import Group from gcloud.monitoring.label import LabelDescriptor from gcloud.monitoring.label import LabelValueType from gcloud.monitoring.metric import Metric @@ -33,6 +34,7 @@ __all__ = ( 'Client', 'Connection', + 'Group', 'LabelDescriptor', 'LabelValueType', 'Metric', 'MetricDescriptor', 'MetricKind', 'ValueType', 'Aligner', 'Query', 'Reducer', diff --git a/gcloud/monitoring/client.py b/gcloud/monitoring/client.py index efe47c23e538..73a4da6df64d 100644 --- a/gcloud/monitoring/client.py +++ b/gcloud/monitoring/client.py @@ -30,6 +30,7 @@ from gcloud.client import JSONClient from gcloud.monitoring.connection import Connection +from gcloud.monitoring.group import Group from gcloud.monitoring.metric import MetricDescriptor from gcloud.monitoring.metric import MetricKind from gcloud.monitoring.metric import ValueType @@ -282,3 +283,82 @@ def list_resource_descriptors(self, filter_string=None): https://cloud.google.com/monitoring/api/v3/filters """ return ResourceDescriptor._list(self, filter_string) + + def group(self, group_id=None, display_name=None, parent_id=None, + filter_string=None, is_cluster=False): + """Factory constructor for group object. + + .. note:: + This will not make an HTTP request; it simply instantiates + a group object owned by this client. + + :type group_id: string or None + :param group_id: The ID of the group. + + :type display_name: string or None + :param display_name: + A user-assigned name for this group, used only for display + purposes. + + :type parent_id: string or None + :param parent_id: + The ID of the group's parent, if it has one. + + :type filter_string: string or None + :param filter_string: + The filter string used to determine which monitored resources + belong to this group. + + :type is_cluster: boolean + :param is_cluster: + If true, the members of this group are considered to be a cluster. + The system can perform additional analysis on groups that are + clusters. + + :rtype: :class:`Group` + :returns: The group created with the passed-in arguments. + + :raises: + :exc:`ValueError` if both ``group_id`` and ``name`` are specified. + """ + return Group( + self, + group_id=group_id, + display_name=display_name, + parent_id=parent_id, + filter_string=filter_string, + is_cluster=is_cluster, + ) + + def fetch_group(self, group_id): + """Fetch a group from the API based on it's ID. + + Example:: + + >>> try: + >>> group = client.fetch_group('1234') + >>> except gcloud.exceptions.NotFound: + >>> print('That group does not exist!') + + :type group_id: string + :param group_id: The ID of the group. + + :rtype: :class:`~gcloud.monitoring.group.Group` + :returns: The group instance. + + :raises: :class:`gcloud.exceptions.NotFound` if the group is not found. + """ + return Group._fetch(self, group_id) + + def list_groups(self): + """List all groups for the project. + + Example:: + + >>> for group in client.list_groups(): + ... print((group.display_name, group.name)) + + :rtype: list of :class:`~gcloud.monitoring.group.Group` + :returns: A list of group instances. + """ + return Group._list(self) diff --git a/gcloud/monitoring/group.py b/gcloud/monitoring/group.py new file mode 100644 index 000000000000..abeff05fe1e9 --- /dev/null +++ b/gcloud/monitoring/group.py @@ -0,0 +1,496 @@ +# Copyright 2016 Google 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. + +"""Groups for the `Google Stackdriver Monitoring API (V3)`_. + +.. _Google Stackdriver Monitoring API (V3): + https://cloud.google.com/monitoring/api/ref_v3/rest/v3/\ + projects.groups +""" + +import re + +from gcloud._helpers import _datetime_to_rfc3339 +from gcloud._helpers import _name_from_project_path +from gcloud.exceptions import NotFound +from gcloud.monitoring.resource import Resource + + +_GROUP_TEMPLATE = re.compile(r""" + projects/ # static prefix + (?P[^/]+) # initial letter, wordchars + hyphen + /groups/ # static midfix + (?P[^/]+) # initial letter, wordchars + allowed punc +""", re.VERBOSE) + + +def _group_id_from_name(path, project=None): + """Validate a group URI path and get the group ID. + + :type path: string + :param path: URI path for a group API request. + + :type project: string or None + :param project: The project associated with the request. It is + included for validation purposes. + + :rtype: string + :returns: Group ID parsed from ``path``. + :raises: :class:`ValueError` if the ``path`` is ill-formed or if + the project from the ``path`` does not agree with the + ``project`` passed in. + """ + return _name_from_project_path(path, project, _GROUP_TEMPLATE) + + +def _group_name_from_id(project, group_id): + """Build the group name given the project and group ID. + + :type project: string + :param project: The project associated with the group. + + :type group_id: string + :param group_id: The group ID. + + :rtype: string + :returns: The fully qualified name of the group. + """ + return 'projects/{project}/groups/{group_id}'.format( + project=project, group_id=group_id) + + +class Group(object): + """A dynamic collection of monitored resources. + + :type client: :class:`gcloud.monitoring.client.Client` + :param client: A client for operating on the metric descriptor. + + :type group_id: string or None + :param group_id: The ID of the group. + + :type display_name: string or None + :param display_name: + A user-assigned name for this group, used only for display purposes. + + :type parent_id: string or None + :param parent_id: + The ID of the group's parent, if it has one. + + :type filter_string: string or None + :param filter_string: + The filter string used to determine which monitored resources belong to + this group. + + :type is_cluster: boolean + :param is_cluster: + If true, the members of this group are considered to be a cluster. The + system can perform additional analysis on groups that are clusters. + """ + def __init__(self, client, group_id=None, display_name=None, + parent_id=None, filter_string=None, is_cluster=False): + self.client = client + self._id = group_id + self.display_name = display_name + self.parent_id = parent_id + self.filter = filter_string + self.is_cluster = is_cluster + + if group_id: + self._name = _group_name_from_id(client.project, group_id) + else: + self._name = None + + @property + def id(self): + """Returns the group ID. + + :rtype: str or None + :returns: the ID of the group based on it's name. + """ + return self._id + + @property + def name(self): + """Returns the fully qualified name of the group. + + :rtype: str or None + :returns: + The fully qualified name of the group in the format + "projects//groups/". + """ + return self._name + + @property + def parent_name(self): + """Returns the fully qualified name of the parent group. + + :rtype: str or None + :returns: + The fully qualified name of the parent group. + """ + if not self.parent_id: + return None + return _group_name_from_id(self.client.project, self.parent_id) + + @property + def path(self): + """URL path to this group. + + :rtype: str + :returns: the path based on project and group name. + + :raises: :exc:`ValueError` if :attr:`name` is not specified. + """ + if not self.id: + raise ValueError('Cannot determine path without group ID.') + return '/' + self.name + + def create(self): + """Create a new group based on this object via a ``POST`` request. + + Example:: + + >>> filter_string = 'resource.type = "gce_instance"' + >>> group = client.group( + ... display_name='My group', + ... filter_string=filter_string, + ... parent_id='5678', + ... is_cluster=True) + >>> group.create() + + The ``name`` attribute is ignored in preparing the creation request. + All attributes are overwritten by the values received in the response + (normally affecting only ``name``). + """ + path = '/projects/%s/groups/' % (self.client.project,) + info = self.client.connection.api_request( + method='POST', path=path, data=self._to_dict()) + self._set_properties_from_dict(info) + + def exists(self): + """Test for the existence of the group via a ``GET`` request. + + :rtype: bool + :returns: Boolean indicating existence of the group. + """ + try: + self.client.connection.api_request( + method='GET', path=self.path, query_params={'fields': 'name'}) + except NotFound: + return False + else: + return True + + def reload(self): + """Sync local group information via a ``GET`` request. + + .. warning:: + + This will overwrite any local changes you've made and not saved + via :meth:`update`. + """ + info = self.client.connection.api_request(method='GET', path=self.path) + self._set_properties_from_dict(info) + + def update(self): + """Update the group via a ``PUT`` request.""" + info = self.client.connection.api_request( + method='PUT', path=self.path, data=self._to_dict()) + self._set_properties_from_dict(info) + + def delete(self): + """Delete the group via a ``DELETE`` request. + + Example:: + + >>> group = client.group('1234') + >>> group.delete() + + Only the ``client`` and ``name`` attributes are used. + + .. warning:: + + This method will fail for groups that have one or more children + groups. + """ + self.client.connection.api_request(method='DELETE', path=self.path) + + def fetch_parent(self): + """Returns the parent group of this group via a ``GET`` request. + + :rtype: :class:`Group` or None + :returns: The parent of the group. + """ + if not self.parent_id: + return None + return self._fetch(self.client, self.parent_id) + + def list_children(self): + """Lists all children of this group via a ``GET`` request. + + Returns groups whose parent_name field contains the group name. If no + groups have this parent, the results are empty. + + :rtype: list of :class:`~gcloud.monitoring.group.Group` + :returns: A list of group instances. + """ + return self._list(self.client, children_of_group=self.name) + + def list_ancestors(self): + """Lists all ancestors of this group via a ``GET`` request. + + The groups are returned in order, starting with the immediate parent + and ending with the most distant ancestor. If the specified group has + no immediate parent, the results are empty. + + :rtype: list of :class:`~gcloud.monitoring.group.Group` + :returns: A list of group instances. + """ + return self._list(self.client, ancestors_of_group=self.name) + + def list_descendants(self): + """Lists all descendants of this group via a ``GET`` request. + + This returns a superset of the results returned by the :meth:`children` + method, and includes children-of-children, and so forth. + + :rtype: list of :class:`~gcloud.monitoring.group.Group` + :returns: A list of group instances. + """ + return self._list(self.client, descendants_of_group=self.name) + + def list_members(self, filter_string=None, end_time=None, start_time=None): + """Lists all members of this group via a ``GET`` request. + + If no ``end_time`` is provided then the group membership over the last + minute is returned. + + Example:: + + >>> for member in group.list_members(): + ... print member + + List members that are Compute Engine VM instances:: + + >>> filter_string = 'resource.type = "gce_instance"' + >>> for member in group.list_members(filter_string=filter_string): + ... print member + + List historical members that existed between 4 and 5 hours ago:: + + >>> import datetime + >>> t1 = datetime.datetime.utcnow() - datetime.timedelta(hours=4) + >>> t0 = t1 - datetime.timedelta(hours=1) + >>> for member in group.list_members(end_time=t1, start_time=t0): + ... print member + + + :type filter_string: string or None + :param filter_string: + An optional list filter describing the members to be returned. The + filter may reference the type, labels, and metadata of monitored + resources that comprise the group. See the `filter documentation`_. + + :type end_time: :class:`datetime.datetime` or None + :param end_time: + The end time (inclusive) of the time interval for which results + should be returned, as a datetime object. If ``start_time`` is + specified, then this must also be specified. + + :type start_time: :class:`datetime.datetime` or None + :param start_time: + The start time (exclusive) of the time interval for which results + should be returned, as a datetime object. + + :rtype: list of :class:`~gcloud.monitoring.resource.Resource` + :returns: A list of resource instances. + + :raises: + :exc:`ValueError` if the ``start_time`` is specified, but the + ``end_time`` is missing. + + .. _filter documentation: + https://cloud.google.com/monitoring/api/v3/filters#group-filter + """ + if start_time is not None and end_time is None: + raise ValueError('If "start_time" is specified, "end_time" must ' + 'also be specified') + + path = '%s/members' % (self.path,) + resources = [] + page_token = None + params = {} + + if filter_string is not None: + params['filter'] = filter_string + + if end_time is not None: + params['interval.endTime'] = _datetime_to_rfc3339( + end_time, ignore_zone=False) + + if start_time is not None: + params['interval.startTime'] = _datetime_to_rfc3339( + start_time, ignore_zone=False) + + while True: + if page_token is not None: + params['pageToken'] = page_token + + response = self.client.connection.api_request( + method='GET', path=path, query_params=params.copy()) + for info in response.get('members', ()): + resources.append(Resource._from_dict(info)) + + page_token = response.get('nextPageToken') + if not page_token: + break + + return resources + + @classmethod + def _fetch(cls, client, group_id): + """Fetch a group from the API based on it's ID. + + :type client: :class:`gcloud.monitoring.client.Client` + :param client: The client to use. + + :type group_id: string + :param group_id: The group ID. + + :rtype: :class:`Group` + :returns: The group instance. + + :raises: :class:`gcloud.exceptions.NotFound` if the group + is not found. + """ + new_group = cls(client, group_id) + new_group.reload() + return new_group + + @classmethod + def _list(cls, client, children_of_group=None, ancestors_of_group=None, + descendants_of_group=None): + """Lists all groups in the project. + + :type client: :class:`gcloud.monitoring.client.Client` + :param client: The client to use. + + :type children_of_group: string or None + :param children_of_group: + Returns groups whose parent_name field contains the group name. If + no groups have this parent, the results are empty. + + :type ancestors_of_group: string or None + :param ancestors_of_group: + Returns groups that are ancestors of the specified group. If the + specified group has no immediate parent, the results are empty. + + :type descendants_of_group: string or None + :param descendants_of_group: + Returns the descendants of the specified group. This is a superset + of the results returned by the children_of_group filter, and + includes children-of-children, and so forth. + + :rtype: list of :class:`~gcloud.monitoring.group.Group` + :returns: A list of group instances. + """ + path = '/projects/%s/groups/' % (client.project,) + groups = [] + page_token = None + params = {} + + if children_of_group is not None: + params['childrenOfGroup'] = children_of_group + + if ancestors_of_group is not None: + params['ancestorsOfGroup'] = ancestors_of_group + + if descendants_of_group is not None: + params['descendantsOfGroup'] = descendants_of_group + + while True: + if page_token is not None: + params['pageToken'] = page_token + + response = client.connection.api_request( + method='GET', path=path, query_params=params.copy()) + for info in response.get('group', ()): + groups.append(cls._from_dict(client, info)) + + page_token = response.get('nextPageToken') + if not page_token: + break + + return groups + + @classmethod + def _from_dict(cls, client, info): + """Constructs a Group instance from the parsed JSON representation. + + :type client: :class:`gcloud.monitoring.client.Client` + :param client: A client to be included in the returned object. + + :type info: dict + :param info: + A ``dict`` parsed from the JSON wire-format representation. + + :rtype: :class:`Group` + :returns: A group. + """ + group = cls(client) + group._set_properties_from_dict(info) + return group + + def _set_properties_from_dict(self, info): + """Update the group properties from its API representation. + + :type info: dict + :param info: + A ``dict`` parsed from the JSON wire-format representation. + """ + self._name = info['name'] + self._id = _group_id_from_name(self._name) + self.display_name = info['displayName'] + self.filter = info['filter'] + self.is_cluster = info.get('isCluster', False) + + parent_name = info.get('parentName') + if parent_name is None: + self.parent_id = None + else: + self.parent_id = _group_id_from_name(parent_name) + + def _to_dict(self): + """Build a dictionary ready to be serialized to the JSON wire format. + + :rtype: dict + :returns: A dictionary. + """ + info = { + 'filter': self.filter, + 'displayName': self.display_name, + 'isCluster': self.is_cluster, + } + + if self.name is not None: + info['name'] = self.name + + parent_name = self.parent_name + if parent_name is not None: + info['parentName'] = parent_name + + return info + + def __repr__(self): + return '' % (self.name,) diff --git a/gcloud/monitoring/test_client.py b/gcloud/monitoring/test_client.py index 6124eda7806d..af497c8ee5ce 100644 --- a/gcloud/monitoring/test_client.py +++ b/gcloud/monitoring/test_client.py @@ -330,6 +330,103 @@ def test_list_resource_descriptors(self): 'query_params': {}} self.assertEqual(request, expected_request) + def test_group(self): + GROUP_ID = 'GROUP_ID' + DISPLAY_NAME = 'My Group' + PARENT_ID = 'PARENT_ID' + FILTER = 'resource.type = "gce_instance"' + IS_CLUSTER = False + + client = self._makeOne(project=PROJECT, credentials=_Credentials()) + group = client.group(GROUP_ID, display_name=DISPLAY_NAME, + parent_id=PARENT_ID, filter_string=FILTER, + is_cluster=IS_CLUSTER) + + self.assertEqual(group.id, GROUP_ID) + self.assertEqual(group.display_name, DISPLAY_NAME) + self.assertEqual(group.parent_id, PARENT_ID) + self.assertEqual(group.filter, FILTER) + self.assertEqual(group.is_cluster, IS_CLUSTER) + + def test_group_defaults(self): + client = self._makeOne(project=PROJECT, credentials=_Credentials()) + group = client.group() + + self.assertIsNone(group.id) + self.assertIsNone(group.display_name) + self.assertIsNone(group.parent_id) + self.assertIsNone(group.filter) + self.assertFalse(group.is_cluster) + + def test_fetch_group(self): + PATH = 'projects/{project}/groups/'.format(project=PROJECT) + GROUP_ID = 'GROUP_ID' + GROUP_NAME = PATH + GROUP_ID + DISPLAY_NAME = 'My Group' + PARENT_ID = 'PARENT_ID' + PARENT_NAME = PATH + PARENT_ID + FILTER = 'resource.type = "gce_instance"' + IS_CLUSTER = False + + GROUP = { + 'name': GROUP_NAME, + 'displayName': DISPLAY_NAME, + 'parentName': PARENT_NAME, + 'filter': FILTER, + 'isCluster': IS_CLUSTER + } + + client = self._makeOne(project=PROJECT, credentials=_Credentials()) + connection = client.connection = _Connection(GROUP) + group = client.fetch_group(GROUP_ID) + + self.assertEqual(group.id, GROUP_ID) + self.assertEqual(group.display_name, DISPLAY_NAME) + self.assertEqual(group.parent_id, PARENT_ID) + self.assertEqual(group.filter, FILTER) + self.assertEqual(group.is_cluster, IS_CLUSTER) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + GROUP_NAME} + self.assertEqual(request, expected_request) + + def test_list_groups(self): + PATH = 'projects/{project}/groups/'.format(project=PROJECT) + GROUP_NAME = PATH + 'GROUP_ID' + DISPLAY_NAME = 'My Group' + PARENT_NAME = PATH + 'PARENT_ID' + FILTER = 'resource.type = "gce_instance"' + IS_CLUSTER = False + + GROUP = { + 'name': GROUP_NAME, + 'displayName': DISPLAY_NAME, + 'parentName': PARENT_NAME, + 'filter': FILTER, + 'isCluster': IS_CLUSTER, + } + + RESPONSE = { + 'group': [GROUP], + } + client = self._makeOne(project=PROJECT, credentials=_Credentials()) + connection = client.connection = _Connection(RESPONSE) + groups = client.list_groups() + + self.assertEqual(len(groups), 1) + + group = groups[0] + self.assertEqual(group.name, GROUP_NAME) + self.assertEqual(group.display_name, DISPLAY_NAME) + self.assertEqual(group.parent_name, PARENT_NAME) + self.assertEqual(group.filter, FILTER) + self.assertEqual(group.is_cluster, IS_CLUSTER) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + PATH, + 'query_params': {}} + self.assertEqual(request, expected_request) + class _Credentials(object): diff --git a/gcloud/monitoring/test_group.py b/gcloud/monitoring/test_group.py new file mode 100644 index 000000000000..b97e15a1675e --- /dev/null +++ b/gcloud/monitoring/test_group.py @@ -0,0 +1,547 @@ +# Copyright 2016 Google 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 unittest2 + + +class Test_group_id_from_name(unittest2.TestCase): + + def _callFUT(self, path, project): + from gcloud.monitoring.group import _group_id_from_name + return _group_id_from_name(path, project) + + def test_w_empty_name(self): + PROJECT = 'my-project-1234' + PATH = '' + with self.assertRaises(ValueError): + self._callFUT(PATH, PROJECT) + + def test_w_simple_name(self): + GROUP_ID = 'GROUP_ID' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/groups/%s' % (PROJECT, GROUP_ID) + group_id = self._callFUT(PATH, PROJECT) + self.assertEqual(group_id, GROUP_ID) + + def test_w_name_w_all_extras(self): + GROUP_ID = 'GROUP_ID-part.one~part.two%part-three' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/groups/%s' % (PROJECT, GROUP_ID) + group_id = self._callFUT(PATH, PROJECT) + self.assertEqual(group_id, GROUP_ID) + + +class TestGroup(unittest2.TestCase): + + def setUp(self): + self.PROJECT = 'PROJECT' + self.GROUP_ID = 'group_id' + self.PARENT_ID = 'parent_id' + self.DISPLAY_NAME = 'My Group' + + self.PATH = 'projects/%s/groups/' % self.PROJECT + self.GROUP_NAME = self.PATH + self.GROUP_ID + self.PARENT_NAME = self.PATH + self.PARENT_ID + + FILTER_TEMPLATE = 'resource.metadata.tag."color"="%s"' + self.FILTER = FILTER_TEMPLATE % 'blue' + + self.JSON_GROUP = { + 'name': self.GROUP_NAME, + 'displayName': self.DISPLAY_NAME, + 'parentName': self.PARENT_NAME, + 'filter': self.FILTER, + 'isCluster': True, + } + self.JSON_PARENT = { + 'name': self.PARENT_NAME, + 'displayName': 'Parent group', + 'filter': FILTER_TEMPLATE % 'red', + 'isCluster': False, + } + self.JSON_SIBLING = { + 'name': self.PATH + 'sibling_id', + 'displayName': 'Sibling group', + 'parentName': self.PARENT_NAME, + 'filter': FILTER_TEMPLATE % 'orange', + 'isCluster': True, + } + self.JSON_CHILD = { + 'name': self.PATH + 'child_id', + 'displayName': 'Child group', + 'parentName': self.PARENT_NAME, + 'filter': FILTER_TEMPLATE % 'purple', + 'isCluster': False, + } + + def _setUpResources(self): + from gcloud.monitoring.resource import Resource + info1 = { + 'type': 'gce_instance', + 'labels': { + 'project_id': 'my-project', + 'instance_id': '1234567890123456788', + 'zone': 'us-central1-a', + } + } + info2 = { + 'type': 'gce_instance', + 'labels': { + 'project_id': 'my-project', + 'instance_id': '1234567890123456789', + 'zone': 'us-central1-a', + } + } + self.RESOURCE1 = Resource._from_dict(info1) + self.RESOURCE2 = Resource._from_dict(info2) + self.MEMBERS = [info1, info2] + + def _getTargetClass(self): + from gcloud.monitoring.group import Group + return Group + + def _makeOne(self, *args, **kwargs): + return self._getTargetClass()(*args, **kwargs) + + def _makeOneFromJSON(self, info, client=None): + return self._getTargetClass()._from_dict(client=client, info=info) + + def _validateGroup(self, actual_group, expected_group_json): + expected_group = self._makeOneFromJSON(expected_group_json) + self.assertEqual(actual_group.id, expected_group.id) + self.assertEqual(actual_group.display_name, + expected_group.display_name) + self.assertEqual(actual_group.parent_id, expected_group.parent_id) + self.assertEqual(actual_group.filter, expected_group.filter) + self.assertEqual(actual_group.is_cluster, expected_group.is_cluster) + + def _validateGroupList(self, client, actual_groups, expected_groups_json): + self.assertEqual(len(actual_groups), len(expected_groups_json)) + for i, group in enumerate(actual_groups): + self.assertIs(group.client, client) + self._validateGroup(group, expected_groups_json[i]) + + def test_constructor(self): + client = _Client(project=self.PROJECT) + group = self._makeOne( + client=client, + group_id=self.GROUP_ID, + display_name=self.DISPLAY_NAME, + parent_id=self.PARENT_ID, + filter_string=self.FILTER, + is_cluster=True, + ) + + self.assertIs(group.client, client) + + self.assertEqual(group.id, self.GROUP_ID) + self.assertEqual(group.name, self.GROUP_NAME) + self.assertEqual(group.display_name, self.DISPLAY_NAME) + self.assertEqual(group.parent_id, self.PARENT_ID) + self.assertEqual(group.parent_name, self.PARENT_NAME) + self.assertEqual(group.filter, self.FILTER) + self.assertTrue(group.is_cluster) + + def test_constructor_defaults(self): + client = _Client(project=self.PROJECT) + group = self._makeOne(client=client) + + self.assertIs(group.client, client) + + self.assertIsNone(group.id) + self.assertIsNone(group.name) + self.assertIsNone(group.display_name) + self.assertIsNone(group.parent_id) + self.assertIsNone(group.parent_name) + self.assertIsNone(group.filter) + self.assertFalse(group.is_cluster) + + def test_path_no_id(self): + group = self._makeOne(client=None) + self.assertRaises(ValueError, getattr, group, 'path') + + def test_path_w_id(self): + client = _Client(project=self.PROJECT) + group = self._makeOne(client=client, group_id=self.GROUP_ID) + self.assertEqual(group.path, '/%s' % self.GROUP_NAME) + + def test_from_dict(self): + client = _Client(project=self.PROJECT) + group = self._getTargetClass()._from_dict(client, self.JSON_GROUP) + + self.assertIs(group.client, client) + + self.assertEqual(group.name, self.GROUP_NAME) + self.assertEqual(group.display_name, self.DISPLAY_NAME) + self.assertEqual(group.parent_name, self.PARENT_NAME) + self.assertEqual(group.filter, self.FILTER) + self.assertTrue(group.is_cluster) + + def test_from_dict_defaults(self): + client = _Client(project=self.PROJECT) + info = { + 'name': self.GROUP_NAME, + 'displayName': self.DISPLAY_NAME, + 'filter': self.FILTER, + } + group = self._getTargetClass()._from_dict(client, info) + + self.assertIs(group.client, client) + + self.assertEqual(group.id, self.GROUP_ID) + self.assertEqual(group.display_name, self.DISPLAY_NAME) + self.assertIsNone(group.parent_id) + self.assertEqual(group.filter, self.FILTER) + self.assertFalse(group.is_cluster) + + def test_to_dict(self): + client = _Client(project=self.PROJECT) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + self.assertEqual(group._to_dict(), self.JSON_GROUP) + + def test_to_dict_defaults(self): + client = _Client(project=self.PROJECT) + group = self._makeOne( + client=client, group_id=self.GROUP_ID, + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER) + expected_dict = { + 'name': self.GROUP_NAME, + 'displayName': self.DISPLAY_NAME, + 'filter': self.FILTER, + 'isCluster': False, + } + self.assertEqual(group._to_dict(), expected_dict) + + def test_create(self): + RESPONSE = self.JSON_GROUP + + REQUEST = dict(RESPONSE) + REQUEST.pop('name') + + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOne( + client=client, + display_name=self.DISPLAY_NAME, + parent_id=self.PARENT_ID, + filter_string=self.FILTER, + is_cluster=True + ) + group.create() + + self._validateGroup(group, RESPONSE) + + request, = connection._requested + expected_request = {'method': 'POST', 'path': '/' + self.PATH, + 'data': REQUEST} + self.assertEqual(request, expected_request) + + def test_exists_hit(self): + connection = _Connection(self.JSON_GROUP) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOne(client=client, group_id=self.GROUP_ID) + + self.assertTrue(group.exists()) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + self.GROUP_NAME, + 'query_params': {'fields': 'name'}} + self.assertEqual(request, expected_request) + + def test_exists_miss(self): + connection = _Connection() + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOne(client=client, group_id=self.GROUP_ID) + + self.assertFalse(group.exists()) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + self.GROUP_NAME, + 'query_params': {'fields': 'name'}} + self.assertEqual(request, expected_request) + + def test_reload(self): + connection = _Connection(self.JSON_GROUP) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOne(client, group_id=self.GROUP_ID) + group.reload() + + self.assertIs(group.client, client) + self._validateGroup(group, self.JSON_GROUP) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + self.GROUP_NAME} + self.assertEqual(request, expected_request) + + def test_update(self): + REQUEST = self.JSON_GROUP + RESPONSE = REQUEST + + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(REQUEST, client) + group.update() + + self._validateGroup(group, RESPONSE) + + request, = connection._requested + expected_request = {'method': 'PUT', 'path': '/' + self.GROUP_NAME, + 'data': REQUEST} + self.assertEqual(request, expected_request) + + def test_delete(self): + connection = _Connection(self.JSON_GROUP) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + group.delete() + + request, = connection._requested + expected_request = {'method': 'DELETE', 'path': group.path} + self.assertEqual(request, expected_request) + + def test_fetch_parent(self): + connection = _Connection(self.JSON_PARENT) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + + actual_parent = group.fetch_parent() + + self.assertIs(actual_parent.client, client) + self._validateGroup(actual_parent, self.JSON_PARENT) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + self.PARENT_NAME} + self.assertEqual(request, expected_request) + + def test_fetch_parent_empty(self): + connection = _Connection() + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOne(client=client) + actual_parent = group.fetch_parent() + + self.assertIsNone(actual_parent) + self.assertEqual(connection._requested, []) + + def test_list(self): + LIST_OF_GROUPS = [self.JSON_GROUP, self.JSON_PARENT] + RESPONSE = { + 'group': LIST_OF_GROUPS, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + groups = self._getTargetClass()._list(client) + self._validateGroupList(client, groups, LIST_OF_GROUPS) + + request, = connection._requested + expected_request = {'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {}} + self.assertEqual(request, expected_request) + + def test_list_paged(self): + from gcloud.exceptions import NotFound + + LIST_OF_GROUPS = [self.JSON_GROUP, self.JSON_PARENT] + TOKEN = 'second-page-please' + RESPONSE1 = { + 'group': [LIST_OF_GROUPS[0]], + 'nextPageToken': TOKEN, + } + RESPONSE2 = { + 'group': [LIST_OF_GROUPS[1]], + } + + connection = _Connection(RESPONSE1, RESPONSE2) + client = _Client(project=self.PROJECT, connection=connection) + groups = self._getTargetClass()._list(client) + self._validateGroupList(client, groups, LIST_OF_GROUPS) + + request1, request2 = connection._requested + expected_request1 = {'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {}} + expected_request2 = {'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {'pageToken': TOKEN}} + self.assertEqual(request1, expected_request1) + self.assertEqual(request2, expected_request2) + + with self.assertRaises(NotFound): + self._getTargetClass()._list(client) + + def test_list_children(self): + CHILDREN = [self.JSON_GROUP, self.JSON_SIBLING] + RESPONSE = { + 'group': CHILDREN, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + parent_group = self._makeOneFromJSON(self.JSON_PARENT, client) + groups = parent_group.list_children() + self._validateGroupList(client, groups, CHILDREN) + + request, = connection._requested + expected_request = { + 'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {'childrenOfGroup': self.PARENT_NAME} + } + self.assertEqual(request, expected_request) + + def test_list_ancestors(self): + ANCESTORS = [self.JSON_GROUP, self.JSON_PARENT] + RESPONSE = { + 'group': ANCESTORS, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + child_group = self._makeOneFromJSON(self.JSON_CHILD, client) + groups = child_group.list_ancestors() + self._validateGroupList(client, groups, ANCESTORS) + + request, = connection._requested + expected_request = { + 'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {'ancestorsOfGroup': child_group.name} + } + self.assertEqual(request, expected_request) + + def test_list_descendants(self): + DESCENDANTS = [self.JSON_GROUP, self.JSON_SIBLING, self.JSON_CHILD] + RESPONSE = { + 'group': DESCENDANTS, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + parent_group = self._makeOneFromJSON(self.JSON_PARENT, client) + groups = parent_group.list_descendants() + self._validateGroupList(client, groups, DESCENDANTS) + + request, = connection._requested + expected_request = { + 'method': 'GET', 'path': '/' + self.PATH, + 'query_params': {'descendantsOfGroup': self.PARENT_NAME} + } + self.assertEqual(request, expected_request) + + def test_list_members(self): + self._setUpResources() + RESPONSE = { + 'members': self.MEMBERS, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + members = group.list_members() + + self.assertEqual(members, [self.RESOURCE1, self.RESOURCE2]) + + request, = connection._requested + expected_request = { + 'method': 'GET', 'path': '/%s/members' % self.GROUP_NAME, + 'query_params': {}, + } + self.assertEqual(request, expected_request) + + def test_list_members_paged(self): + self._setUpResources() + TOKEN = 'second-page-please' + RESPONSE1 = { + 'members': [self.MEMBERS[0]], + 'nextPageToken': TOKEN, + } + RESPONSE2 = { + 'members': [self.MEMBERS[1]], + } + + connection = _Connection(RESPONSE1, RESPONSE2) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + members = group.list_members() + + self.assertEqual(members, [self.RESOURCE1, self.RESOURCE2]) + + request1, request2 = connection._requested + expected_request1 = { + 'method': 'GET', 'path': '/%s/members' % self.GROUP_NAME, + 'query_params': {}, + } + expected_request2 = { + 'method': 'GET', 'path': '/%s/members' % self.GROUP_NAME, + 'query_params': {'pageToken': TOKEN}, + } + self.assertEqual(request1, expected_request1) + self.assertEqual(request2, expected_request2) + + def test_list_members_w_all_arguments(self): + import datetime + from gcloud._helpers import _datetime_to_rfc3339 + + self._setUpResources() + + T0 = datetime.datetime(2016, 4, 6, 22, 5, 0) + T1 = datetime.datetime(2016, 4, 6, 22, 10, 0) + MEMBER_FILTER = 'resource.zone = "us-central1-a"' + + RESPONSE = { + 'members': self.MEMBERS, + } + connection = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + members = group.list_members( + start_time=T0, end_time=T1, filter_string=MEMBER_FILTER) + + self.assertEqual(members, [self.RESOURCE1, self.RESOURCE2]) + + request, = connection._requested + expected_request = { + 'method': 'GET', 'path': '/%s/members' % self.GROUP_NAME, + 'query_params': { + 'interval.startTime': _datetime_to_rfc3339(T0), + 'interval.endTime': _datetime_to_rfc3339(T1), + 'filter': MEMBER_FILTER, + }, + } + self.assertEqual(request, expected_request) + + def test_list_members_w_missing_end_time(self): + import datetime + + T0 = datetime.datetime(2016, 4, 6, 22, 5, 0) + + connection = _Connection() + client = _Client(project=self.PROJECT, connection=connection) + group = self._makeOneFromJSON(self.JSON_GROUP, client) + with self.assertRaises(ValueError): + group.list_members(start_time=T0) + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = list(responses) + self._requested = [] + + def api_request(self, **kwargs): + from gcloud.exceptions import NotFound + self._requested.append(kwargs) + try: + return self._responses.pop(0) + except IndexError: + raise NotFound('miss') + + +class _Client(object): + + def __init__(self, project, connection=None): + self.project = project + self.connection = connection diff --git a/system_tests/monitoring.py b/system_tests/monitoring.py index d5b0ede2fded..dfbbf6cff2c5 100644 --- a/system_tests/monitoring.py +++ b/system_tests/monitoring.py @@ -175,3 +175,136 @@ def test_create_and_delete_metric_descriptor(self): descriptor.delete() with self.assertRaises(NotFound): descriptor.delete() + + +class TestMonitoringGroups(unittest2.TestCase): + + def setUp(self): + self.to_delete = [] + self.DISPLAY_NAME = 'Testing: New group' + self.FILTER = 'resource.type = "gce_instance"' + self.IS_CLUSTER = True + + def tearDown(self): + for group in self.to_delete: + group.delete() + + def test_create_group(self): + client = monitoring.Client() + group = client.group( + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER, + is_cluster=self.IS_CLUSTER, + ) + group.create() + self.to_delete.append(group) + self.assertTrue(group.exists()) + + def test_list_groups(self): + client = monitoring.Client() + new_group = client.group( + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER, + is_cluster=self.IS_CLUSTER, + ) + before_groups = client.list_groups() + before_names = set(group.name for group in before_groups) + new_group.create() + self.to_delete.append(new_group) + self.assertTrue(new_group.exists()) + after_groups = client.list_groups() + after_names = set(group.name for group in after_groups) + self.assertEqual(after_names - before_names, + set([new_group.name])) + + def test_reload_group(self): + client = monitoring.Client() + group = client.group( + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER, + is_cluster=self.IS_CLUSTER, + ) + group.create() + self.to_delete.append(group) + group.filter = 'resource.type = "aws_ec2_instance"' + group.display_name = 'locally changed name' + group.reload() + self.assertEqual(group.filter, self.FILTER) + self.assertEqual(group.display_name, self.DISPLAY_NAME) + + def test_update_group(self): + NEW_FILTER = 'resource.type = "aws_ec2_instance"' + NEW_DISPLAY_NAME = 'updated' + + client = monitoring.Client() + group = client.group( + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER, + is_cluster=self.IS_CLUSTER, + ) + group.create() + self.to_delete.append(group) + + group.filter = NEW_FILTER + group.display_name = NEW_DISPLAY_NAME + group.update() + + after = client.fetch_group(group.id) + self.assertEqual(after.filter, NEW_FILTER) + self.assertEqual(after.display_name, NEW_DISPLAY_NAME) + + def test_list_group_members(self): + client = monitoring.Client() + group = client.group( + display_name=self.DISPLAY_NAME, + filter_string=self.FILTER, + is_cluster=self.IS_CLUSTER, + ) + group.create() + self.to_delete.append(group) + + for member in group.list_members(): + self.assertIsInstance(member, monitoring.Resource) + + def test_group_hierarchy(self): + client = monitoring.Client() + root_group = client.group( + display_name='Testing: Root group', + filter_string=self.FILTER, + ) + root_group.create() + + middle_group = client.group( + display_name='Testing: Middle group', + filter_string=self.FILTER, + parent_id=root_group.id, + ) + middle_group.create() + + leaf_group = client.group( + display_name='Testing: Leaf group', + filter_string=self.FILTER, + parent_id=middle_group.id, + ) + leaf_group.create() + self.to_delete.extend([leaf_group, middle_group, root_group]) + + # Test for parent. + actual_parent = middle_group.fetch_parent() + self.assertTrue(actual_parent.name, root_group.name) + + # Test for children. + actual_children = middle_group.list_children() + children_names = [group.name for group in actual_children] + self.assertEqual(children_names, [leaf_group.name]) + + # Test for descendants. + actual_descendants = root_group.list_descendants() + descendant_names = {group.name for group in actual_descendants} + self.assertEqual(descendant_names, + set([middle_group.name, leaf_group.name])) + + # Test for ancestors. + actual_ancestors = leaf_group.list_ancestors() + ancestor_names = [group.name for group in actual_ancestors] + self.assertEqual(ancestor_names, [middle_group.name, root_group.name])