blob: 8c906d2a584ee69795e0e53b27f0e7a5d65e17fc [file] [log] [blame]
# 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
import httplib2
from chromite.lib import cros_logging as logging
from chromite.lib import timeout_util
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import HttpRequest
from oauth2client.client import GoogleCredentials
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)
class GceContext(object):
"""A convinient wrapper around the GCE Python API."""
_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'
_DEFAULT_TIMEOUT_SEC = 5 * 60
_INSTANCE_OPERATIONS_TIMEOUT_SEC = 5 * 60
_IMAGE_OPERATIONS_TIMEOUT_SEC = 2* 60
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)
@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 CreateInstance(self, name, image, zone=None, network=None,
machine_type=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.
machine_type: The machine type to use. Default machine type will be used
if omitted.
kwargs: Other possible Instance Resource properties.
https://cloud.google.com/compute/docs/reference/latest/instances#resource
Returns:
URL to the created instance.
"""
machine_type = 'zones/%s/machineTypes/%s' % (
zone or self.zone, machine_type or self._DEFAULT_MACHINE_TYPE)
# Allow machineType overriding.
if 'machineType' in kwargs:
machine_type = kwargs['machineType']
config = {
'name': name,
'machineType': machine_type,
'disks': (
{
'boot': True,
'autoDelete': True,
'initializeParams': {
'sourceImage': image,
},
},
),
'networkInterfaces': (
{
'network': ('global/networks/%s' % network or
self._DEFAULT_NETWORK),
'accessConfigs': (
{
'type': 'ONE_TO_ONE_NAT',
'name': 'External NAT',
},
),
},
),
}
config.update(**kwargs)
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.
"""
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 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.
"""
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.
"""
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 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 _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.
"""
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 _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.
"""
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.
"""
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 not 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)