diff --git a/docs/index.rst b/docs/index.rst index b370b15bd4cc..5ca3a44eafb5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,6 +49,15 @@ bigquery-job bigquery-table +.. toctree:: + :maxdepth: 0 + :hidden: + :caption: Resource Manager + + Overview + resource-manager-client + resource-manager-project + .. toctree:: :maxdepth: 0 :hidden: diff --git a/docs/resource-manager-api.rst b/docs/resource-manager-api.rst new file mode 100644 index 000000000000..be50d7578ecb --- /dev/null +++ b/docs/resource-manager-api.rst @@ -0,0 +1,86 @@ +Resource Manager Overview +------------------------- + +The Cloud Resource Manager API provides methods that you can use +to programmatically manage your projects in the Google Cloud Platform. +With this API, you can do the following: + +- Get a list of all projects associated with an account +- Create new projects +- Update existing projects +- Delete projects +- Undelete, or recover, projects that you don't want to delete + +.. note:: + + Don't forget to look at the :ref:`Authentication` section below. + It's slightly different from the rest of this library. + +Here's a quick example of the full life-cycle: + +.. code-block:: python + + >>> from gcloud import resource_manager + + >>> # List all projects you have access to + >>> client = resource_manager.Client() + >>> for project in client.list_projects(): + ... print(project) + + >>> # Create a new project + >>> new_project = client.new_project('your-project-id-here', + ... name='My new project) + >>> new_project.create() + + >>> # Update an existing project + >>> project = client.fetch_project('my-existing-project') + >>> print(project) + + >>> project.name = 'Modified name' + >>> project.update() + >>> print(project) + + + >>> # Delete a project + >>> project = client.new_project('my-existing-project') + >>> project.delete() + + >>> # Undelete a project + >>> project = client.new_project('my-existing-project') + >>> project.undelete() + +.. _Authentication: + +Authentication +~~~~~~~~~~~~~~ + +Unlike the other APIs, the Resource Manager API is focused on managing your +various projects inside Google Cloud Platform. What this means (currently, as +of August 2015) is that you can't use a Service Account to work with some +parts of this API (for example, creating projects). + +The reason is actually pretty simple: if your API call is trying to do +something like create a project, what project's Service Account can you use? +Currently none. + +This means that for this API you should always use the credentials +provided by the `Google Cloud SDK`_, which you can get by running +``gcloud auth login``. + +.. _Google Cloud SDK: http://cloud.google.com/sdk + +Once you run that command, ``gcloud-python`` will automatically pick up the +credentials, and you can use the "automatic discovery" feature of the library. + +Start by authenticating: + +.. code-block:: bash + + $ gcloud auth login + +And then simply create a client: + +.. code-block:: python + + >>> from gcloud import resource_manager + >>> client = resource_manager.Client() diff --git a/docs/resource-manager-client.rst b/docs/resource-manager-client.rst new file mode 100644 index 000000000000..eda8e7ac1fb8 --- /dev/null +++ b/docs/resource-manager-client.rst @@ -0,0 +1,19 @@ +.. toctree:: + :maxdepth: 0 + :hidden: + +Client +------ + +.. automodule:: gcloud.resource_manager.client + :members: + :undoc-members: + :show-inheritance: + +Connection +~~~~~~~~~~ + +.. automodule:: gcloud.resource_manager.connection + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/resource-manager-project.rst b/docs/resource-manager-project.rst new file mode 100644 index 000000000000..8b6b93bf133e --- /dev/null +++ b/docs/resource-manager-project.rst @@ -0,0 +1,7 @@ +Projects +~~~~~~~~ + +.. automodule:: gcloud.resource_manager.project + :members: + :undoc-members: + :show-inheritance: diff --git a/gcloud/resource_manager/__init__.py b/gcloud/resource_manager/__init__.py new file mode 100644 index 000000000000..c86c983b7a25 --- /dev/null +++ b/gcloud/resource_manager/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2015 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. + +"""Google Cloud Resource Manager API wrapper.""" + +from gcloud.resource_manager.client import Client +from gcloud.resource_manager.connection import SCOPE +from gcloud.resource_manager.project import Project diff --git a/gcloud/resource_manager/client.py b/gcloud/resource_manager/client.py new file mode 100644 index 000000000000..9362659cbad5 --- /dev/null +++ b/gcloud/resource_manager/client.py @@ -0,0 +1,215 @@ +# Copyright 2015 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. + +"""A Client for interacting with the Resource Manager API.""" + + +from gcloud.client import Client as BaseClient +from gcloud.iterator import Iterator +from gcloud.resource_manager.connection import Connection +from gcloud.resource_manager.project import Project + + +class Client(BaseClient): + """Client to bundle configuration needed for API requests. + + See + https://cloud.google.com/resource-manager/reference/rest/ + for more information on this API. + + Automatically get credentials:: + + >>> from gcloud import resource_manager + >>> client = resource_manager.Client() + + :type credentials: :class:`oauth2client.client.OAuth2Credentials` or + :class:`NoneType` + :param credentials: The OAuth2 Credentials to use for the connection + owned by this client. If not passed (and if no ``http`` + object is passed), falls back to the default inferred + from the environment. + + :type http: :class:`httplib2.Http` or class that defines ``request()``. + :param http: An optional HTTP object to make requests. If not passed, an + ``http`` object is created that is bound to the + ``credentials`` for the current object. + """ + + _connection_class = Connection + + @classmethod + def from_service_account_json(cls, *args, **kwargs): + """Factory to retrieve JSON credentials while creating client. + + The behavior from the parent class is disabled here since the Resource + Manager API can only use credentials from a user account, not a + service account. + + :raises: :class:`NotImplementedError ` + always. + """ + raise NotImplementedError('A service account cannot be used with the ' + 'Resource Manager API. Only user ' + 'credentials can be used.') + + @classmethod + def from_service_account_p12(cls, *args, **kwargs): + """Factory to retrieve P12 credentials while creating client. + + The behavior from the parent class is disabled here since the Resource + Manager API can only use credentials from a user account, not a + service account. + + :raises: :class:`NotImplementedError ` + always. + """ + raise NotImplementedError('A service account cannot be used with the ' + 'Resource Manager API. Only user ' + 'credentials can be used.') + + def new_project(self, project_id, name=None, labels=None): + """Creates a :class:`.Project` bound to the current client. + + Use :meth:`Project.reload() \ + ` to retrieve + project metadata after creating a :class:`.Project` instance. + + .. note: + + This does not make an API call. + + :type project_id: str + :param project_id: The ID for this project. + + :type name: string + :param name: The display name of the project. + + :type labels: dict + :param labels: A list of labels associated with the project. + + :rtype: :class:`.Project` + :returns: A new instance of a :class:`.Project` **without** + any metadata loaded. + """ + return Project(project_id=project_id, + client=self, name=name, labels=labels) + + def fetch_project(self, project_id): + """Fetch an existing project and it's relevant metadata by ID. + + .. note:: + + If the project does not exist, this will raise a + :class:`NotFound ` error. + + :type project_id: str + :param project_id: The ID for this project. + + :rtype: :class:`.Project` + :returns: A :class:`.Project` with metadata fetched from the API. + """ + project = self.new_project(project_id) + project.reload() + return project + + def list_projects(self, filter_params=None, page_size=None): + """List the projects visible to this client. + + Example:: + + >>> from gcloud import resource_manager + >>> client = resource_manager.Client() + >>> for project in client.list_projects(): + ... print project.project_id + + List all projects with label ``'environment'`` set to ``'prod'`` + (filtering by labels):: + + >>> from gcloud import resource_manager + >>> client = resource_manager.Client() + >>> env_filter = {'labels.environment': 'prod'} + >>> for project in client.list_projects(env_filter): + ... print project.project_id + + See: + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/list + + Complete filtering example:: + + >>> project_filter = { # Return projects with... + ... 'name': 'My Project', # name set to 'My Project'. + ... 'id': 'my-project-id', # id set to 'my-project-id'. + ... 'labels.stage': 'prod', # the label 'stage' set to 'prod' + ... 'labels.color': '*' # a label 'color' set to anything. + ... } + >>> client.list_projects(project_filter) + + :type filter_params: dict + :param filter_params: (Optional) A dictionary of filter options where + each key is a property to filter on, and each + value is the (case-insensitive) value to check + (or the glob ``*`` to check for existence of the + property). See the example above for more + details. + + :type page_size: int + :param page_size: (Optional) Maximum number of projects to return in a + single page. If not passed, defaults to a value set + by the API. + + :rtype: :class:`_ProjectIterator` + :returns: A project iterator. The iterator will make multiple API + requests if you continue iterating and there are more + pages of results. Each item returned will be a. + :class:`.Project`. + """ + extra_params = {} + + if page_size is not None: + extra_params['pageSize'] = page_size + + if filter_params is not None: + extra_params['filter'] = filter_params + + return _ProjectIterator(self, extra_params=extra_params) + + +class _ProjectIterator(Iterator): + """An iterator over a list of Project resources. + + You shouldn't have to use this directly, but instead should use the + helper methods on :class:`gcloud.resource_manager.client.Client` + objects. + + :type client: :class:`gcloud.resource_manager.client.Client` + :param client: The client to use for making connections. + + :type extra_params: dict + :param extra_params: (Optional) Extra query string parameters for + the API call. + """ + + def __init__(self, client, extra_params=None): + super(_ProjectIterator, self).__init__(client=client, path='/projects', + extra_params=extra_params) + + def get_items_from_response(self, response): + """Yield :class:`.Project` items from response. + + :type response: dict + :param response: The JSON API response for a page of projects. + """ + for resource in response.get('projects', []): + item = Project.from_api_repr(resource, client=self.client) + yield item diff --git a/gcloud/resource_manager/connection.py b/gcloud/resource_manager/connection.py new file mode 100644 index 000000000000..5f60b2768cba --- /dev/null +++ b/gcloud/resource_manager/connection.py @@ -0,0 +1,47 @@ +# Copyright 2015 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. + +"""Create / interact with gcloud.resource_manager connections.""" + + +from oauth2client.client import AssertionCredentials + +from gcloud import connection as base_connection + + +SCOPE = ('https://www.googleapis.com/auth/cloud-platform',) +"""The scopes required for authenticating as a Resouce Manager consumer.""" + + +class Connection(base_connection.JSONConnection): + """A connection to Google Cloud Resource Manager via the JSON REST API.""" + + API_BASE_URL = 'https://cloudresourcemanager.googleapis.com' + """The base of the API call URL.""" + + API_VERSION = 'v1beta1' + """The version of the API, used in building the API call's URL.""" + + API_URL_TEMPLATE = '{api_base_url}/{api_version}{path}' + """A template for the URL of a particular API call.""" + + def __init__(self, credentials=None, http=None): + if isinstance(credentials, AssertionCredentials): + message = ('credentials (%r) inherits from ' + 'AppAssertionCredentials. Only user credentials can be ' + 'used with the Resource Manager API. ' % (credentials,)) + raise TypeError(message) + + credentials = self._create_scoped_credentials(credentials, SCOPE) + super(Connection, self).__init__(credentials=credentials, http=http) diff --git a/gcloud/resource_manager/project.py b/gcloud/resource_manager/project.py new file mode 100644 index 000000000000..79b9ca80de20 --- /dev/null +++ b/gcloud/resource_manager/project.py @@ -0,0 +1,266 @@ +# Copyright 2015 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. + +"""Utility for managing projects via the Cloud Resource Manager API.""" + + +from gcloud.exceptions import NotFound + + +class Project(object): + """Projects are containers for your work on Google Cloud Platform. + + .. note:: + + A :class:`Project` can also be created via + :meth:`Client.new_project() \ + ` + + To manage labels on a :class:`Project`:: + + >>> from gcloud import resource_manager + >>> client = resource_manager.Client() + >>> project = client.new_project('purple-spaceship-123') + >>> project.labels = {'color': 'purple'} + >>> project.labels['environment'] = 'production' + >>> project.update() + + See: + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects + + :type project_id: string + :param project_id: The globally unique ID of the project. + + :type client: :class:`gcloud.resource_manager.client.Client` + :param client: The Client used with this project. + + :type name: string + :param name: The display name of the project. + + :type labels: dict + :param labels: A list of labels associated with the project. + """ + def __init__(self, project_id, client, name=None, labels=None): + self._client = client + self.project_id = project_id + self.name = name + self.number = None + self.labels = labels or {} + self.status = None + + def __repr__(self): + return '' % (self.name, self.project_id) + + @classmethod + def from_api_repr(cls, resource, client): + """Factory: construct a project given its API representation. + + :type resource: dict + :param resource: project resource representation returned from the API + + :type client: :class:`gcloud.resource_manager.client.Client` + :param client: The Client used with this project. + + :rtype: :class:`gcloud.resource_manager.project.Project` + """ + project = cls(project_id=resource['projectId'], client=client) + project.set_properties_from_api_repr(resource) + return project + + def set_properties_from_api_repr(self, resource): + """Update specific properties from its API representation.""" + self.name = resource.get('name') + self.number = resource['projectNumber'] + self.labels = resource.get('labels', {}) + self.status = resource['lifecycleState'] + + @property + def full_name(self): + """Fully-qualified name (ie, ``'projects/purple-spaceship-123'``).""" + if not self.project_id: + raise ValueError('Missing project ID.') + return 'projects/%s' % (self.project_id) + + @property + def path(self): + """URL for the project (ie, ``'/projects/purple-spaceship-123'``).""" + return '/%s' % (self.full_name) + + def _require_client(self, client): + """Check client or verify over-ride. + + :type client: :class:`gcloud.resource_manager.client.Client` or + ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the current project. + + :rtype: :class:`gcloud.resource_manager.client.Client` + :returns: The client passed in or the currently bound client. + """ + if client is None: + client = self._client + return client + + def create(self, client=None): + """API call: create the project via a ``POST`` request. + + See + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/create + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + """ + client = self._require_client(client) + + data = { + 'projectId': self.project_id, + 'name': self.name, + 'labels': self.labels, + } + resp = client.connection.api_request(method='POST', path='/projects', + data=data) + self.set_properties_from_api_repr(resource=resp) + + def reload(self, client=None): + """API call: reload the project via a ``GET`` request. + + This method will reload the newest metadata for the project. If you've + created a new :class:`Project` instance via + :meth:`Client.new_project() \ + `, + this method will retrieve project metadata. + + .. warning:: + + This will overwrite any local changes you've made and not saved + via :meth:`update`. + + See + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/get + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + """ + client = self._require_client(client) + + # We assume the project exists. If it doesn't it will raise a NotFound + # exception. + resp = client.connection.api_request(method='GET', path=self.path) + self.set_properties_from_api_repr(resource=resp) + + def exists(self, client=None): + """API call: test the existence of a project via a ``GET`` request. + + See + https://cloud.google.com/pubsub/reference/rest/v1beta2/projects/projects/get + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + """ + client = self._require_client(client) + + try: + # Note that we have to request the entire resource as the API + # doesn't provide a way tocheck for existence only. + client.connection.api_request(method='GET', path=self.path) + except NotFound: + return False + else: + return True + + def update(self, client=None): + """API call: update the project via a ``PUT`` request. + + See + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/update + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + """ + client = self._require_client(client) + + data = {'name': self.name, 'labels': self.labels} + resp = client.connection.api_request(method='PUT', path=self.path, + data=data) + self.set_properties_from_api_repr(resp) + + def delete(self, client=None, reload_data=False): + """API call: delete the project via a ``DELETE`` request. + + See: + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/delete + + This actually changes the status (``lifecycleState``) from ``ACTIVE`` + to ``DELETE_REQUESTED``. + Later (it's not specified when), the project will move into the + ``DELETE_IN_PROGRESS`` state, which means the deleting has actually + begun. + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + + :type reload_data: bool + :param reload_data: Whether to reload the project with the latest + state. If you want to get the updated status, + you'll want this set to :data:`True` as the DELETE + method doesn't send back the updated project. + Default: :data:`False`. + """ + client = self._require_client(client) + client.connection.api_request(method='DELETE', path=self.path) + + # If the reload flag is set, reload the project. + if reload_data: + self.reload() + + def undelete(self, client=None, reload_data=False): + """API call: undelete the project via a ``POST`` request. + + See + https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects/undelete + + This actually changes the project status (``lifecycleState``) from + ``DELETE_REQUESTED`` to ``ACTIVE``. + If the project has already reached a status of ``DELETE_IN_PROGRESS``, + this request will fail and the project cannot be restored. + + :type client: :class:`gcloud.resource_manager.client.Client` or + :data:`NoneType ` + :param client: the client to use. If not passed, falls back to + the client stored on the current project. + + :type reload_data: bool + :param reload_data: Whether to reload the project with the latest + state. If you want to get the updated status, + you'll want this set to :data:`True` as the DELETE + method doesn't send back the updated project. + Default: :data:`False`. + """ + client = self._require_client(client) + client.connection.api_request(method='POST', + path=self.path + ':undelete') + + # If the reload flag is set, reload the project. + if reload_data: + self.reload() diff --git a/gcloud/resource_manager/test_client.py b/gcloud/resource_manager/test_client.py new file mode 100644 index 000000000000..2ad2873bf33e --- /dev/null +++ b/gcloud/resource_manager/test_client.py @@ -0,0 +1,306 @@ +# Copyright 2015 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__ProjectIterator(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.resource_manager.client import _ProjectIterator + return _ProjectIterator + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_constructor(self): + client = object() + iterator = self._makeOne(client) + self.assertEqual(iterator.path, '/projects') + self.assertEqual(iterator.page_number, 0) + self.assertEqual(iterator.next_page_token, None) + self.assertTrue(iterator.client is client) + self.assertEqual(iterator.extra_params, {}) + + def test_get_items_from_response_empty(self): + client = object() + iterator = self._makeOne(client) + self.assertEqual(list(iterator.get_items_from_response({})), []) + + def test_get_items_from_response_non_empty(self): + from gcloud.resource_manager.project import Project + + PROJECT_ID = 'project-id' + PROJECT_NAME = 'My Project Name' + PROJECT_NUMBER = 12345678 + PROJECT_LABELS = {'env': 'prod'} + PROJECT_LIFECYCLE_STATE = 'ACTIVE' + API_RESOURCE = { + 'projectId': PROJECT_ID, + 'name': PROJECT_NAME, + 'projectNumber': PROJECT_NUMBER, + 'labels': PROJECT_LABELS, + 'lifecycleState': PROJECT_LIFECYCLE_STATE, + } + RESPONSE = {'projects': [API_RESOURCE]} + + client = object() + iterator = self._makeOne(client) + projects = list(iterator.get_items_from_response(RESPONSE)) + + project, = projects + self.assertTrue(isinstance(project, Project)) + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project._client, client) + self.assertEqual(project.name, PROJECT_NAME) + self.assertEqual(project.number, PROJECT_NUMBER) + self.assertEqual(project.labels, PROJECT_LABELS) + self.assertEqual(project.status, PROJECT_LIFECYCLE_STATE) + + +class TestClient(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.resource_manager.client import Client + return Client + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_constructor(self): + from gcloud.resource_manager.connection import Connection + + http = object() + credentials = _Credentials() + client = self._makeOne(credentials=credentials, http=http) + self.assertTrue(isinstance(client.connection, Connection)) + self.assertEqual(client.connection._credentials, credentials) + self.assertEqual(client.connection._http, http) + + def test_from_service_account_json_factory(self): + klass = self._getTargetClass() + with self.assertRaises(NotImplementedError): + klass.from_service_account_json() + + def test_from_service_account_p12_factory(self): + klass = self._getTargetClass() + with self.assertRaises(NotImplementedError): + klass.from_service_account_p12() + + def test_new_project_factory(self): + from gcloud.resource_manager.project import Project + + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + project_id = 'project_id' + name = object() + labels = object() + project = client.new_project(project_id, name=name, labels=labels) + + self.assertTrue(isinstance(project, Project)) + self.assertEqual(project._client, client) + self.assertEqual(project.project_id, project_id) + self.assertEqual(project.name, name) + self.assertEqual(project.labels, labels) + + def test_fetch_project(self): + from gcloud.resource_manager.project import Project + + project_id = 'project-id' + project_number = 123 + project_name = 'Project Name' + labels = {'env': 'prod'} + project_resource = { + 'projectId': project_id, + 'projectNumber': project_number, + 'name': project_name, + 'labels': labels, + 'lifecycleState': 'ACTIVE', + } + + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + # Patch the connection with one we can easily control. + client.connection = _Connection(project_resource) + + project = client.fetch_project(project_id) + self.assertTrue(isinstance(project, Project)) + self.assertEqual(project._client, client) + self.assertEqual(project.project_id, project_id) + self.assertEqual(project.name, project_name) + self.assertEqual(project.labels, labels) + + def test_list_projects_return_type(self): + from gcloud.resource_manager.client import _ProjectIterator + + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + # Patch the connection with one we can easily control. + client.connection = _Connection({}) + + results = client.list_projects() + self.assertIsInstance(results, _ProjectIterator) + + def test_list_projects_no_paging(self): + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 1 + STATUS = 'ACTIVE' + PROJECTS_RESOURCE = { + 'projects': [ + { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'lifecycleState': STATUS, + }, + ], + } + # Patch the connection with one we can easily control. + client.connection = _Connection(PROJECTS_RESOURCE) + # Make sure there will be no paging. + self.assertFalse('nextPageToken' in PROJECTS_RESOURCE) + + results = list(client.list_projects()) + + project, = results + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project.number, PROJECT_NUMBER) + self.assertEqual(project.status, STATUS) + + def test_list_projects_with_paging(self): + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + + PROJECT_ID1 = 'project-id' + PROJECT_NUMBER1 = 1 + STATUS = 'ACTIVE' + TOKEN = 'next-page-token' + FIRST_PROJECTS_RESOURCE = { + 'projects': [ + { + 'projectId': PROJECT_ID1, + 'projectNumber': PROJECT_NUMBER1, + 'lifecycleState': STATUS, + }, + ], + 'nextPageToken': TOKEN, + } + PROJECT_ID2 = 'project-id-2' + PROJECT_NUMBER2 = 42 + SECOND_PROJECTS_RESOURCE = { + 'projects': [ + { + 'projectId': PROJECT_ID2, + 'projectNumber': PROJECT_NUMBER2, + 'lifecycleState': STATUS, + }, + ], + } + # Patch the connection with one we can easily control. + client.connection = _Connection(FIRST_PROJECTS_RESOURCE, + SECOND_PROJECTS_RESOURCE) + + # Page size = 1 with two response means we'll have two requests. + results = list(client.list_projects(page_size=1)) + + # Check that the results are as expected. + project1, project2 = results + self.assertEqual(project1.project_id, PROJECT_ID1) + self.assertEqual(project1.number, PROJECT_NUMBER1) + self.assertEqual(project1.status, STATUS) + self.assertEqual(project2.project_id, PROJECT_ID2) + self.assertEqual(project2.number, PROJECT_NUMBER2) + self.assertEqual(project2.status, STATUS) + + # Check that two requests were required since page_size=1. + request1, request2 = client.connection._requested + self.assertEqual(request1, { + 'path': '/projects', + 'method': 'GET', + 'query_params': { + 'pageSize': 1, + }, + }) + self.assertEqual(request2, { + 'path': '/projects', + 'method': 'GET', + 'query_params': { + 'pageSize': 1, + 'pageToken': TOKEN, + }, + }) + + def test_list_projects_with_filter(self): + credentials = _Credentials() + client = self._makeOne(credentials=credentials) + + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 1 + STATUS = 'ACTIVE' + PROJECTS_RESOURCE = { + 'projects': [ + { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'lifecycleState': STATUS, + }, + ], + } + # Patch the connection with one we can easily control. + client.connection = _Connection(PROJECTS_RESOURCE) + + FILTER_PARAMS = {'id': 'project-id'} + results = list(client.list_projects(filter_params=FILTER_PARAMS)) + + project, = results + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project.number, PROJECT_NUMBER) + self.assertEqual(project.status, STATUS) + + # Check that the filter made it in the request. + request, = client.connection._requested + self.assertEqual(request, { + 'path': '/projects', + 'method': 'GET', + 'query_params': { + 'filter': FILTER_PARAMS, + }, + }) + + +class _Credentials(object): + + _scopes = None + + @staticmethod + def create_scoped_required(): + return True + + def create_scoped(self, scope): + self._scopes = scope + return self + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + self._requested.append(kw) + response, self._responses = self._responses[0], self._responses[1:] + return response diff --git a/gcloud/resource_manager/test_connection.py b/gcloud/resource_manager/test_connection.py new file mode 100644 index 000000000000..de47592f990c --- /dev/null +++ b/gcloud/resource_manager/test_connection.py @@ -0,0 +1,53 @@ +# Copyright 2015 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 TestConnection(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.resource_manager.connection import Connection + return Connection + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_constructor_with_assertion(self): + from oauth2client.client import AssertionCredentials + + credentials = AssertionCredentials(None) + with self.assertRaises(TypeError): + self._makeOne(credentials=credentials) + + def test_build_api_url_no_extra_query_params(self): + conn = self._makeOne() + URI = '/'.join([ + conn.API_BASE_URL, + conn.API_VERSION, + 'foo', + ]) + self.assertEqual(conn.build_api_url('/foo'), URI) + + def test_build_api_url_w_extra_query_params(self): + from six.moves.urllib.parse import parse_qsl + from six.moves.urllib.parse import urlsplit + conn = self._makeOne() + uri = conn.build_api_url('/foo', {'bar': 'baz'}) + scheme, netloc, path, qs, _ = urlsplit(uri) + self.assertEqual('%s://%s' % (scheme, netloc), conn.API_BASE_URL) + self.assertEqual(path, + '/'.join(['', conn.API_VERSION, 'foo'])) + parms = dict(parse_qsl(qs)) + self.assertEqual(parms['bar'], 'baz') diff --git a/gcloud/resource_manager/test_project.py b/gcloud/resource_manager/test_project.py new file mode 100644 index 000000000000..2173b55edcae --- /dev/null +++ b/gcloud/resource_manager/test_project.py @@ -0,0 +1,340 @@ +# Copyright 2015 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 TestProject(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.resource_manager.project import Project + return Project + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_constructor_defaults(self): + client = object() + PROJECT_ID = 'project-id' + project = self._makeOne(PROJECT_ID, client) + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project._client, client) + self.assertEqual(project.name, None) + self.assertEqual(project.number, None) + self.assertEqual(project.labels, {}) + self.assertEqual(project.status, None) + + def test_constructor_explicit(self): + client = object() + PROJECT_ID = 'project-id' + DISPLAY_NAME = 'name' + LABELS = {'foo': 'bar'} + project = self._makeOne(PROJECT_ID, client, + name=DISPLAY_NAME, labels=LABELS) + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project._client, client) + self.assertEqual(project.name, DISPLAY_NAME) + self.assertEqual(project.number, None) + self.assertEqual(project.labels, LABELS) + self.assertEqual(project.status, None) + + def test_from_api_repr(self): + client = object() + PROJECT_ID = 'project-id' + PROJECT_NAME = 'My Project Name' + PROJECT_NUMBER = 12345678 + PROJECT_LABELS = {'env': 'prod'} + PROJECT_LIFECYCLE_STATE = 'ACTIVE' + resource = {'projectId': PROJECT_ID, + 'name': PROJECT_NAME, + 'projectNumber': PROJECT_NUMBER, + 'labels': PROJECT_LABELS, + 'lifecycleState': PROJECT_LIFECYCLE_STATE} + project = self._getTargetClass().from_api_repr(resource, client) + self.assertEqual(project.project_id, PROJECT_ID) + self.assertEqual(project._client, client) + self.assertEqual(project.name, PROJECT_NAME) + self.assertEqual(project.number, PROJECT_NUMBER) + self.assertEqual(project.labels, PROJECT_LABELS) + self.assertEqual(project.status, PROJECT_LIFECYCLE_STATE) + + def test_full_name(self): + PROJECT_ID = 'project-id' + project = self._makeOne(PROJECT_ID, None) + self.assertEqual('projects/%s' % PROJECT_ID, project.full_name) + + def test_full_name_missing_id(self): + project = self._makeOne(None, None) + with self.assertRaises(ValueError): + self.assertIsNone(project.full_name) + + def test_path(self): + PROJECT_ID = 'project-id' + project = self._makeOne(PROJECT_ID, None) + self.assertEqual('/projects/%s' % PROJECT_ID, project.path) + + def test_create(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {}, + 'lifecycleState': 'ACTIVE', + } + connection = _Connection(PROJECT_RESOURCE) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + self.assertEqual(project.number, None) + project.create() + self.assertEqual(project.number, PROJECT_NUMBER) + request, = connection._requested + + expected_request = { + 'method': 'POST', + 'data': { + 'projectId': PROJECT_ID, + 'labels': {}, + 'name': None, + }, + 'path': '/projects', + } + self.assertEqual(request, expected_request) + + def test_reload(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {'env': 'prod'}, + 'lifecycleState': 'ACTIVE', + } + connection = _Connection(PROJECT_RESOURCE) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + self.assertEqual(project.number, None) + self.assertEqual(project.name, None) + self.assertEqual(project.labels, {}) + self.assertEqual(project.status, None) + project.reload() + self.assertEqual(project.name, PROJECT_RESOURCE['name']) + self.assertEqual(project.number, PROJECT_NUMBER) + self.assertEqual(project.labels, PROJECT_RESOURCE['labels']) + self.assertEqual(project.status, PROJECT_RESOURCE['lifecycleState']) + + request, = connection._requested + # NOTE: data is not in the request since a GET request. + expected_request = { + 'method': 'GET', + 'path': project.path, + } + self.assertEqual(request, expected_request) + + def test_exists(self): + PROJECT_ID = 'project-id' + connection = _Connection({'projectId': PROJECT_ID}) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + self.assertTrue(project.exists()) + + def test_exists_with_explicitly_passed_client(self): + PROJECT_ID = 'project-id' + connection = _Connection({'projectId': PROJECT_ID}) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, None) + self.assertTrue(project.exists(client=client)) + + def test_exists_with_missing_client(self): + PROJECT_ID = 'project-id' + project = self._makeOne(PROJECT_ID, None) + with self.assertRaises(AttributeError): + project.exists() + + def test_exists_not_found(self): + PROJECT_ID = 'project-id' + connection = _Connection() + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + self.assertFalse(project.exists()) + + def test_update(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_NAME = 'Project Name' + LABELS = {'env': 'prod'} + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': PROJECT_NAME, + 'labels': LABELS, + 'lifecycleState': 'ACTIVE', + } + connection = _Connection(PROJECT_RESOURCE) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + project.name = PROJECT_NAME + project.labels = LABELS + project.update() + + request, = connection._requested + expected_request = { + 'method': 'PUT', + 'data': { + 'name': PROJECT_NAME, + 'labels': LABELS, + }, + 'path': project.path, + } + self.assertEqual(request, expected_request) + + def test_delete_without_reload_data(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {'env': 'prod'}, + 'lifecycleState': 'ACTIVE', + } + connection = _Connection(PROJECT_RESOURCE) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + project.delete(reload_data=False) + + request, = connection._requested + # NOTE: data is not in the request since a DELETE request. + expected_request = { + 'method': 'DELETE', + 'path': project.path, + } + self.assertEqual(request, expected_request) + + def test_delete_with_reload_data(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {'env': 'prod'}, + 'lifecycleState': 'ACTIVE', + } + DELETING_PROJECT = PROJECT_RESOURCE.copy() + DELETING_PROJECT['lifecycleState'] = NEW_STATE = 'DELETE_REQUESTED' + + connection = _Connection(PROJECT_RESOURCE, DELETING_PROJECT) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + project.delete(reload_data=True) + self.assertEqual(project.status, NEW_STATE) + + delete_request, get_request = connection._requested + # NOTE: data is not in the request since a DELETE request. + expected_delete_request = { + 'method': 'DELETE', + 'path': project.path, + } + self.assertEqual(delete_request, expected_delete_request) + + # NOTE: data is not in the request since a GET request. + expected_get_request = { + 'method': 'GET', + 'path': project.path, + } + self.assertEqual(get_request, expected_get_request) + + def test_undelete_without_reload_data(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {'env': 'prod'}, + 'lifecycleState': 'DELETE_REQUESTED', + } + connection = _Connection(PROJECT_RESOURCE) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + project.undelete(reload_data=False) + + request, = connection._requested + # NOTE: data is not in the request, undelete doesn't need it. + expected_request = { + 'method': 'POST', + 'path': project.path + ':undelete', + } + self.assertEqual(request, expected_request) + + def test_undelete_with_reload_data(self): + PROJECT_ID = 'project-id' + PROJECT_NUMBER = 123 + PROJECT_RESOURCE = { + 'projectId': PROJECT_ID, + 'projectNumber': PROJECT_NUMBER, + 'name': 'Project Name', + 'labels': {'env': 'prod'}, + 'lifecycleState': 'DELETE_REQUESTED', + } + UNDELETED_PROJECT = PROJECT_RESOURCE.copy() + UNDELETED_PROJECT['lifecycleState'] = NEW_STATE = 'ACTIVE' + + connection = _Connection(PROJECT_RESOURCE, UNDELETED_PROJECT) + client = _Client(connection=connection) + project = self._makeOne(PROJECT_ID, client) + project.undelete(reload_data=True) + self.assertEqual(project.status, NEW_STATE) + + undelete_request, get_request = connection._requested + # NOTE: data is not in the request, undelete doesn't need it. + expected_undelete_request = { + 'method': 'POST', + 'path': project.path + ':undelete', + } + self.assertEqual(undelete_request, expected_undelete_request) + + # NOTE: data is not in the request since a GET request. + expected_get_request = { + 'method': 'GET', + 'path': project.path, + } + self.assertEqual(get_request, expected_get_request) + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + from gcloud.exceptions import NotFound + self._requested.append(kw) + + try: + response, self._responses = self._responses[0], self._responses[1:] + except: + raise NotFound('miss') + else: + return response + + +class _Client(object): + + def __init__(self, connection=None): + self.connection = connection