From 43f9bf609bb0be381881d4b668bf768c38223e83 Mon Sep 17 00:00:00 2001 From: Quan Pham Date: Sun, 10 Mar 2024 16:32:27 -0400 Subject: [PATCH] Intergrated ESI, allowing users to allocate ESI Network quotas --- README.md | 3 + ci/devstack_esi.sh | 2 + ci/run_functional_tests_esi.sh | 32 ++++++ src/coldfront_plugin_cloud/attributes.py | 6 ++ src/coldfront_plugin_cloud/esi.py | 72 +++++++++++++ .../management/commands/add_esi_resource.py | 100 ++++++++++++++++++ .../commands/register_cloud_attributes.py | 3 + .../commands/validate_allocations.py | 14 +++ src/coldfront_plugin_cloud/openstack.py | 9 -- src/coldfront_plugin_cloud/tasks.py | 6 ++ src/coldfront_plugin_cloud/tests/base.py | 20 +++- .../tests/functional/esi/test_allocations.py | 32 ++++++ .../functional/openstack/test_allocation.py | 2 +- .../openstack_mini/test_allocation.py | 2 +- .../tests/unit/test_attribute_migration.py | 2 +- 15 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 ci/devstack_esi.sh create mode 100644 ci/run_functional_tests_esi.sh create mode 100644 src/coldfront_plugin_cloud/esi.py create mode 100644 src/coldfront_plugin_cloud/management/commands/add_esi_resource.py create mode 100644 src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py diff --git a/README.md b/README.md index 162f68d7..5017d1e7 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,9 @@ usage: coldfront add_openshift_resource [-h] --name NAME --auth-url AUTH_URL [-- coldfront add_openshift_resource: error: the following arguments are required: --name, --auth-url ``` +### Configuring for ESI +(Quan Pham) TODO Add instructions for configuring ESI + ### Quotas The amount of quota to start out a resource allocation after approval, can be diff --git a/ci/devstack_esi.sh b/ci/devstack_esi.sh new file mode 100644 index 00000000..95edf8b4 --- /dev/null +++ b/ci/devstack_esi.sh @@ -0,0 +1,2 @@ +# (Quan Pham) TODO Know we want to install in a test Devstack cluster +# to test ESI diff --git a/ci/run_functional_tests_esi.sh b/ci/run_functional_tests_esi.sh new file mode 100644 index 00000000..20a4483c --- /dev/null +++ b/ci/run_functional_tests_esi.sh @@ -0,0 +1,32 @@ +# Creates the appropriate credentials and runs tests +# +# Tests expect the resource to be name ESI +set -xe + +source /opt/stack/devstack/openrc admin admin + +credential_name=$(openssl rand -base64 12) + +export ESI_ESI_APPLICATION_CREDENTIAL_SECRET=$( + openstack application credential create "$credential_name" -f value -c secret) +export ESI_ESI_APPLICATION_CREDENTIAL_ID=$( + openstack application credential show "$credential_name" -f value -c id) + +export ESI_PUBLIC_NETWORK_ID=$(openstack network show public -f value -c id) + +if [[ ! "${CI}" == "true" ]]; then + source /tmp/coldfront_venv/bin/activate +fi + +export DJANGO_SETTINGS_MODULE="local_settings" +export FUNCTIONAL_TESTS="True" +export OS_AUTH_URL="http://$HOST_IP/identity" +export KEYCLOAK_URL="http://$HOST_IP:8080" +export KEYCLOAK_USER="admin" +export KEYCLOAK_PASS="nomoresecret" +export KEYCLOAK_REALM="master" + +coverage run --source="." -m django test coldfront_plugin_cloud.tests.functional.esi +coverage report + +openstack application credential delete $ESI_ESI_APPLICATION_CREDENTIAL_ID diff --git a/src/coldfront_plugin_cloud/attributes.py b/src/coldfront_plugin_cloud/attributes.py index 0c146905..3da098ed 100644 --- a/src/coldfront_plugin_cloud/attributes.py +++ b/src/coldfront_plugin_cloud/attributes.py @@ -89,6 +89,10 @@ class CloudAllocationAttribute: QUOTA_REQUESTS_GPU = 'OpenShift Request on GPU Quota' QUOTA_PVC = 'OpenShift Persistent Volume Claims Quota' +########################################################### +# ESI Quota Attributes +ESI_FLOATING_IPS = 'ESI Floating IP Quota' +ESI_NETWORKS = 'ESI Network Quota' ALLOCATION_QUOTA_ATTRIBUTES = [ CloudAllocationAttribute(name=QUOTA_INSTANCES), @@ -105,4 +109,6 @@ class CloudAllocationAttribute: CloudAllocationAttribute(name=QUOTA_REQUESTS_STORAGE), CloudAllocationAttribute(name=QUOTA_REQUESTS_GPU), CloudAllocationAttribute(name=QUOTA_PVC), + CloudAllocationAttribute(name=ESI_FLOATING_IPS), + CloudAllocationAttribute(name=ESI_NETWORKS) ] diff --git a/src/coldfront_plugin_cloud/esi.py b/src/coldfront_plugin_cloud/esi.py new file mode 100644 index 00000000..0e4610c6 --- /dev/null +++ b/src/coldfront_plugin_cloud/esi.py @@ -0,0 +1,72 @@ +import logging +import functools +import os + +from keystoneauth1.identity import v3 +from keystoneauth1 import session + + +from coldfront_plugin_cloud import attributes, utils + +from coldfront_plugin_cloud.openstack import OpenStackResourceAllocator + +logger = logging.getLogger(__name__) + +QUOTA_KEY_MAPPING = { + 'network': { + 'keys': { + attributes.ESI_FLOATING_IPS: 'floatingip', + attributes.ESI_NETWORKS: 'network' + } + }, +} + +def get_session_for_resource(resource): + auth_url = resource.get_attribute(attributes.RESOURCE_AUTH_URL) + var_name = utils.env_safe_name(resource.name) + auth = v3.ApplicationCredential( + auth_url=auth_url, + application_credential_id=os.environ.get( + f'ESI_{var_name}_APPLICATION_CREDENTIAL_ID'), + application_credential_secret=os.environ.get( + f'ESI_{var_name}_APPLICATION_CREDENTIAL_SECRET') + ) + return session.Session( + auth, + verify=os.environ.get('FUNCTIONAL_TESTS', '') != 'True' + ) + + +class ESIResourceAllocator(OpenStackResourceAllocator): + + resource_type = 'esi' + + @functools.cached_property + def session(self) -> session.Session: + return get_session_for_resource(self.resource) + + def set_quota(self, project_id): + for service_name, service in QUOTA_KEY_MAPPING.items(): + if service_name not in self.available_service_types: + logger.error(f"Service {service_name} needed for ESI allocation not available!") + else: + payload = dict() + for coldfront_attr, openstack_key in service['keys'].items(): + value = self.allocation.get_attribute(coldfront_attr) + if value is not None: + payload[openstack_key] = value + + if not payload: + # Skip if service doesn't have any associated attributes + continue + + if service_name == 'network': + self.network.update_quota(project_id, body={'quota': payload}) + + + def get_quota(self, project_id): + quotas = dict() + + network_quota = self.network.show_quota(project_id)['quota'] + for k in QUOTA_KEY_MAPPING['network']['keys'].values(): + quotas[k] = network_quota.get(k) diff --git a/src/coldfront_plugin_cloud/management/commands/add_esi_resource.py b/src/coldfront_plugin_cloud/management/commands/add_esi_resource.py new file mode 100644 index 00000000..6c945284 --- /dev/null +++ b/src/coldfront_plugin_cloud/management/commands/add_esi_resource.py @@ -0,0 +1,100 @@ +from django.core.management.base import BaseCommand +from django.core.management import call_command + +from coldfront.core.resource.models import (Resource, + ResourceAttribute, + ResourceAttributeType, + ResourceType) + +from coldfront_plugin_cloud import attributes + + +class Command(BaseCommand): + help = 'Create ESI resource' + # (Quan Pham) TODO Know what other attributes an ESI resource must/could have + # That could be set from cli, or through other means + + def add_arguments(self, parser): + parser.add_argument('--name', type=str, required=True, + help='Name of ESI resource') + parser.add_argument('--auth-url', type=str, required=True, + help='URL of the ESI esi Identity Endpoint') + parser.add_argument('--users-domain', type=str, default='default', + help='Domain ID to create users') + parser.add_argument('--projects-domain', type=str, default='default', + help='Domain ID to create projects') + parser.add_argument('--idp', type=str, required=True, + help='Identity provider configured in ESI cluster') + parser.add_argument('--protocol', type=str, default='openid', + help='Federation protocol (default: openid)') + parser.add_argument('--role', type=str, default='member', + help='Role for user when added to project (default: member)') + parser.add_argument('--public-network', type=str, default='', + help='Public network ID for default networks. ' + 'If not specified, no default network is ' + 'created for new projects.') + parser.add_argument('--network-cidr', type=str, default='192.168.0.0/24', + help='CIDR for default networks. ' + 'Ignored if no --public-network.') + + def handle(self, *args, **options): + esi, _ = Resource.objects.get_or_create( + resource_type=ResourceType.objects.get(name='ESI'), + parent_resource=None, + name=options['name'], + description='ESI Bare Metal environment', + is_available=True, + is_public=True, + is_allocatable=True + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_AUTH_URL), + resource=esi, + value=options['auth_url'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_PROJECT_DOMAIN), + resource=esi, + value=options['projects_domain'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_USER_DOMAIN), + resource=esi, + value=options['users_domain'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_IDP), + resource=esi, + value=options['idp'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_FEDERATION_PROTOCOL), + resource=esi, + value=options['protocol'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_ROLE), + resource=esi, + value=options['role'] + ) + + if options['public_network']: + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_DEFAULT_PUBLIC_NETWORK), + resource=esi, + value=options['public_network'] + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get( + name=attributes.RESOURCE_DEFAULT_NETWORK_CIDR), + resource=esi, + value=options['network_cidr'] + ) diff --git a/src/coldfront_plugin_cloud/management/commands/register_cloud_attributes.py b/src/coldfront_plugin_cloud/management/commands/register_cloud_attributes.py index 5ed78d40..059e7599 100644 --- a/src/coldfront_plugin_cloud/management/commands/register_cloud_attributes.py +++ b/src/coldfront_plugin_cloud/management/commands/register_cloud_attributes.py @@ -123,6 +123,9 @@ def register_resource_type(self): resource_models.ResourceType.objects.get_or_create( name='OpenShift', description='OpenShift Cloud' ) + resource_models.ResourceType.objects.get_or_create( + name='ESI', description='ESI Bare Metal Cloud' + ) def handle(self, *args, **options): self.register_resource_type() diff --git a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py index ff838d01..884c2191 100644 --- a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py +++ b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py @@ -273,3 +273,17 @@ def handle(self, *args, **options): except Exception as e: logger.error(f'setting openshift quota failed: {e}') continue + + + # (Quan Pham) TODO Implement validating ESI resource + # Should these validation code be put in a sepearate file or + # in tasks.py or utils.py as functions for each resource type? + esi_resources = Resource.objects.filter( + resource_type=ResourceType.objects.get( + name='ESI' + ) + ) + esi_allocations = Allocation.objects.filter( + resources__in=esi_resources, + status=AllocationStatusChoice.objects.get(name='Active') + ) \ No newline at end of file diff --git a/src/coldfront_plugin_cloud/openstack.py b/src/coldfront_plugin_cloud/openstack.py index ffc4da2a..d71f8ec1 100644 --- a/src/coldfront_plugin_cloud/openstack.py +++ b/src/coldfront_plugin_cloud/openstack.py @@ -23,12 +23,6 @@ # Map the attribute name in ColdFront, to the client of the respective # service, the version of the API, and the key in the payload. -# (Quan Pham) TODO: The name of the services should be the same as the -# names or aliases listed in the Openstack Service Type Authority list -# (https://opendev.org/openstack/service-types-authority/) -# -# This is because we query the Openstack cluster to check what services -# are available. It is probably a bad idea to hardcode the service names here QUOTA_KEY_MAPPING = { 'compute': { 'keys': { @@ -149,8 +143,6 @@ def __init__(self, resource, allocation): service_list = self.identity.services.list() self.available_service_types = [service.type for service in service_list] - - def create_project(self, suggested_project_name) -> base.ResourceAllocator.Project: project_name = utils.get_unique_project_name( suggested_project_name, @@ -209,7 +201,6 @@ def _set_object_quota(self, project_id, payload): payload[obj_q_mapping] *= GB_IN_BYTES if payload[obj_q_mapping] <= 0: payload[obj_q_mapping] = 1 - self.object(project_id).post_account(headers=payload) except ksa_exceptions.catalog.EndpointNotFound: logger.debug('No swift available, skipping its quota.') diff --git a/src/coldfront_plugin_cloud/tasks.py b/src/coldfront_plugin_cloud/tasks.py index 4a3c0eb8..113084c3 100644 --- a/src/coldfront_plugin_cloud/tasks.py +++ b/src/coldfront_plugin_cloud/tasks.py @@ -10,6 +10,7 @@ base, openstack, openshift, + esi, utils) logger = logging.getLogger(__name__) @@ -48,6 +49,10 @@ }, 'openshift': { attributes.QUOTA_REQUESTS_GPU: 0, + }, + 'esi': { + attributes.ESI_FLOATING_IPS: 1, + attributes.ESI_NETWORKS: 1 } } @@ -56,6 +61,7 @@ def find_allocator(allocation) -> base.ResourceAllocator: allocators = { 'openstack': openstack.OpenStackResourceAllocator, 'openshift': openshift.OpenShiftResourceAllocator, + 'esi': esi.ESIResourceAllocator, } # TODO(knikolla): It doesn't seem to be possible to select multiple resources # when requesting a new allocation, so why is this multivalued? diff --git a/src/coldfront_plugin_cloud/tests/base.py b/src/coldfront_plugin_cloud/tests/base.py index e078a814..6a979196 100644 --- a/src/coldfront_plugin_cloud/tests/base.py +++ b/src/coldfront_plugin_cloud/tests/base.py @@ -41,9 +41,27 @@ def new_user(username=None) -> User: username = username or f'{uuid.uuid4().hex}@example.com' User.objects.create(username=username, email=username) return User.objects.get(username=username) + + @staticmethod + def new_esi_resource(name=None, auth_url=None) -> Resource: + resource_name = name or uuid.uuid4().hex + + call_command( + 'add_esi_resource', + name=resource_name, + auth_url=auth_url or f'https://{resource_name}/identity/v3', + projects_domain='default', + users_domain='default', + idp='sso', + protocol='openid', + role='member', + public_network=os.getenv('ESI_PUBLIC_NETWORK_ID'), + network_cidr='192.168.0.0/24', + ) + return Resource.objects.get(name=resource_name) @staticmethod - def new_resource(name=None, auth_url=None) -> Resource: + def new_openstack_resource(name=None, auth_url=None) -> Resource: resource_name = name or uuid.uuid4().hex call_command( diff --git a/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py b/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py new file mode 100644 index 00000000..fcbde254 --- /dev/null +++ b/src/coldfront_plugin_cloud/tests/functional/esi/test_allocations.py @@ -0,0 +1,32 @@ +import os +import unittest +import uuid + +from coldfront_plugin_cloud import esi +from coldfront_plugin_cloud.tests import base + +from django.core.management import call_command +from keystoneclient.v3 import client +from cinderclient import client as cinderclient +from neutronclient.v2_0 import client as neutronclient +from novaclient import client as novaclient + +@unittest.skipUnless(os.getenv('FUNCTIONAL_TESTS'), 'Functional tests not enabled.') +class TestAllocation(base.TestBase): + + # (Quan Pham) TODO What are the test cases for an ESI resource? + # What do we need to test? + + def setUp(self) -> None: + super().setUp() + self.resource = self.new_esi_resource(name='ESI', + auth_url=os.getenv('OS_AUTH_URL')) + self.session = esi.get_session_for_resource(self.resource) + self.identity = client.Client(session=self.session) + self.compute = novaclient.Client(session=self.session, version=2) + self.volume = cinderclient.Client(session=self.session, version=3) + self.networking = neutronclient.Client(session=self.session) + self.role_member = self.identity.roles.find(name='member') + + def test_new_ESI_allocation(self): + pass diff --git a/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py b/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py index 2c523e76..7bddb97c 100644 --- a/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py +++ b/src/coldfront_plugin_cloud/tests/functional/openstack/test_allocation.py @@ -18,7 +18,7 @@ class TestAllocation(base.TestBase): def setUp(self) -> None: super().setUp() - self.resource = self.new_resource(name='Devstack', + self.resource = self.new_openstack_resource(name='Devstack', auth_url=os.getenv('OS_AUTH_URL')) self.session = openstack.get_session_for_resource(self.resource) self.identity = client.Client(session=self.session) diff --git a/src/coldfront_plugin_cloud/tests/functional/openstack_mini/test_allocation.py b/src/coldfront_plugin_cloud/tests/functional/openstack_mini/test_allocation.py index 8842b225..4680c47e 100644 --- a/src/coldfront_plugin_cloud/tests/functional/openstack_mini/test_allocation.py +++ b/src/coldfront_plugin_cloud/tests/functional/openstack_mini/test_allocation.py @@ -21,7 +21,7 @@ class TestSmallOpenstack(base.TestBase): """ def setUp(self) -> None: super().setUp() - self.resource = self.new_resource(name='Devstack', + self.resource = self.new_openstack_resource(name='Devstack', auth_url=os.getenv('OS_AUTH_URL')) self.session = openstack.get_session_for_resource(self.resource) self.identity = client.Client(session=self.session) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_attribute_migration.py b/src/coldfront_plugin_cloud/tests/unit/test_attribute_migration.py index 9e886994..48dae117 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_attribute_migration.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_attribute_migration.py @@ -154,7 +154,7 @@ def test_rename_identity_url(self): ): call_command('register_cloud_attributes') - resource = self.new_resource('Example', auth_url_val) + resource = self.new_openstack_resource('Example', auth_url_val) self.assertEqual( resource.get_attribute(new_auth_url_name),