diff --git a/img_proof/ipa_azure.py b/img_proof/ipa_azure.py index 60316e57..3ceb75f0 100644 --- a/img_proof/ipa_azure.py +++ b/img_proof/ipa_azure.py @@ -63,7 +63,9 @@ def __init__( timeout=None, vnet_name=None, vnet_resource_group=None, - collect_vm_info=None + collect_vm_info=None, + enable_secure_boot=None, + enable_uefi=None ): """Initialize Azure Cloud class.""" super(AzureCloud, self).__init__( @@ -89,7 +91,9 @@ def __init__( collect_vm_info, ssh_private_key_file, ssh_user, - subnet_id + subnet_id, + enable_secure_boot, + enable_uefi ) self.vnet_name = vnet_name or self.ipa_config['vnet_name'] diff --git a/img_proof/ipa_cloud.py b/img_proof/ipa_cloud.py index c304347d..600542d3 100644 --- a/img_proof/ipa_cloud.py +++ b/img_proof/ipa_cloud.py @@ -45,9 +45,7 @@ from img_proof.ipa_exceptions import ( IpaException, IpaCloudException, - IpaSSHException, - IpaRetryableError, - GCECloudRetryableError + IpaSSHException ) from img_proof.results_plugin import Report @@ -95,7 +93,9 @@ def __init__( collect_vm_info=None, ssh_private_key_file=None, ssh_user=None, - subnet_id=None + subnet_id=None, + enable_secure_boot=None, + enable_uefi=None ): """Initialize base cloud framework class.""" super(IpaCloud, self).__init__() @@ -157,6 +157,11 @@ def __init__( self.ssh_private_key_file = self.ipa_config['ssh_private_key_file'] self.ssh_user = self.ipa_config['ssh_user'] self.subnet_id = self.ipa_config['subnet_id'] + self.enable_secure_boot = self.ipa_config['enable_secure_boot'] + self.enable_uefi = self.ipa_config['enable_uefi'] + + if self.enable_secure_boot and not self.enable_uefi: + self.enable_uefi = True if self.cloud_config: self.cloud_config = os.path.expanduser(self.cloud_config) @@ -668,19 +673,11 @@ def test_image(self): self.logger.info('Launching new instance') try: self._launch_instance() - except GCECloudRetryableError as error: - with ipa_utils.ignored(Exception): - self._cleanup_instance(1) - - msg = 'Unable to connect to instance: %s' % error - self.logger.error(msg) - raise IpaRetryableError(msg) except Exception as error: with ipa_utils.ignored(Exception): self._cleanup_instance(1) - msg = 'Unable to connect to instance: %s' % error - self.logger.error(msg) + self.logger.error(error) raise if not self.instance_ip: diff --git a/img_proof/ipa_controller.py b/img_proof/ipa_controller.py index d008baae..e1feff03 100644 --- a/img_proof/ipa_controller.py +++ b/img_proof/ipa_controller.py @@ -48,6 +48,7 @@ def test_image( early_exit=None, history_log=None, image_id=None, + image_project=None, inject=None, instance_type=None, ip_address=None, @@ -75,7 +76,9 @@ def test_image( signing_key_fingerprint=None, signing_key_file=None, tenancy=None, - oci_user_id=None + oci_user_id=None, + enable_secure_boot=None, + enable_uefi=None ): """Creates a cloud framework instance and initiates testing.""" kwargs = { @@ -100,7 +103,9 @@ def test_image( 'test_dirs': test_dirs, 'test_files': tests, 'timeout': timeout, - 'collect_vm_info': collect_vm_info + 'collect_vm_info': collect_vm_info, + 'enable_secure_boot': enable_secure_boot, + 'enable_uefi': enable_uefi } cloud_name = cloud_name.lower() @@ -124,6 +129,7 @@ def test_image( elif cloud_name == 'gce': cloud = GCECloud( service_account_file=service_account_file, + image_project=image_project, **kwargs ) elif cloud_name == 'ssh': diff --git a/img_proof/ipa_ec2.py b/img_proof/ipa_ec2.py index ef7a96aa..b14a281f 100644 --- a/img_proof/ipa_ec2.py +++ b/img_proof/ipa_ec2.py @@ -67,7 +67,9 @@ def __init__( test_dirs=None, test_files=None, timeout=None, - collect_vm_info=None + collect_vm_info=None, + enable_secure_boot=None, + enable_uefi=None ): """Initialize EC2 cloud framework class.""" super(EC2Cloud, self).__init__( @@ -93,7 +95,9 @@ def __init__( collect_vm_info, ssh_private_key_file, ssh_user, - subnet_id + subnet_id, + enable_secure_boot, + enable_uefi ) # Get command line values that are not None cmd_line_values = self._get_non_null_values(locals()) diff --git a/img_proof/ipa_exceptions.py b/img_proof/ipa_exceptions.py index a3b1acd6..b42c16c9 100644 --- a/img_proof/ipa_exceptions.py +++ b/img_proof/ipa_exceptions.py @@ -41,10 +41,6 @@ class GCECloudException(IpaCloudException): """Generic GCE exception.""" -class GCECloudRetryableError(GCECloudException): - """GCE retryable error exception.""" - - class OCICloudException(IpaCloudException): """Generic OCI exception.""" diff --git a/img_proof/ipa_gce.py b/img_proof/ipa_gce.py index ce468bbf..4e2918d5 100644 --- a/img_proof/ipa_gce.py +++ b/img_proof/ipa_gce.py @@ -2,7 +2,7 @@ """Cloud framework module for testing Google Compute Engine (GCE) images.""" -# Copyright (c) 2019 SUSE LLC. All rights reserved. +# Copyright (c) 2020 SUSE LLC. All rights reserved. # # This file is part of img_proof. img_proof provides an api and command line # utilities for testing images in the Public Cloud. @@ -22,21 +22,59 @@ import json import os +import time + +from contextlib import contextmanager, suppress from img_proof import ipa_utils from img_proof.ipa_constants import ( GCE_DEFAULT_TYPE, GCE_DEFAULT_USER ) -from img_proof.ipa_exceptions import GCECloudException -from img_proof.ipa_exceptions import GCECloudRetryableError +from img_proof.ipa_exceptions import GCECloudException, IpaRetryableError from img_proof.ipa_cloud import IpaCloud -from libcloud.common.google import ResourceNotFoundError -from libcloud.common.google import GoogleBaseError -from libcloud.common.google import QuotaExceededError -from libcloud.compute.types import Provider -from libcloud.compute.providers import get_driver +from google.oauth2 import service_account +from googleapiclient import discovery + + +def get_message_from_http_error(error, resource_name): + """ + Attempt to parse error message from json. + + If there is an error getting the message content + use the default of `resource not found`. + """ + with suppress(AttributeError): + # In python 3.5 content is bytes + error.content = error.content.decode() + + try: + message = json.loads(error.content)['error']['message'] + except (AttributeError, KeyError): + message = 'Resource {resource_name} not found.'.format( + resource_name=resource_name + ) + + return message + + +@contextmanager +def handle_gce_http_errors(type_name, resource_name): + """ + Context manager to handle GCE HTTP Errors. + """ + try: + yield + except Exception as error: + message = get_message_from_http_error(error, resource_name) + + raise GCECloudException( + 'Unable to retrieve {type_name}: {error}'.format( + type_name=type_name, + error=message + ) + ) from error class GCECloud(IpaCloud): @@ -68,7 +106,10 @@ def __init__( test_dirs=None, test_files=None, timeout=None, - collect_vm_info=None + collect_vm_info=None, + image_project=None, + enable_secure_boot=None, + enable_uefi=None ): super(GCECloud, self).__init__( 'gce', @@ -93,7 +134,9 @@ def __init__( collect_vm_info, ssh_private_key_file, ssh_user, - subnet_id + subnet_id, + enable_secure_boot, + enable_uefi ) self.service_account_file = ( @@ -116,14 +159,15 @@ def __init__( self.ssh_user = self.ssh_user or GCE_DEFAULT_USER self.ssh_public_key = self._get_ssh_public_key() + self.image_project = image_project - self._get_service_account_info() + self.credentials = self._get_credentials() self.compute_driver = self._get_driver() self._validate_region() - def _get_service_account_info(self): - """Retrieve json dict from service account file.""" + def _get_credentials(self): + """Retrieve credentials object using service account file.""" with open(self.service_account_file, 'r') as f: info = json.load(f) @@ -143,28 +187,27 @@ def _get_service_account_info(self): 'docs for information on GCE configuration.' ) + return service_account.Credentials.from_service_account_file( + self.service_account_file + ) + def _get_driver(self): """Get authenticated GCE driver.""" - ComputeEngine = get_driver(Provider.GCE) - return ComputeEngine( - self.service_account_email, - self.service_account_file, - project=self.service_account_project + return discovery.build( + 'compute', + 'v1', + credentials=self.credentials, + cache_discovery=False ) def _get_instance(self): """Retrieve instance matching instance_id.""" - try: - instance = self.compute_driver.ex_get_node( - self.running_instance_id, - zone=self.region - ) - except ResourceNotFoundError as e: - raise GCECloudException( - 'Instance with id: {id} cannot be found: {error}'.format( - id=self.running_instance_id, error=e - ) - ) + with handle_gce_http_errors('instance', self.running_instance_id): + instance = self.compute_driver.instances().get( + project=self.service_account_project, + zone=self.region, + instance=self.running_instance_id + ).execute() return instance @@ -176,92 +219,258 @@ def _get_ssh_public_key(self): key=key.decode() ) + def _get_network(self, network_id): + """ + Return the network by network id (name). + + If network not found GCE will raise a 404 error. + """ + with handle_gce_http_errors('network', network_id): + network = self.compute_driver.networks().get( + project=self.service_account_project, + network=network_id + ).execute() + + return network + def _get_subnet(self, subnet_id): - subnet = None - try: + """ + Return the subnet by subnet id (name). + + If subnet not found GCE will raise a 404 error. + """ + with handle_gce_http_errors('subnet', subnet_id): # Subnet lives in a region whereas self.region # is a specific zone (us-west1-a). region = '-'.join(self.region.split('-')[:-1]) - subnet = self.compute_driver.ex_get_subnetwork( - subnet_id, region=region - ) - except Exception: - raise GCECloudException( - 'GCE subnet: {subnet_id} not found.'.format( - subnet_id=subnet_id - ) - ) + subnet = self.compute_driver.subnetworks().get( + project=self.service_account_project, + region=region, + subnetwork=subnet_id + ).execute() return subnet + def _get_instance_type(self, type_name): + """ + Return the instance type by name. + + If type not found GCE will raise a 404 error. + """ + with handle_gce_http_errors('instance type', type_name): + machine_type = self.compute_driver.machineTypes().get( + project=self.service_account_project, + zone=self.region, + machineType=type_name + ).execute() + + return machine_type + + def _get_image(self, image_name): + """ + Return the image by image name. + + If image is not found GCE will raise a 404 error. + """ + with handle_gce_http_errors('image', image_name): + image = self.compute_driver.images().get( + project=self.image_project or self.service_account_project, + image=image_name + ).execute() + + return image + + def _get_disk(self, disk_name): + """ + Return the disk by name. + + If disk is not found GCE will raise a 404 error. + """ + with handle_gce_http_errors('disk', disk_name): + disk = self.compute_driver.disks().get( + project=self.service_account_project, + zone=self.region, + disk=disk_name + ).execute() + + return disk + + def _get_network_config(self, subnet_id): + """ + Return the network config. + + If a subnet_id is provided use the subnet and + network. Otherwise use the default network. + """ + interface = { + 'accessConfigs': [{ + 'name': 'External NAT', + 'type': 'ONE_TO_ONE_NAT' + }] + } + + if subnet_id: + subnet = self._get_subnet(subnet_id) + interface['subnetwork'] = subnet['selfLink'] + interface['network'] = subnet['network'] + else: + interface['network'] = self._get_network('default')['selfLink'] + + return interface + + @staticmethod + def get_shielded_instance_config( + enable_secure_boot=False, + enable_vtpm=True, + enable_integrity_monitoring=True + ): + """ + Return shielded instance config object. + + Return with default values unless overridden by args. + """ + shielded_instance_config = { + 'enableSecureBoot': enable_secure_boot, + 'enableVtpm': enable_vtpm, + 'enableIntegrityMonitoring': enable_integrity_monitoring + } + + return shielded_instance_config + + @staticmethod + def get_instance_config( + instance_name, + machine_type, + network_interfaces, + service_account_email, + source_image, + ssh_key, + auto_delete=True, + boot_disk=True, + disk_type='PERSISTENT', + disk_mode='READ_WRITE', + shielded_instance_config=None, + ): + """Return an instance config for launching a new instance.""" + config = { + 'metadata': { + 'items': [{'key': 'ssh-keys', 'value': ssh_key}] + }, + 'service_accounts': [{ + 'email': service_account_email, + 'scopes': ['storage-ro'] + }], + 'machineType': machine_type, + 'disks': [{ + 'autoDelete': auto_delete, + 'boot': boot_disk, + 'type': disk_type, + 'mode': disk_mode, + 'deviceName': instance_name, + 'initializeParams': { + 'diskName': instance_name, + 'sourceImage': source_image + } + }], + 'networkInterfaces': network_interfaces, + 'name': instance_name + } + + if shielded_instance_config: + config['shieldedInstanceConfig'] = shielded_instance_config + config['disks'][0]['guestOsFeatures'] = [{ + 'type': 'UEFI_COMPATIBLE' + }] + + return config + def _launch_instance(self): """Launch an instance of the given image.""" - metadata = {'key': 'ssh-keys', 'value': self.ssh_public_key} self.running_instance_id = ipa_utils.generate_instance_name( 'gce-img-proof-test' ) self.logger.debug('ID of instance: %s' % self.running_instance_id) + machine_type = self._get_instance_type( + self.instance_type or GCE_DEFAULT_TYPE + )['selfLink'] + source_image = self._get_image(self.image_id)['selfLink'] + network_interfaces = [self._get_network_config(self.subnet_id)] + kwargs = { - 'location': self.region, - 'ex_metadata': metadata, - 'ex_service_accounts': [{ - 'email': self.service_account_email, - 'scopes': ['storage-ro'] - }] + 'instance_name': self.running_instance_id, + 'machine_type': machine_type, + 'service_account_email': self.service_account_email, + 'source_image': source_image, + 'ssh_key': self.ssh_public_key, + 'network_interfaces': network_interfaces } - if self.subnet_id: - kwargs['ex_subnetwork'] = self._get_subnet(self.subnet_id) - kwargs['ex_network'] = kwargs['ex_subnetwork'].network + if self.enable_uefi: + kwargs['shielded_instance_config'] = \ + self.get_shielded_instance_config( + enable_secure_boot=self.enable_secure_boot + ) try: - instance = self.compute_driver.create_node( - self.running_instance_id, - self.instance_type or GCE_DEFAULT_TYPE, - self.image_id, - **kwargs - ) - except ResourceNotFoundError as error: + response = self.compute_driver.instances().insert( + project=self.service_account_project, + zone=self.region, + body=self.get_instance_config(**kwargs) + ).execute() + except Exception as error: + with suppress(AttributeError): + # In python 3.5 content is bytes + error.content = error.content.decode() + + error_obj = json.loads(error.content)['error'] + try: - message = error.value['message'] - except TypeError: - message = error + message = error_obj['message'] + except (AttributeError, KeyError): + message = 'Unknown exception.' - raise GCECloudException( - 'An error occurred launching instance: {message}.'.format( + if error_obj['code'] == 412: + # 412 is conditionNotmet + error_class = IpaRetryableError + else: + error_class = GCECloudException + + raise error_class( + 'Failed to launch instance: {message}'.format( message=message ) - ) - except QuotaExceededError as error: - raise GCECloudRetryableError( - 'An error occurred launching instance: {message}.'.format( - message=error.value['message'] - ) - ) - except GoogleBaseError as error: - if error.value['reason'] in ['quotaExceeded', 'conditionNotMet']: - raise GCECloudRetryableError( - 'An error occurred launching instance: {message}.'.format( - message=error.value['message'] - ) - ) + ) from error + + operation = self._wait_on_operation(response['name']) + + if 'error' in operation and operation['error'].get('errors'): + error = operation['error']['errors'][0] + + if error['code'] in ('QUOTA_EXCEEDED', 'PRECONDITION_FAILED'): + error_class = IpaRetryableError else: - raise GCECloudException( - 'An error occurred launching instance: {message}.'.format( - message=error.value['message'] - ) + error_class = GCECloudException + + raise error_class( + 'Failed to launch instance: {message}'.format( + message=error['message'] ) + ) - self.compute_driver.wait_until_running( - [instance], + self._wait_on_instance( + 'RUNNING', timeout=self.timeout ) def _set_image_id(self): - """If existing image used get image id.""" + """Set the image_id instance variable based on boot disk.""" instance = self._get_instance() - self.image_id = instance.image + disk = self._get_disk(instance['disks'][0]['deviceName']) + + # Example sourceImage format: + # projects/debian-cloud/global/images/opensuse-leap-15.0-YYYYMMDD + self.image_id = disk['sourceImage'].rsplit('/', maxsplit=1)[-1] def _validate_region(self): """Validate region was passed in and is a valid GCE zone.""" @@ -272,7 +481,10 @@ def _validate_region(self): ) try: - zone = self.compute_driver.ex_get_zone(self.region) + zone = self.compute_driver.zones().get( + project=self.service_account_project, + zone=self.region + ).execute() except Exception: zone = None @@ -287,50 +499,87 @@ def _validate_region(self): def _get_instance_state(self): """Attempt to retrieve the state of the instance.""" instance = self._get_instance() - return instance.state + return instance['status'] def _is_instance_running(self): """Return True if instance is in running state.""" - return self._get_instance_state() == 'running' + return self._get_instance_state() == 'RUNNING' def _set_instance_ip(self): """Retrieve and set the instance ip address.""" instance = self._get_instance() - if instance.public_ips: - self.instance_ip = instance.public_ips[0] - elif instance.private_ips: - self.instance_ip = instance.private_ips[0] - else: - raise GCECloudException( - 'IP address for instance: %s cannot be found.' - % self.running_instance_id - ) + interface = instance['networkInterfaces'][0] + try: + self.instance_ip = interface['accessConfigs'][0]['natIP'] + except (KeyError, IndexError): + try: + self.instance_ip = interface['networkIP'] + except KeyError: + raise GCECloudException( + 'IP address for instance: %s cannot be found.' + % self.running_instance_id + ) def _start_instance(self): """Start the instance.""" - instance = self._get_instance() - self.compute_driver.ex_start_node(instance) - self.compute_driver.wait_until_running( - [instance], + self.compute_driver.instances().start( + project=self.service_account_project, + zone=self.region, + instance=self.running_instance_id + ).execute() + + self._wait_on_instance( + 'RUNNING', timeout=self.timeout ) def _stop_instance(self): """Stop the instance.""" - instance = self._get_instance() - self.compute_driver.ex_stop_node(instance) - self._wait_on_instance('stopped', timeout=self.timeout) + self.compute_driver.instances().stop( + project=self.service_account_project, + zone=self.region, + instance=self.running_instance_id + ).execute() + + # In GCE an instance that is stopped has a state of TERMINATED: + # https://cloud.google.com/compute/docs/instances/instance-life-cycle + self._wait_on_instance( + 'TERMINATED', + timeout=self.timeout + ) def _terminate_instance(self): """Terminate the instance.""" - instance = self._get_instance() - instance.destroy() + self.compute_driver.instances().delete( + project=self.service_account_project, + zone=self.region, + instance=self.running_instance_id + ).execute() def get_console_log(self): """ Return console log output if it is available. """ - instance = self._get_instance() - output = self.compute_driver.ex_get_serial_output(instance) - return output + output = self.compute_driver.instances().getSerialPortOutput( + project=self.service_account_project, + zone=self.region, + instance=self.running_instance_id + ).execute() + return output.get('contents', '') + + def _wait_on_operation(self, operation_name, timeout=600, wait_period=10): + start = time.time() + end = start + timeout + + while time.time() < end: + time.sleep(wait_period) + + operation = self.compute_driver.zoneOperations().get( + project=self.service_account_project, + zone=self.region, + operation=operation_name + ).execute() + + if operation['status'] == 'DONE': + return operation diff --git a/img_proof/ipa_oci.py b/img_proof/ipa_oci.py index e29c7844..973075ef 100644 --- a/img_proof/ipa_oci.py +++ b/img_proof/ipa_oci.py @@ -63,7 +63,9 @@ def __init__( signing_key_fingerprint=None, signing_key_file=None, tenancy=None, - oci_user_id=None + oci_user_id=None, + enable_secure_boot=None, + enable_uefi=None ): """Initialize OCI cloud framework class.""" super(OCICloud, self).__init__( @@ -89,7 +91,9 @@ def __init__( collect_vm_info, ssh_private_key_file, ssh_user, - subnet_id + subnet_id, + enable_secure_boot, + enable_uefi ) self.availability_domain = ( diff --git a/img_proof/scripts/cli.py b/img_proof/scripts/cli.py index 8ba959ab..e22e158b 100644 --- a/img_proof/scripts/cli.py +++ b/img_proof/scripts/cli.py @@ -136,6 +136,12 @@ def main(context, no_color): '--image-id', help='The ID of the image used for instance.' ) +@click.option( + '--image-project', + help='The image project where the image exists. This is required if ' + 'testing an image in a project different than the service account ' + 'project.' +) @click.option( '--inject', help='Path to an injection yaml config file.', @@ -273,6 +279,18 @@ def main(context, no_color): '--oci-user-id', help='The ID for the OCI user.' ) +@click.option( + '--enable-secure-boot', + is_flag=True, + help='Enable secure boot for the instance. Secure boot requires ' + 'UEFI boot firmware.' +) +@click.option( + '--enable-uefi', + is_flag=True, + help='Enable boot firmware for the instance. By default secure boot ' + 'is disabled.' +) @click.argument('tests', nargs=-1) @click.pass_context def test(context, @@ -286,6 +304,7 @@ def test(context, early_exit, history_log, image_id, + image_project, inject, instance_type, ip_address, @@ -314,6 +333,8 @@ def test(context, signing_key_file, tenancy, oci_user_id, + enable_secure_boot, + enable_uefi, tests): """Test image in the given framework using the supplied test files.""" no_color = context.obj['no_color'] @@ -330,6 +351,7 @@ def test(context, early_exit, history_log, image_id, + image_project, inject, instance_type, ip_address, @@ -358,6 +380,8 @@ def test(context, signing_key_file, tenancy, oci_user_id, + enable_secure_boot, + enable_uefi ) echo_results(results, no_color) sys.exit(status) diff --git a/package/python3-img-proof.spec b/package/python3-img-proof.spec index 53452e85..6235c785 100644 --- a/package/python3-img-proof.spec +++ b/package/python3-img-proof.spec @@ -31,7 +31,8 @@ BuildRequires: python3-click-man BuildRequires: python3-click Requires: python3-PyYAML Requires: python3-boto3 -Requires: python3-apache-libcloud +Requires: python3-google-auth +Requires: python3-google-api-python-client Requires: python3-azure-common Requires: python3-azure-mgmt-compute Requires: python3-azure-mgmt-network @@ -48,7 +49,8 @@ BuildArch: noarch %if %{with test} BuildRequires: python3-PyYAML BuildRequires: python3-boto3 -BuildRequires: python3-apache-libcloud +BuildRequires: python3-google-auth +BuildRequires: python3-google-api-python-client BuildRequires: python3-azure-common BuildRequires: python3-azure-mgmt-compute BuildRequires: python3-azure-mgmt-network diff --git a/requirements.txt b/requirements.txt index 9468dd4f..99388161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ boto3 -apache-libcloud azure-common azure-mgmt-compute azure-mgmt-network @@ -13,3 +12,5 @@ pytest PyYAML testinfra oci +google-auth +google-api-python-client diff --git a/tests/test_ipa_gce.py b/tests/test_ipa_gce.py index 20c8af14..2f5bbd20 100644 --- a/tests/test_ipa_gce.py +++ b/tests/test_ipa_gce.py @@ -21,25 +21,42 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import json import pytest from img_proof.ipa_gce import GCECloud -from img_proof.ipa_exceptions import GCECloudException +from img_proof.ipa_exceptions import GCECloudException, IpaRetryableError from unittest.mock import MagicMock, patch -from libcloud.common.google import ResourceNotFoundError +from googleapiclient.errors import HttpError + + +def get_http_error(msg, status='404'): + resp = MagicMock() + resp.status = status + + content = { + 'error': { + 'code': int(status), + 'message': msg + } + } + + return HttpError(resp, json.dumps(content).encode()) class TestGCECloud(object): """Test GCE cloud class.""" + @patch('img_proof.ipa_gce.service_account') @patch.object(GCECloud, '_validate_region') - @patch('libcloud.compute.drivers.gce.GCENodeDriver') + @patch('img_proof.ipa_gce.discovery') def setup( self, - mock_node_driver, - mock_validate_region + mock_discovery, + mock_validate_region, + mock_service_account ): """Set up kwargs dict.""" self.kwargs = { @@ -53,7 +70,11 @@ def setup( } driver = MagicMock() - mock_node_driver.return_value = driver + mock_discovery.build.return_value = driver + + service_account = MagicMock() + mock_service_account.Credentials.\ + from_service_account_file.return_value = service_account self.cloud = GCECloud(**self.kwargs) @@ -80,21 +101,14 @@ def test_gce_exception_required_args(self): self.kwargs['ssh_private_key_file'] = 'tests/data/ida_test' - def test_gce_get_service_account_info(self): - """Test get service account info method.""" - self.cloud._get_service_account_info() - - assert self.cloud.service_account_email == \ - 'test@test.iam.gserviceaccount.com' - assert self.cloud.service_account_project == 'test' - - def test_gce_get_service_account_info_invalid(self): - """Test get service account info method.""" + @patch('img_proof.ipa_gce.service_account') + def test_gce_get_service_account_info_invalid(self, mock_service_account): + """Test get credentials method with invalid service account.""" self.cloud.service_account_file = \ 'tests/gce/invalid-service-account.json' with pytest.raises(GCECloudException) as error: - self.cloud._get_service_account_info() + self.cloud._get_credentials() msg = 'Service account JSON file is invalid for GCE. ' \ 'client_email key is expected. See getting started ' \ @@ -104,143 +118,312 @@ def test_gce_get_service_account_info_invalid(self): def test_gce_get_instance(self): """Test gce get instance method.""" instance = MagicMock() - self.cloud.compute_driver.ex_get_node.return_value = instance + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = instance + instances_obj.get.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj val = self.cloud._get_instance() assert val == instance self.cloud.running_instance_id = 'test-instance' - self.cloud.compute_driver.ex_get_node.side_effect = \ - ResourceNotFoundError( - 'Broken', - 'test', - 'test' - ) + instances_obj.get.side_effect = get_http_error( + 'test-instance cannot be found.' + ) with pytest.raises(GCECloudException) as error: self.cloud._get_instance() - assert str(error.value) == "Instance with id: test-instance cannot" \ - " be found: 'Broken'" + exc = "Unable to retrieve instance: test-instance cannot be found." + assert str(error.value) == exc + + def test_gce_get_network(self): + """Test GCE get network method.""" + network = MagicMock() + networks_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = network + networks_obj.get.return_value = operation + self.cloud.compute_driver.networks.return_value = networks_obj + + result = self.cloud._get_network('test-network') + + assert result == network + + networks_obj.get.side_effect = get_http_error( + 'Resource test-network not found.' + ) + + msg = 'Unable to retrieve network: Resource test-network not found.' + with pytest.raises(GCECloudException) as error: + self.cloud._get_network('test-network') + + assert msg == str(error.value) def test_gce_get_subnet(self): """Test GCE get subnetwork method.""" subnetwork = MagicMock() - self.cloud.compute_driver.ex_get_subnetwork.return_value = subnetwork + subnet_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = subnetwork + subnet_obj.get.return_value = operation + self.cloud.compute_driver.subnetworks.return_value = subnet_obj self.cloud.region = 'us-west-1a' result = self.cloud._get_subnet('test-subnet') assert result == subnetwork - def test_gce_get_subnet_exception(self): - """Test GCE get subnetwork method.""" - self.cloud.compute_driver.ex_get_subnetwork.side_effect = Exception( - 'Cannot find subnet!' + subnet_obj.get.side_effect = get_http_error( + 'Resource test-subnet not found.' ) - self.cloud.region = 'us-west-1a' - - msg = 'GCE subnet: test-subnet not found.' + msg = 'Unable to retrieve subnet: Resource test-subnet not found.' with pytest.raises(GCECloudException) as error: self.cloud._get_subnet('test-subnet') assert msg == str(error.value) + def test_gce_get_instance_type(self): + """Test GCE get instance type method.""" + machine_type = MagicMock() + machine_type_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = machine_type + machine_type_obj.get.return_value = operation + self.cloud.compute_driver.machineTypes.return_value = machine_type_obj + + result = self.cloud._get_instance_type('n1-standard-1') + assert result == machine_type + + machine_type_obj.get.side_effect = get_http_error( + 'Resource n1-standard-1 not found.' + ) + + msg = 'Unable to retrieve instance type: ' \ + 'Resource n1-standard-1 not found.' + with pytest.raises(GCECloudException) as error: + self.cloud._get_instance_type('n1-standard-1') + + assert msg == str(error.value) + + def test_gce_get_image(self): + """Test GCE get image method.""" + image = MagicMock() + image_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = image + image_obj.get.return_value = operation + self.cloud.compute_driver.images.return_value = image_obj + + result = self.cloud._get_image('fake-image-20200202') + assert result == image + + image_obj.get.side_effect = get_http_error( + 'Resource fake-image-20200202 not found.' + ) + + msg = 'Unable to retrieve image: ' \ + 'Resource fake-image-20200202 not found.' + with pytest.raises(GCECloudException) as error: + self.cloud._get_image('fake-image-20200202') + + assert msg == str(error.value) + + def test_gce_get_disk(self): + """Test GCE get image method.""" + disk = MagicMock() + disk_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = disk + disk_obj.get.return_value = operation + self.cloud.compute_driver.disks.return_value = disk_obj + + result = self.cloud._get_disk('disk12') + assert result == disk + + disk_obj.get.side_effect = get_http_error( + 'Resource disk12 not found.' + ) + + msg = 'Unable to retrieve disk: ' \ + 'Resource disk12 not found.' + with pytest.raises(GCECloudException) as error: + self.cloud._get_disk('disk12') + + assert msg == str(error.value) + @patch.object(GCECloud, '_get_subnet') + def test_get_network_config(self, mock_get_subnet): + subnet = 'projects/test/regions/us-west1/subnetworks/sub-123' + net = 'projects/test/global/networks/network' + + mock_get_subnet.return_value = { + 'selfLink': subnet, + 'network': net + } + + subnet_config = self.cloud._get_network_config('sub-123') + + assert subnet_config['network'] == net + assert subnet_config['subnetwork'] == subnet + + def test_get_shielded_instance_config(self): + si_config = self.cloud.get_shielded_instance_config() + + assert si_config['enableSecureBoot'] is False + assert si_config['enableVtpm'] + assert si_config['enableIntegrityMonitoring'] + + def test_get_instance_config(self): + config = self.cloud.get_instance_config( + 'instance123', + 'n1-standard-1', + [{}], + 'service-account-123@email.com', + 'image123', + 'secretkey', + shielded_instance_config={'shielded': 'config'} + ) + + assert 'metadata' in config + assert 'service_accounts' in config + assert 'machineType' in config + assert 'disks' in config + assert 'networkInterfaces' in config + assert 'name' in config + assert 'shieldedInstanceConfig' in config + + @patch.object(GCECloud, '_wait_on_instance') + @patch.object(GCECloud, '_wait_on_operation') + @patch.object(GCECloud, '_get_network') + @patch.object(GCECloud, '_get_image') + @patch.object(GCECloud, '_get_instance_type') @patch('img_proof.ipa_utils.generate_instance_name') def test_gce_launch_instance( self, mock_generate_instance_name, - mock_get_subnet + mock_get_instance_type, + mock_get_image, + mock_get_network, + mock_wait_on_operation, + mock_wait_on_instance ): """Test GCE launch instance method.""" - instance = MagicMock() - self.cloud.compute_driver.create_node.return_value = instance - self.cloud.compute_driver.wait_until_running.return_value = None mock_generate_instance_name.return_value = 'test-instance' + mock_get_network.return_value = { + 'selfLink': 'projects/test/global/networks/net1' + } + mock_get_image.return_value = { + 'selfLink': 'projects/test/global/images/img-123' + } + mock_get_instance_type.return_value = { + 'selfLink': 'zones/us-west1-a/machineTypes/n1-standard-1' + } + mock_wait_on_operation.return_value = {} - self.cloud.region = 'us-west1-a' - self.cloud.subnet_id = 'test-subnet' + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = {'name': 'operation123'} + instances_obj.insert.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj - subnet = MagicMock() - network = MagicMock() - subnet.network = network - mock_get_subnet.return_value = subnet + self.cloud.region = 'us-west1-a' self.cloud._launch_instance() assert self.cloud.running_instance_id == 'test-instance' + assert mock_wait_on_instance.call_count == 1 + + # Exception on operation + + mock_wait_on_operation.return_value = { + 'error': { + 'errors': [{ + 'code': 'QUOTA_EXCEEDED', + 'message': 'Too many cpus.' + }] + } + } + + with pytest.raises(IpaRetryableError) as error: + self.cloud._launch_instance() + + assert 'Failed to launch instance: Too many cpus.' == str(error.value) + # Exception on API call + + mock_wait_on_operation.return_value = {} + instances_obj.insert.side_effect = get_http_error( + 'Invalid instance type.', + '412' + ) + + with pytest.raises(IpaRetryableError) as error: + self.cloud._launch_instance() + + msg = 'Failed to launch instance: Invalid instance type.' + assert msg == str(error.value) + + @patch.object(GCECloud, '_get_disk') @patch.object(GCECloud, '_get_instance') - def test_gce_set_image_id(self, mock_get_instance): + def test_gce_set_image_id(self, mock_get_instance, mock_get_disk): """Test gce cloud set image id method.""" - instance = MagicMock() - instance.image = 'test-image' + instance = { + 'disks': [{'deviceName': 'disk123'}] + } + disk = { + 'sourceImage': 'projects/suse/global/images/opensuse-leap-15.0' + } mock_get_instance.return_value = instance + mock_get_disk.return_value = disk self.cloud._set_image_id() - assert self.cloud.image_id == instance.image + assert self.cloud.image_id == 'opensuse-leap-15.0' assert mock_get_instance.call_count == 1 + assert mock_get_disk.call_count == 1 - @patch.object(GCECloud, '_get_driver') - def test_gce_validate_region(self, mock_get_driver): + def test_gce_validate_region(self): """Test gce cloud set image id method.""" - driver = MagicMock() - driver.ex_get_zone.return_value = None - mock_get_driver.return_value = driver + zones_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = None + zones_obj.get.return_value = operation + self.cloud.compute_driver.zones.return_value = zones_obj with pytest.raises(GCECloudException) as error: - GCECloud(**self.kwargs) + self.cloud._validate_region() assert str(error.value) == \ 'Zone is required for GCE cloud framework: Example: us-west1-a' - self.kwargs['region'] = 'fake' + self.cloud.region = 'fake' with pytest.raises(GCECloudException) as error: - GCECloud(**self.kwargs) - - driver.ex_get_zone.assert_called_once_with('fake') + self.cloud._validate_region() assert str(error.value) == \ 'fake is not a valid GCE zone. Example: us-west1-a' @patch.object(GCECloud, '_get_instance') - def test_gce_get_instance_state(self, mock_get_instance): - """Test gce get instance method.""" - instance = MagicMock() - instance.state = 'running' - mock_get_instance.return_value = instance - - val = self.cloud._get_instance_state() - - assert val == 'running' - assert mock_get_instance.call_count == 1 - - @patch.object(GCECloud, '_get_instance_state') - def test_gce_is_instance_running(self, mock_get_instance_state): + def test_gce_is_instance_running(self, mock_get_instance): """Test gce cloud is instance runnning method.""" - mock_get_instance_state.return_value = 'running' - + mock_get_instance.return_value = {'status': 'RUNNING'} assert self.cloud._is_instance_running() - assert mock_get_instance_state.call_count == 1 - - mock_get_instance_state.return_value = 'stopped' - mock_get_instance_state.reset_mock() + assert mock_get_instance.call_count == 1 + mock_get_instance.return_value = {'status': 'TERMINATED'} assert not self.cloud._is_instance_running() - assert mock_get_instance_state.call_count == 1 @patch.object(GCECloud, '_get_instance') def test_gce_set_instance_ip(self, mock_get_instance): """Test gce cloud set instance ip method.""" - instance = MagicMock() - instance.public_ips = [] - instance.private_ips = [] - mock_get_instance.return_value = instance + mock_get_instance.return_value = { + 'networkInterfaces': [{'some': 'data'}] + } self.cloud.running_instance_id = 'test' @@ -251,63 +434,79 @@ def test_gce_set_instance_ip(self, mock_get_instance): 'IP address for instance: test cannot be found.' assert mock_get_instance.call_count == 1 - mock_get_instance.reset_mock() - - instance.public_ips = ['127.0.0.1'] + mock_get_instance.return_value = { + 'networkInterfaces': [{'networkIP': '10.0.0.0'}] + } self.cloud._set_instance_ip() - assert self.cloud.instance_ip == '127.0.0.1' - assert mock_get_instance.call_count == 1 + assert self.cloud.instance_ip == '10.0.0.0' - @patch.object(GCECloud, '_get_instance') - def test_gce_start_instance(self, mock_get_instance): + @patch.object(GCECloud, '_wait_on_instance') + def test_gce_start_instance(self, mock_wait_on_instance): """Test gce start instance method.""" - instance = MagicMock() - mock_get_instance.return_value = instance - self.cloud.compute_driver.ex_start_node.return_value = None - self.cloud.compute_driver.wait_until_running.return_value = None + mock_wait_on_instance.return_value = None + + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = None + instances_obj.start.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj self.cloud._start_instance() - assert mock_get_instance.call_count == 1 - assert self.cloud.compute_driver.ex_start_node.call_count == 1 - assert self.cloud.compute_driver.wait_until_running.call_count == 1 + assert instances_obj.start.call_count == 1 @patch.object(GCECloud, '_wait_on_instance') - @patch.object(GCECloud, '_get_instance') - def test_gce_stop_instance( - self, - mock_get_instance, - mock_wait_on_instance - ): + def test_gce_stop_instance(self, mock_wait_on_instance): """Test gce stop instance method.""" - instance = MagicMock() - mock_get_instance.return_value = instance mock_wait_on_instance.return_value = None - self.cloud.compute_driver.ex_stop_node.return_value = None + + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = None + instances_obj.stop.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj self.cloud._stop_instance() - assert mock_get_instance.call_count == 1 - assert self.cloud.compute_driver.ex_stop_node.call_count == 1 + assert instances_obj.stop.call_count == 1 - @patch.object(GCECloud, '_get_instance') - def test_gce_terminate_instance(self, mock_get_instance): + def test_gce_terminate_instance(self): """Test gce terminate instance method.""" - instance = MagicMock() - instance.destroy.return_value = None - mock_get_instance.return_value = instance + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = None + instances_obj.delete.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj self.cloud._terminate_instance() - assert instance.destroy.call_count == 1 + assert instances_obj.delete.call_count == 1 - @patch.object(GCECloud, '_get_instance') - def test_gce_get_console_log(self, mock_get_instance): + def test_gce_get_console_log(self): """Test gce get console log method.""" - instance = MagicMock() - mock_get_instance.return_value = instance - self.cloud.compute_driver.ex_get_serial_output.return_value = 'output' + instances_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = {'content': 'some output'} + instances_obj.getSerialPortOutput.return_value = operation + self.cloud.compute_driver.instances.return_value = instances_obj + + self.cloud.get_console_log() + assert instances_obj.getSerialPortOutput.call_count == 1 + + @patch('img_proof.ipa_gce.time') + def test_wait_on_operation(self, mock_time): + self.cloud.service_account_project = 'test_project' + self.cloud.region = 'us-west1-a' + + mock_time.sleep.return_value = None + mock_time.time.return_value = 10 + + zone_ops_obj = MagicMock() + operation = MagicMock() + operation.execute.return_value = {'status': 'DONE'} + zone_ops_obj.get.return_value = operation + self.cloud.compute_driver.zoneOperations.return_value = zone_ops_obj - output = self.cloud.get_console_log() - assert output == 'output' - assert self.cloud.compute_driver.ex_get_serial_output.call_count == 1 + result = self.cloud._wait_on_operation('operation213') + assert result['status'] == 'DONE' + assert zone_ops_obj.get.call_count == 1