| # -*- coding: utf-8 -*- |
| # Copyright 2015 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """A convinient wrapper of the GCE python API. |
| |
| Public methods in class GceContext raise HttpError when the underlining call to |
| Google API fails, or gce.Error on other failures. |
| """ |
| |
| from __future__ import print_function |
| |
| from googleapiclient.discovery import build |
| from googleapiclient.errors import HttpError |
| from googleapiclient.http import HttpRequest |
| import httplib2 |
| from oauth2client.client import GoogleCredentials |
| |
| from chromite.lib import cros_logging as logging |
| from chromite.lib import timeout_util |
| |
| |
| class Error(Exception): |
| """Base exception for this module.""" |
| |
| |
| class ResourceNotFoundError(Error): |
| """Exceptions raised when requested GCE resource was not found.""" |
| |
| |
| class RetryOnServerErrorHttpRequest(HttpRequest): |
| """A HttpRequest that will be retried on server errors automatically.""" |
| |
| def __init__(self, num_retries, *args, **kwargs): |
| """Constructor for RetryOnServerErrorHttpRequest.""" |
| self.num_retries = num_retries |
| super(RetryOnServerErrorHttpRequest, self).__init__(*args, **kwargs) |
| |
| def execute(self, http=None, num_retries=None): |
| """Excutes a RetryOnServerErrorHttpRequest. |
| |
| HttpRequest.execute() has the option of automatically retrying on server |
| errors, i.e., 500 status codes. Call it with a non-zero value of |
| |num_retries| will cause failed requests to be retried. |
| |
| Args: |
| http: The httplib2.http to send this request through. |
| num_retries: Number of retries. Class default value will be used if |
| omitted. |
| |
| Returns: |
| A deserialized object model of the response body as determined |
| by the postproc. See HttpRequest.execute(). |
| """ |
| return super(RetryOnServerErrorHttpRequest, self).execute( |
| http=http, num_retries=num_retries or self.num_retries) |
| |
| |
| def _GetMetdataValue(metadata, key): |
| """Finds a value corresponding to a given metadata key. |
| |
| Args: |
| metadata: metadata object, i.e. a dict containing containing 'items' |
| - a list of key-value pairs. |
| key: name of the key. |
| |
| Returns: |
| Corresponding value or None if it was not found. |
| """ |
| for item in metadata['items']: |
| if item['key'] == key: |
| return item['value'] |
| return None |
| |
| |
| def _UpdateMetadataValue(metadata, key, value): |
| """Updates a single key-value pair in a metadata object. |
| |
| Args: |
| metadata: metadata object, i.e. a dict containing containing 'items' |
| - a list of key-value pairs. |
| key: name of the key. |
| value: new value for the key, or None if it should be removed. |
| """ |
| items = metadata.setdefault('items', []) |
| for item in items: |
| if item['key'] == key: |
| if value is None: |
| items.remove(item) |
| else: |
| item['value'] = value |
| return |
| |
| if value is not None: |
| items.append({ |
| 'key': key, |
| 'value': value, |
| }) |
| |
| |
| class GceContext(object): |
| """A convinient wrapper around the GCE Python API.""" |
| |
| # These constants are made public so that users can customize as they need. |
| DEFAULT_TIMEOUT_SEC = 5 * 60 |
| INSTANCE_OPERATIONS_TIMEOUT_SEC = 10 * 60 |
| IMAGE_OPERATIONS_TIMEOUT_SEC = 10 * 60 |
| |
| _GCE_SCOPES = ( |
| 'https://www.googleapis.com/auth/compute', # CreateInstance, CreateImage |
| 'https://www.googleapis.com/auth/devstorage.full_control', # CreateImage |
| ) |
| _DEFAULT_NETWORK = 'default' |
| _DEFAULT_MACHINE_TYPE = 'n1-standard-8' |
| |
| # Project default service account and scopes. |
| _DEFAULT_SERVICE_ACCOUNT_EMAIL = 'default' |
| # The list is in line with what the gcloud cli uses. |
| # https://cloud.google.com/sdk/gcloud/reference/compute/instances/create |
| _DEFAULT_INSTANCE_SCOPES = [ |
| 'https://www.googleapis.com/auth/cloud.useraccounts.readonly', |
| 'https://www.googleapis.com/auth/devstorage.read_only', |
| 'https://www.googleapis.com/auth/logging.write', |
| ] |
| |
| # This is made public to allow easy customization of the retry behavior. |
| RETRIES = 2 |
| |
| def __init__(self, project, zone, credentials, thread_safe=False): |
| """Initializes GceContext. |
| |
| Args: |
| project: The GCP project to create instances in. |
| zone: The default zone to create instances in. |
| credentials: The credentials used to call the GCE API. |
| thread_safe: Whether the client is expected to be thread safe. |
| """ |
| self.project = project |
| self.zone = zone |
| |
| def _BuildRequest(http, *args, **kwargs): |
| """Custom request builder.""" |
| return self._BuildRetriableRequest(self.RETRIES, http, thread_safe, |
| credentials, *args, **kwargs) |
| |
| self.gce_client = build('compute', 'v1', credentials=credentials, |
| requestBuilder=_BuildRequest) |
| |
| self.region = self.GetZoneRegion(zone) |
| |
| @classmethod |
| def ForServiceAccount(cls, project, zone, json_key_file): |
| """Creates a GceContext using service account credentials. |
| |
| About service account: |
| https://developers.google.com/api-client-library/python/auth/service-accounts |
| |
| Args: |
| project: The GCP project to create images and instances in. |
| zone: The default zone to create instances in. |
| json_key_file: Path to the service account JSON key. |
| |
| Returns: |
| GceContext. |
| """ |
| credentials = GoogleCredentials.from_stream(json_key_file).create_scoped( |
| cls._GCE_SCOPES) |
| return GceContext(project, zone, credentials) |
| |
| @classmethod |
| def ForServiceAccountThreadSafe(cls, project, zone, json_key_file): |
| """Creates a thread-safe GceContext using service account credentials. |
| |
| About service account: |
| https://developers.google.com/api-client-library/python/auth/service-accounts |
| |
| Args: |
| project: The GCP project to create images and instances in. |
| zone: The default zone to create instances in. |
| json_key_file: Path to the service account JSON key. |
| |
| Returns: |
| GceContext. |
| """ |
| credentials = GoogleCredentials.from_stream(json_key_file).create_scoped( |
| cls._GCE_SCOPES) |
| return GceContext(project, zone, credentials, thread_safe=True) |
| |
| def CreateAddress(self, name, region=None): |
| """Reserves an external IP address. |
| |
| Args: |
| name: The name to assign to the address. |
| region: Region to reserved the address in. |
| |
| Returns: |
| The reserved address as a string. |
| """ |
| body = { |
| 'name': name, |
| } |
| operation = self.gce_client.addresses().insert( |
| project=self.project, |
| region=region or self.region, |
| body=body).execute() |
| self._WaitForRegionOperation( |
| operation['name'], region, |
| timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC) |
| |
| address = self.gce_client.addresses().get( |
| project=self.project, |
| region=region or self.region, |
| address=name).execute() |
| |
| return address['address'] |
| |
| def DeleteAddress(self, name, region=None): |
| """Frees up an external IP address. |
| |
| Args: |
| name: The name of the address. |
| region: Region of the address. |
| """ |
| operation = self.gce_client.addresses().delete( |
| project=self.project, |
| region=region or self.region, |
| address=name).execute() |
| self._WaitForRegionOperation( |
| operation['name'], region=region or self.region, |
| timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC) |
| |
| def GetZoneRegion(self, zone=None): |
| """Resolves name of the region that a zone belongs to. |
| |
| Args: |
| zone: The zone to resolve. |
| |
| Returns: |
| Name of the region corresponding to the zone. |
| """ |
| zone_resource = self.gce_client.zones().get( |
| project=self.project, |
| zone=zone or self.zone).execute() |
| return zone_resource['region'].split('/')[-1] |
| |
| def CreateInstance(self, name, image, zone=None, network=None, subnet=None, |
| machine_type=None, default_scopes=True, |
| static_address=None, **kwargs): |
| """Creates an instance with the given image and waits until it's ready. |
| |
| Args: |
| name: Instance name. |
| image: Fully spelled URL of the image, e.g., for private images, |
| 'global/images/my-private-image', or for images from a |
| publicly-available project, |
| 'projects/debian-cloud/global/images/debian-7-wheezy-vYYYYMMDD'. |
| Details: |
| https://cloud.google.com/compute/docs/reference/latest/instances/insert |
| zone: The zone to create the instance in. Default zone will be used if |
| omitted. |
| network: An existing network to create the instance in. Default network |
| will be used if omitted. |
| subnet: The subnet to create the instance in. |
| machine_type: The machine type to use. Default machine type will be used |
| if omitted. |
| default_scopes: If true, the default scopes are added to the instances. |
| static_address: External IP address to assign to the instance as a string. |
| If None an emphemeral address will be used. |
| kwargs: Other possible Instance Resource properties. |
| https://cloud.google.com/compute/docs/reference/latest/instances#resource |
| Note that values from kwargs will overrule properties constructed from |
| positinal arguments, i.e., name, image, zone, network and |
| machine_type. |
| |
| Returns: |
| URL to the created instance. |
| """ |
| logging.info('Creating instance "%s" with image "%s" ...', name, image) |
| network = 'global/networks/%s' % network or self._DEFAULT_NETWORK |
| machine_type = 'zones/%s/machineTypes/%s' % ( |
| zone or self.zone, machine_type or self._DEFAULT_MACHINE_TYPE) |
| service_accounts = ( |
| { |
| 'email': self._DEFAULT_SERVICE_ACCOUNT_EMAIL, |
| 'scopes': self._DEFAULT_INSTANCE_SCOPES, |
| }, |
| ) if default_scopes else () |
| |
| config = { |
| 'name': name, |
| 'machineType': machine_type, |
| 'disks': ( |
| { |
| 'boot': True, |
| 'autoDelete': True, |
| 'initializeParams': { |
| 'sourceImage': image, |
| }, |
| }, |
| ), |
| 'networkInterfaces': ( |
| { |
| 'network': network, |
| 'accessConfigs': ( |
| { |
| 'type': 'ONE_TO_ONE_NAT', |
| 'name': 'External NAT', |
| }, |
| ), |
| }, |
| ), |
| 'serviceAccounts' : service_accounts, |
| } |
| config.update(**kwargs) |
| if static_address is not None: |
| config['networkInterfaces'][0]['accessConfigs'][0]['natIP'] = ( |
| static_address) |
| if subnet is not None: |
| region = self.GetZoneRegion(zone) |
| config['networkInterfaces'][0]['subnetwork'] = ( |
| 'regions/%s/subnetworks/%s' % (region, subnet) |
| ) |
| operation = self.gce_client.instances().insert( |
| project=self.project, |
| zone=zone or self.zone, |
| body=config).execute() |
| self._WaitForZoneOperation( |
| operation['name'], |
| timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC, |
| timeout_handler=lambda: self.DeleteInstance(name)) |
| return operation['targetLink'] |
| |
| def DeleteInstance(self, name, zone=None): |
| """Deletes an instance with the name and waits until it's done. |
| |
| Args: |
| name: Name of the instance to delete. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| """ |
| logging.info('Deleting instance "%s" ...', name) |
| operation = self.gce_client.instances().delete( |
| project=self.project, |
| zone=zone or self.zone, |
| instance=name).execute() |
| self._WaitForZoneOperation( |
| operation['name'], timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC) |
| |
| def StartInstance(self, name, zone=None): |
| """Starts an instance with the name and waits until it's done. |
| |
| Args: |
| name: Name of the instance to start. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| """ |
| logging.info('Starting instance "%s" ...', name) |
| operation = self.gce_client.instances().start( |
| project=self.project, |
| zone=zone or self.zone, |
| instance=name).execute() |
| self._WaitForZoneOperation( |
| operation['name'], timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC) |
| |
| def StopInstance(self, name, zone=None): |
| """Stops an instance with the name and waits until it's done. |
| |
| Args: |
| name: Name of the instance to stop. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| """ |
| logging.info('Stopping instance "%s" ...', name) |
| operation = self.gce_client.instances().stop( |
| project=self.project, |
| zone=zone or self.zone, |
| instance=name).execute() |
| self._WaitForZoneOperation( |
| operation['name'], timeout_sec=self.INSTANCE_OPERATIONS_TIMEOUT_SEC) |
| |
| def CreateImage(self, name, source): |
| """Creates an image with the given |source|. |
| |
| Args: |
| name: Name of the image to be created. |
| source: |
| Google Cloud Storage object of the source disk, e.g., |
| 'https://storage.googleapis.com/my-gcs-bucket/test_image.tar.gz'. |
| |
| Returns: |
| URL to the created image. |
| """ |
| logging.info('Creating image "%s" with "source" %s ...', name, source) |
| config = { |
| 'name': name, |
| 'rawDisk': { |
| 'source': source, |
| }, |
| } |
| operation = self.gce_client.images().insert( |
| project=self.project, |
| body=config).execute() |
| self._WaitForGlobalOperation(operation['name'], |
| timeout_sec=self.IMAGE_OPERATIONS_TIMEOUT_SEC, |
| timeout_handler=lambda: self.DeleteImage(name)) |
| return operation['targetLink'] |
| |
| def DeleteImage(self, name): |
| """Deletes an image and waits until it's deleted. |
| |
| Args: |
| name: Name of the image to delete. |
| """ |
| logging.info('Deleting image "%s" ...', name) |
| operation = self.gce_client.images().delete( |
| project=self.project, |
| image=name).execute() |
| self._WaitForGlobalOperation(operation['name'], |
| timeout_sec=self.IMAGE_OPERATIONS_TIMEOUT_SEC) |
| |
| def ListInstances(self, zone=None): |
| """Lists all instances. |
| |
| Args: |
| zone: Zone where the instances are in. Default zone will be used if |
| omitted. |
| |
| Returns: |
| A list of Instance Resources if found, or an empty list otherwise. |
| """ |
| result = self.gce_client.instances().list(project=self.project, |
| zone=zone or self.zone).execute() |
| return result.get('items', []) |
| |
| def ListImages(self): |
| """Lists all images. |
| |
| Returns: |
| A list of Image Resources if found, or an empty list otherwise. |
| """ |
| result = self.gce_client.images().list(project=self.project).execute() |
| return result.get('items', []) |
| |
| def GetInstance(self, instance, zone=None): |
| """Gets an Instance Resource by name and zone. |
| |
| Args: |
| instance: Name of the instance. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| |
| Returns: |
| An Instance Resource. |
| |
| Raises: |
| ResourceNotFoundError if instance was not found, or HttpError on other |
| HTTP failures. |
| """ |
| try: |
| return self.gce_client.instances().get(project=self.project, |
| zone=zone or self.zone, |
| instance=instance).execute() |
| except HttpError as e: |
| if e.resp.status == 404: |
| raise ResourceNotFoundError( |
| 'Instance "%s" for project "%s" in zone "%s" was not found.' % |
| (instance, self.project, zone or self.zone)) |
| else: |
| raise |
| |
| def GetInstanceIP(self, instance, zone=None): |
| """Gets the external IP of an instance. |
| |
| Args: |
| instance: Name of the instance to get IP for. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| |
| Returns: |
| External IP address of the instance. |
| |
| Raises: |
| Error: Something went wrong when trying to get IP for the instance. |
| """ |
| result = self.GetInstance(instance, zone) |
| try: |
| return result['networkInterfaces'][0]['accessConfigs'][0]['natIP'] |
| except (KeyError, IndexError): |
| raise Error('Failed to get IP address for instance %s' % instance) |
| |
| def GetInstanceInternalIP(self, instance, zone=None): |
| """Gets the internal IP of an instance.""" |
| result = self.GetInstance(instance, zone) |
| try: |
| return result['networkInterfaces'][0]['networkIP'] |
| except (KeyError, IndexError): |
| raise Error('Failed to get internal IP for instance %s' % instance) |
| |
| def GetImage(self, image): |
| """Gets an Image Resource by name. |
| |
| Args: |
| image: Name of the image to look for. |
| |
| Returns: |
| An Image Resource. |
| |
| Raises: |
| ResourceNotFoundError: The requested image was not found. |
| """ |
| try: |
| return self.gce_client.images().get(project=self.project, |
| image=image).execute() |
| except HttpError as e: |
| if e.resp.status == 404: |
| raise ResourceNotFoundError('Image "%s" for project "%s" was not found.' |
| % (image, self.project)) |
| else: |
| raise |
| |
| def InstanceExists(self, instance, zone=None): |
| """Checks if an instance exists in the current project. |
| |
| Args: |
| instance: Name of the instance to check existence of. |
| zone: Zone where the instance is in. Default zone will be used if omitted. |
| |
| Returns: |
| True if the instance exists or False otherwise. |
| """ |
| try: |
| return self.GetInstance(instance, zone) is not None |
| except ResourceNotFoundError: |
| return False |
| |
| def ImageExists(self, image): |
| """Checks if an image exists in the current project. |
| |
| Args: |
| image: Name of the image to check existence of. |
| |
| Returns: |
| True if the instance exists or False otherwise. |
| """ |
| try: |
| return self.GetImage(image) is not None |
| except ResourceNotFoundError: |
| return False |
| |
| def GetCommonInstanceMetadata(self, key): |
| """Looks up a single project metadata value. |
| |
| Args: |
| key: Metadata key name. |
| |
| Returns: |
| Metadata value corresponding to the key, or None if it was not found. |
| """ |
| projects_data = self.gce_client.projects().get( |
| project=self.project).execute() |
| metadata = projects_data['commonInstanceMetadata'] |
| return _GetMetdataValue(metadata, key) |
| |
| def SetCommonInstanceMetadata(self, key, value): |
| """Sets a single project metadata value. |
| |
| Args: |
| key: Metadata key to be set. |
| value: New value, or None if the given key should be removed. |
| """ |
| projects_data = self.gce_client.projects().get( |
| project=self.project).execute() |
| metadata = projects_data['commonInstanceMetadata'] |
| _UpdateMetadataValue(metadata, key, value) |
| operation = self.gce_client.projects().setCommonInstanceMetadata( |
| project=self.project, |
| body=metadata).execute() |
| self._WaitForGlobalOperation(operation['name']) |
| |
| def GetInstanceMetadata(self, instance, key): |
| """Looks up instance's metadata value. |
| |
| Args: |
| instance: Name of the instance. |
| key: Metadata key name. |
| |
| Returns: |
| Metadata value corresponding to the key, or None if it was not found. |
| """ |
| instance_data = self.GetInstance(instance) |
| metadata = instance_data['metadata'] |
| return self._GetMetdataValue(metadata, key) |
| |
| def SetInstanceMetadata(self, instance, key, value): |
| """Sets a single instance metadata value. |
| |
| Args: |
| instance: Name of the instance. |
| key: Metadata key to be set. |
| value: New value, or None if the given key should be removed. |
| """ |
| instance_data = self.GetInstance(instance) |
| metadata = instance_data['metadata'] |
| _UpdateMetadataValue(metadata, key, value) |
| operation = self.gce_client.instances().setMetadata( |
| project=self.project, |
| zone=self.zone, |
| instance=instance, |
| body=metadata).execute() |
| self._WaitForZoneOperation(operation['name']) |
| |
| def _WaitForZoneOperation(self, operation, zone=None, timeout_sec=None, |
| timeout_handler=None): |
| """Waits until a GCE ZoneOperation is finished or timed out. |
| |
| Args: |
| operation: The GCE operation to wait for. |
| zone: The zone that |operation| belongs to. |
| timeout_sec: The maximum number of seconds to wait for. |
| timeout_handler: A callable to be executed when timeout happens. |
| |
| Raises: |
| Error when timeout happens or the operation fails. |
| """ |
| get_request = self.gce_client.zoneOperations().get( |
| project=self.project, zone=zone or self.zone, operation=operation) |
| self._WaitForOperation(operation, get_request, timeout_sec, |
| timeout_handler=timeout_handler) |
| |
| def _WaitForRegionOperation(self, operation, region, timeout_sec=None, |
| timeout_handler=None): |
| """Waits until a GCE RegionOperation is finished or timed out. |
| |
| Args: |
| operation: The GCE operation to wait for. |
| region: The region that |operation| belongs to. |
| timeout_sec: The maximum number of seconds to wait for. |
| timeout_handler: A callable to be executed when timeout happens. |
| |
| Raises: |
| Error when timeout happens or the operation fails. |
| """ |
| get_request = self.gce_client.regionOperations().get( |
| project=self.project, region=region or self.region, operation=operation) |
| self._WaitForOperation(operation, get_request, timeout_sec, |
| timeout_handler=timeout_handler) |
| |
| def _WaitForGlobalOperation(self, operation, timeout_sec=None, |
| timeout_handler=None): |
| """Waits until a GCE GlobalOperation is finished or timed out. |
| |
| Args: |
| operation: The GCE operation to wait for. |
| timeout_sec: The maximum number of seconds to wait for. |
| timeout_handler: A callable to be executed when timeout happens. |
| |
| Raises: |
| Error when timeout happens or the operation fails. |
| """ |
| get_request = self.gce_client.globalOperations().get(project=self.project, |
| operation=operation) |
| self._WaitForOperation(operation, get_request, timeout_sec=timeout_sec, |
| timeout_handler=timeout_handler) |
| |
| def _WaitForOperation(self, operation, get_operation_request, |
| timeout_sec=None, timeout_handler=None): |
| """Waits until timeout or the request gets a response with a 'DONE' status. |
| |
| Args: |
| operation: The GCE operation to wait for. |
| get_operation_request: |
| The HTTP request to get the operation's status. |
| This request will be executed periodically until it returns a status |
| 'DONE'. |
| timeout_sec: The maximum number of seconds to wait for. |
| timeout_handler: A callable to be executed when times out. |
| |
| Raises: |
| Error when timeout happens or the operation fails. |
| """ |
| def _IsDone(): |
| result = get_operation_request.execute() |
| if result['status'] == 'DONE': |
| if 'error' in result: |
| raise Error(result['error']) |
| return True |
| return False |
| |
| try: |
| timeout = timeout_sec or self.DEFAULT_TIMEOUT_SEC |
| logging.info('Waiting up to %d seconds for operation [%s] to complete...', |
| timeout, operation) |
| timeout_util.WaitForReturnTrue(_IsDone, timeout, period=1) |
| except timeout_util.TimeoutError: |
| if timeout_handler: |
| timeout_handler() |
| raise Error('Timeout wating for operation [%s] to complete' % operation) |
| |
| def _BuildRetriableRequest(self, num_retries, http, thread_safe=False, |
| credentials=None, *args, **kwargs): |
| """Builds a request that will be automatically retried on server errors. |
| |
| Args: |
| num_retries: The maximum number of times to retry until give up. |
| http: An httplib2.Http object that this request will be executed through. |
| thread_safe: Whether or not the request needs to be thread-safe. |
| credentials: Credentials to apply to the request. |
| *args: Optional positional arguments. |
| **kwargs: Optional keyword arguments. |
| |
| Returns: |
| RetryOnServerErrorHttpRequest: A request that will automatically retried |
| on server errors. |
| """ |
| if thread_safe: |
| # Create a new http object for every request. |
| http = credentials.authorize(httplib2.Http()) |
| return RetryOnServerErrorHttpRequest(num_retries, http, *args, **kwargs) |