diff --git a/cloudcafe/openstackcli/novacli/__init__.py b/cloudcafe/openstackcli/novacli/__init__.py new file mode 100644 index 00000000..59ab77fa --- /dev/null +++ b/cloudcafe/openstackcli/novacli/__init__.py @@ -0,0 +1,15 @@ +""" +Copyright 2013 Rackspace + +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. +""" diff --git a/cloudcafe/openstackcli/novacli/behaviors.py b/cloudcafe/openstackcli/novacli/behaviors.py new file mode 100644 index 00000000..50bfc405 --- /dev/null +++ b/cloudcafe/openstackcli/novacli/behaviors.py @@ -0,0 +1,142 @@ +from time import time + +from cafe.engine.behaviors import behavior + +from cloudcafe.common.tools.datagen import random_string +from cloudcafe.openstackcli.novacli.client import NovaCLI +from cloudcafe.openstackcli.novacli.config import NovaCLI_Config +from cloudcafe.openstackcli.common.behaviors import \ + OpenstackCLI_BaseBehavior, OpenstackCLI_BehaviorError + +from cloudcafe.compute.servers_api.config import ServersConfig +from cloudcafe.compute.images_api.config import ImagesConfig +from cloudcafe.compute.flavors_api.config import FlavorsConfig +from cloudcafe.compute.common.types import \ + NovaServerStatusTypes as ServerStates +from cloudcafe.compute.common.exceptions import \ + TimeoutException, BuildErrorException, RequiredResourceException + + +class NovaCLIBehaviorError(OpenstackCLI_BehaviorError): + pass + + +class NovaCLI_Behaviors(OpenstackCLI_BaseBehavior): + + _default_error = NovaCLIBehaviorError + + def __init__( + self, nova_cli_client=None, nova_cli_config=None, + servers_api_config=None, images_api_config=None, + flavors_api_config=None): + + super(NovaCLI_Behaviors, self).__init__() + self.nova_cli_client = nova_cli_client + self.nova_cli_config = nova_cli_config or NovaCLI_Config() + + self.servers_api_config = servers_api_config or ServersConfig() + self.images_api_config = images_api_config or ImagesConfig() + self.flavors_api_config = flavors_api_config or FlavorsConfig() + + @behavior(NovaCLI) + def create_available_server( + self, name, flavor=None, image=None, no_service_net=None, + no_public=None, disk_config=None, image_with=None, + boot_volume=None, snapshot=None, num_instances=None, meta=None, + file_=None, key_name=None, user_data=None, availability_zone=None, + security_groups=None, block_device_mapping=None, block_device=None, + swap=None, ephemeral=None, hint=None, nic=None, config_drive=None): + """ + Expected input for non-string parameters + + disk_config: 'auto' or 'manual' + image-with: {key: value} + meta: {key: value, [key2=value2, ...] } + file_: {dst-path: src-path} + block_device_mapping: {dev-name: mapping} + block_device: {key=value, [key2=value2, ...] } + ephemeral: {'size': size, ['format': format]} + hint: {key: value} + nic: {'net-id'=net-uuid, + 'port-id'=port-uuid, + ['v4-fixed-ip'=ip-addr]} + + """ + name = name or random_string('NovaCLI') + image = image or self.images_api_config.primary_image + flavor = flavor or self.flavors_api_config.primary_flavor + + failures = [] + attempts = self.servers_api_config.resource_build_attempts + for attempt in range(attempts): + + self._log.debug( + 'Attempt {attempt} of {attempts} to create server with the ' + 'NovaCLI.'.format(attempt=attempt + 1, attempts=attempts)) + + resp = self.nova_cli_client.create_server( + name=name, flavor=flavor, image=image, + no_service_net=no_service_net, no_public=no_public, + disk_config=disk_config, image_with=image_with, + boot_volume=boot_volume, snapshot=snapshot, + num_instances=num_instances, meta=meta, file_=file_, + key_name=key_name, user_data=user_data, + availability_zone=availability_zone, + security_groups=security_groups, + block_device_mapping=block_device_mapping, + block_device=block_device, + swap=swap, ephemeral=ephemeral, hint=hint, nic=nic, + config_drive=config_drive) + server = resp.entity + + if server is None: + raise self._default_error("Unable to parse nova boot response") + + try: + resp = self.wait_for_server_status( + server.id_, ServerStates.ACTIVE) + # Add the password from the create request + # into the final response + resp.entity.admin_pass = server.admin_pass + return resp + except (TimeoutException, BuildErrorException) as ex: + msg = 'Failed to build server {server_id}'.format( + server_id=server.id_) + self._log.exception(msg) + failures.append(ex.message) + self.nova_cli_client.delete_server(server.id_) + + raise RequiredResourceException( + 'Failed to successfully build a server after ' + '{attempts} attempts: {failures}'.format( + attempts=attempts, failures=failures)) + + def wait_for_server_status( + self, server_id, desired_status, interval_time=None, timeout=None): + + interval_time = int( + interval_time or self.servers_api_config.server_status_interval) + timeout = int(timeout or self.servers_api_config.server_build_timeout) + end_time = time.time() + timeout + + time.sleep(interval_time) + while time.time() < end_time: + resp = self.nova_cli_client.show_server(server_id) + server = resp.entity + + if server.status.lower() == ServerStates.ERROR.lower(): + raise BuildErrorException( + 'Build failed. Server with uuid "{0} entered ERROR status.' + .format(server.id)) + + if server.status == desired_status: + break + time.sleep(interval_time) + + else: + raise TimeoutException( + "wait_for_server_status ran for {0} seconds and did not " + "observe server {1} reach the {2} status.".format( + timeout, server_id, desired_status)) + + return resp diff --git a/cloudcafe/openstackcli/novacli/client.py b/cloudcafe/openstackcli/novacli/client.py new file mode 100644 index 00000000..30474ffc --- /dev/null +++ b/cloudcafe/openstackcli/novacli/client.py @@ -0,0 +1,121 @@ +from cloudcafe.openstackcli.common.client import BaseOpenstackPythonCLI_Client +from cloudcafe.openstackcli.novacli.models import responses + + +class NovaCLI(BaseOpenstackPythonCLI_Client): + + _KWMAP = { + 'os_cache': 'os-cache', + 'timings': 'timings', + 'timeout': 'timeout', + 'os_tenant_id': 'os-tenant-id', + 'os_auth_system': 'os-auth-system', + 'service_type': 'service-type', + 'service_name': 'service-name', + 'volume_service_name': 'volume-service-name', + 'os_compute_api_version': 'os-compute-api-version', + 'bypass_url': 'bypass-url', + 'os_auth_url': 'os-auth-url', + 'endpoint_type': 'endpoint-type', + 'insecure': 'insecure'} + + # Make sure to include all openstack common cli paramaters in addition to + # the nova specific ones + _KWMAP.update(BaseOpenstackPythonCLI_Client._KWMAP) + + #The client command the must precede any call to the cli + _CMD = 'nova' + + def __init__( + self, os_cache=None, timings=None, timeout=None, os_tenant_id=None, + os_auth_system=None, service_type=None, service_name=None, + volume_service_name=None, os_compute_api_version=None, + insecure=False, bypass_url=None, os_auth_url=None, + endpoint_type=None, **kwargs): + + super(NovaCLI, self).__init__(**kwargs) + self.os_cache = os_cache + self.timings = timings + self.timeout = timeout + self.os_auth_system = os_auth_system + self.service_type = service_type + self.service_name = service_name + self.volume_service_name = volume_service_name + self.os_compute_api_version = os_compute_api_version + self.insecure = insecure + self.bypass_url = bypass_url + self.os_tenant_id = os_tenant_id + self.os_auth_url = os_auth_url + self.endpoint_type = endpoint_type + + def create_server( + self, name, no_service_net=None, no_public=None, disk_config=None, + flavor=None, image=None, image_with=None, boot_volume=None, + snapshot=None, num_instances=None, meta=None, file_=None, + key_name=None, user_data=None, availability_zone=None, + security_groups=None, block_device_mapping=None, block_device=None, + swap=None, ephemeral=None, hint=None, nic=None, config_drive=None): + """ + Expected input for parameters + + disk_config: 'auto' or 'manual' + image-with: {key: value} + meta: {key: value, [key2=value2, ...] } + file_: {dst-path: src-path} + block_device_mapping: {dev-name: mapping} + block_device: {key=value, [key2=value2, ...] } + ephemeral: {'size': size, ['format': format]} + hint: {key: value} + nic: {'net-id'=net-uuid, + 'port-id'=port-uuid, + ['v4-fixed-ip'=ip-addr]} + + """ + _cmd = 'boot' + _kwmap = { + 'no_service_net': 'no-service-net', + 'no_public': 'no-public', + 'disk_config': 'disk-config', + 'flavor': 'flavor', + 'image': 'image', + 'image_with': 'image-with', + 'boot_volume': 'boot-volume', + 'snapshot': 'snapshot', + 'num_instances': 'num-instances', + 'meta': 'meta', + 'file_': 'file', + 'key_name': 'key-name', + 'user_data': 'user-data', + 'availability_zone': 'availability-zone', + 'security_groups': 'security-groups', + 'block_device_mapping': 'block-device-mapping', + 'block_device': 'block-device', + 'swap': 'swap', + 'ephemeral': 'ephemeral', + 'hint': 'hint', + 'nic': 'nic', + 'config_drive': 'config-drive'} + + no_service_net = True if no_service_net else False + no_public = True if no_public else False + meta = self._multiplicable_flag_data_to_string('meta', meta) + image_with = self._dict_to_string(image_with) + file_ = self._dict_to_string(file_) + block_device = self._dict_to_string(block_device) + ephemeral = self._dict_to_string(ephemeral) + hint = self._dict_to_string(hint) + nic = self._dict_to_string(nic) + block_device_mapping = self._dict_to_string(block_device_mapping) + + _response_type = responses.ServerResponse + return self._process_command() + + def show_server(self, server_id): + _cmd = 'show' + _response_type = responses.ServerResponse + return self._process_command() + + def list_servers(self): + _cmd = 'list' + _response_type = responses.ServerListResponse + return self._process_command() diff --git a/cloudcafe/openstackcli/novacli/config.py b/cloudcafe/openstackcli/novacli/config.py new file mode 100644 index 00000000..7c362e88 --- /dev/null +++ b/cloudcafe/openstackcli/novacli/config.py @@ -0,0 +1,29 @@ +""" +Copyright 2013 Rackspace + +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. +""" +from cloudcafe.common.models.configuration import ConfigSectionInterface + + +class NovaCLI_Config(ConfigSectionInterface): + + SECTION_NAME = 'nova_cli' + + @property + def insecure(self): + return self.get_boolean('insecure', True) + + @property + def os_auth_system(self): + return self.get('os_auth_system') diff --git a/cloudcafe/openstackcli/novacli/models/__init__.py b/cloudcafe/openstackcli/novacli/models/__init__.py new file mode 100644 index 00000000..59ab77fa --- /dev/null +++ b/cloudcafe/openstackcli/novacli/models/__init__.py @@ -0,0 +1,15 @@ +""" +Copyright 2013 Rackspace + +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. +""" diff --git a/cloudcafe/openstackcli/novacli/models/extensions.py b/cloudcafe/openstackcli/novacli/models/extensions.py new file mode 100644 index 00000000..2311da67 --- /dev/null +++ b/cloudcafe/openstackcli/novacli/models/extensions.py @@ -0,0 +1,51 @@ +from cloudcafe.openstackcli.common.models.extensions import \ + ResponseExtensionType, SingleAttributeResponseExtension + +# Extensions defined here are registered in this list +extensions = [] + + +class OS_DCF_show(SingleAttributeResponseExtension): + __extends__ = 'ServerResponse' + key_name = 'OS-DCF:diskConfig' + attr_name = 'disk_config' + + +class ConfigDrive(SingleAttributeResponseExtension): + __extends__ = 'ServerResponse' + key_name = 'config_drive' + attr_name = 'config_drive' + + +class OS_EXT_STS_show(object): + __metaclass__ = ResponseExtensionType + __extends__ = 'ServerResponse' + _prefix = 'OS-EXT-STS' + _sub_attr_map = { + 'OS-EXT-STS:power_state': 'power_state', + 'OS-EXT-STS:task_state': 'task_state', + 'OS-EXT-STS:vm_state': 'vm_state'} + + def extend(cls, obj, **kwargs): + if obj.__class__.__name__ not in cls.__extends__: + return obj + + for kw_name, attr_name in cls._sub_attr_map.items(): + setattr(obj, attr_name, kwargs.get(kw_name, None)) + return obj + + +class OS_EXT_STS_list(object): + __metaclass__ = ResponseExtensionType + __extends__ = '_ServerListItem' + _sub_attr_map = { + 'Power State': 'power_state', + 'Task State': 'task_state'} + + def extend(cls, obj, **kwargs): + if obj.__class__.__name__ not in cls.__extends__: + return obj + + for kw_name, attr_name in cls._sub_attr_map.items(): + setattr(obj, attr_name, kwargs.get(kw_name, None)) + return obj diff --git a/cloudcafe/openstackcli/novacli/models/responses.py b/cloudcafe/openstackcli/novacli/models/responses.py new file mode 100644 index 00000000..d04ff7e2 --- /dev/null +++ b/cloudcafe/openstackcli/novacli/models/responses.py @@ -0,0 +1,80 @@ +# Used by models that inherit from an *Extensible* model +from cloudcafe.openstackcli.novacli.models.extensions import extensions + +from cloudcafe.openstackcli.common.models.responses import ( + BaseExtensibleModel, BasePrettyTableResponseModel, + BasePrettyTableResponseListModel) + + +class ServerResponse(BasePrettyTableResponseModel): + + def __init__( + self, status=None, updated=None, key_name=None, image=None, + host_id=None, flavor=None, id_=None, user_id=None, name=None, + admin_pass=None, tenant_id=None, created_at=None, access_ipv4=None, + access_ipv6=None, progress=None, metadata=None, + private_network=None, public_network=None, **kwargs): + + super(ServerResponse, self).__init__(**kwargs) + self.status = status + self.updated = updated + self.key_name = key_name + self.image = image + self.host_id = host_id + self.flavor = flavor + self.id_ = id_ + self.user_id = user_id + self.name = name + self.admin_pass = admin_pass + self.tenant_id = tenant_id + self.created_at = created_at + self.access_ipv4 = access_ipv4 + self.access_ipv6 = access_ipv6 + self.progress = progress + self.metadata = metadata + self.private_network = private_network + self.public_network = public_network + + @classmethod + def _prettytable_str_to_obj(cls, prettytable_string): + kwdict = cls._property_value_table_to_dict(prettytable_string) + kwmap = { + 'id_': 'id', + 'private_network': 'private network', + 'public_network': 'public network', + 'host_id': 'hostId', + 'access_ipv4': 'accessIPv4', + 'access_ipv6': 'accessIPv6', + 'admin_pass': 'adminPass'} + + kwdict = cls._apply_kwmap(kwmap, kwdict) + return ServerResponse(**kwdict) + + +class _ServerListItem(BaseExtensibleModel): + + def __init__( + self, id_=None, name=None, status=None, networks=None, **kwargs): + + super(_ServerListItem, self).__init__(**kwargs) + self.id_ = id_ + self.name = name + self.status = status + self.networks = networks + + +class ServerListResponse(BasePrettyTableResponseListModel): + + @classmethod + def _prettytable_str_to_obj(cls, prettytable_string): + server_list_response = ServerListResponse() + datatuple = cls._load_prettytable_string(prettytable_string) + for datadict in datatuple: + kwmap = { + 'id_': 'ID', + 'name': 'Name', + 'status': 'Status', + 'networks': 'Networks'} + kwdict = cls._apply_kwmap(kwmap, datadict) + server_list_response.append(_ServerListItem(**kwdict)) + return server_list_response diff --git a/metatests/openstackcli/novacli/__init__.py b/metatests/openstackcli/novacli/__init__.py new file mode 100644 index 00000000..59ab77fa --- /dev/null +++ b/metatests/openstackcli/novacli/__init__.py @@ -0,0 +1,15 @@ +""" +Copyright 2013 Rackspace + +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. +""" diff --git a/metatests/openstackcli/novacli/client_tests.py b/metatests/openstackcli/novacli/client_tests.py new file mode 100644 index 00000000..52f11f52 --- /dev/null +++ b/metatests/openstackcli/novacli/client_tests.py @@ -0,0 +1,199 @@ +import unittest +from cloudcafe.openstackcli.novacli.client import NovaCLI + + +class NovaCLI_InitializeClientWithAllArguments(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.novacli = NovaCLI( + os_cache=True, timings=True, timeout=30, + os_username='fake_username', os_password='fake_password', + os_tenant_name='FakeTenantName', os_tenant_id='1234567', + os_auth_url='os-auth-url', os_region_name='region_name', + os_auth_system='auth_system', service_type='service-type', + volume_service_name='vol serv name', endpoint_type='endpoint_type', + os_compute_api_version='v111', os_cacert='cert_here', + insecure=True, bypass_url='bypass_url') + cls.base_cmd = cls.novacli.base_cmd() + + def test_os_cache(self): + self.assertIn('--os-cache', self.base_cmd) + + def test_timings(self): + self.assertIn('--timings', self.base_cmd) + + def test_timeout(self): + self.assertIn('--timeout 30', self.base_cmd) + + def test_os_username(self): + self.assertIn('--os-username fake_username', self.base_cmd) + + def test_os_password(self): + self.assertIn('--os-password fake_password', self.base_cmd) + + def test_os_tenant_name(self): + self.assertIn('--os-tenant-name FakeTenantName', self.base_cmd) + + def test_os_tenant_id(self): + self.assertIn('--os-tenant-id 1234567', self.base_cmd) + + def test_os_auth_url(self): + self.assertIn('--os-auth-url os-auth-url', self.base_cmd) + + def test_os_region_name(self): + self.assertIn('--os-region-name region_name', self.base_cmd) + + def test_os_auth_system(self): + self.assertIn('--os-auth-system auth_system', self.base_cmd) + + def test_service_type(self): + self.assertIn('--service-type service-type', self.base_cmd) + + def test_volume_service_name(self): + self.assertIn('--volume-service-name vol serv name', self.base_cmd) + + def test_endpoint_type(self): + self.assertIn('--endpoint-type endpoint_type', self.base_cmd) + + def test_os_compute_api_version(self): + self.assertIn('--os-compute-api-version v111', self.base_cmd) + + def test_os_cacert(self): + self.assertIn('--os-cacert cert_here', self.base_cmd) + + def test_insecure(self): + self.assertIn('--insecure', self.base_cmd) + + def test_bypass_url(self): + self.assertIn('--bypass-url bypass_url', self.base_cmd) + + def test_no_arguments_positive(self): + novacli = NovaCLI() + self.assertEquals(novacli.base_cmd().strip(), 'nova') + + +class NovaCLI_CommandSerializationTests_CreateServer(unittest.TestCase): + + @classmethod + def setUpClass(cls): + + class FakeResponse(object): + def __init__(self, cmd): + self.command = cmd + self.standard_out = "fake standard out" + + cls.novacli = NovaCLI() + cls.novacli.run_command = lambda x: FakeResponse(x) + cls.command = cls.novacli.create_server( + name='fake name', + no_service_net=True, + no_public=True, + disk_config='auto', + flavor='fake flavor', + image='fake image', + boot_volume='fake boot volume', + snapshot='fake snapshot', + num_instances='55', + key_name='SomeKeyName', + user_data='SomeUserData', + availability_zone='SomeAvailabilityZone', + security_groups='SomeSecurityGroups', + swap=100, # Just so that there's an int in this test. + config_drive='/dev/sda', + image_with={'fake_image_with_key': 'fake_image_with_value'}, + meta={ + 'fake_meta_key1': 'fake_meta_value1', + 'fake_meta_key2': 'fake_meta_value2'}, + file_={'dst-path': 'src-path'}, + block_device_mapping={'dev-name': 'mapping'}, + block_device={'bdkey': 'bdvalue'}, + ephemeral={'size': 'SomeSize', 'format': 'SomeFormat'}, + hint={'HintKey': 'HintValue'}, + nic={ + 'net-id': 'Some-net-uuid', + 'port-id': 'Some-port-uuid', + 'v4-fixed-ip': 'Some-ip-addr'}).command + + def test_no_arguments(self): + r = self.novacli.create_server("") + self.assertEqual(r.command.strip(), "nova boot") + + def test_name(self): + self.assertIn("fake name", self.command) + + def test_no_service_net(self): + self.assertIn("--no-service-net", self.command) + + def test_no_public(self): + self.assertIn("--no-public", self.command) + + def test_disk_config(self): + self.assertIn("--disk-config auto", self.command) + + def test_flavor(self): + self.assertIn("--flavor fake flavor", self.command) + + def test_image(self): + self.assertIn("--image fake image", self.command) + + def test_boot_volume(self): + self.assertIn("--boot-volume fake boot volume", self.command) + + def test_snapshot(self): + self.assertIn("--snapshot fake snapshot", self.command) + + def test_num_instances(self): + self.assertIn("--num-instances 55", self.command) + + def test_key_name(self): + self.assertIn("--key-name SomeKeyName", self.command) + + def test_user_data(self): + self.assertIn("--user-data SomeUserData", self.command) + + def test_availability_zone(self): + self.assertIn("--availability-zone SomeAvailabilityZone", self.command) + + def test_security_groups(self): + self.assertIn("--security-groups SomeSecurityGroups", self.command) + + def test_swap(self): + self.assertIn("--swap 100", self.command) + + def test_config_drive(self): + self.assertIn("--config-drive /dev/sda", self.command) + + def test_image_with(self): + self.assertIn( + "--image-with 'fake_image_with_key'='fake_image_with_value'", + self.command) + + def test_meta(self): + self.assertIn( + "--meta 'fake_meta_key1'='fake_meta_value1'", self.command) + self.assertIn( + "--meta 'fake_meta_key2'='fake_meta_value2'", self.command) + + def test_file(self): + self.assertIn("--file 'dst-path'='src-path'", self.command) + + def test_block_device_mapping(self): + self.assertIn( + "--block-device-mapping 'dev-name'='mapping'", + self.command) + + def test_block_device(self): + self.assertIn("--block-device 'bdkey'='bdvalue'", self.command) + + def test_ephemeral(self): + self.assertIn( + "--ephemeral 'format'='SomeFormat' 'size'='SomeSize' ", + self.command) + + def test_hint(self): + self.assertIn("--hint 'HintKey'='HintValue'", self.command) + + def test_nic(self): + self.assertIn( + "--nic 'port-id'='Some-port-uuid' 'net-id'='Some-net-uuid' " + "'v4-fixed-ip'='Some-ip-addr'", self.command) diff --git a/metatests/openstackcli/novacli/models/__init__.py b/metatests/openstackcli/novacli/models/__init__.py new file mode 100644 index 00000000..59ab77fa --- /dev/null +++ b/metatests/openstackcli/novacli/models/__init__.py @@ -0,0 +1,15 @@ +""" +Copyright 2013 Rackspace + +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. +"""