Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENG-6650][ENG-6729] Remote Computing / Boa addon #171

Merged
merged 10 commits into from
Dec 18, 2024
2 changes: 2 additions & 0 deletions addon_imps/computing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""addon_imps.computing: imps that implement a computing-like interface
"""
34 changes: 34 additions & 0 deletions addon_imps/computing/boa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import logging

from boaapi.boa_client import (
BOA_API_ENDPOINT,
BoaClient,
BoaException,
)
from django.core.exceptions import ValidationError

from addon_toolkit.interfaces import computing


logger = logging.getLogger(__name__)


class BoaComputingImp(computing.ComputingAddonClientRequestorImp):
"""sending compute jobs to Iowa State's Boa cluster."""

@classmethod
def confirm_credentials(cls, credentials):
try:
boa_client = cls.create_client(credentials)
boa_client.close()
except BoaException:
raise ValidationError(
"Fail to validate username and password for "
"endpoint:({BOA_API_ENDPOINT})"
)

@staticmethod
def create_client(credentials):
boa_client = BoaClient(endpoint=BOA_API_ENDPOINT)
boa_client.login(credentials.username, credentials.password)
return boa_client
Empty file.
58 changes: 58 additions & 0 deletions addon_imps/tests/computing/test_boa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import logging
import unittest
from unittest.mock import (
MagicMock,
patch,
)

from boaapi.boa_client import (
BOA_API_ENDPOINT,
BoaException,
)
from django.core.exceptions import ValidationError

from addon_imps.computing.boa import BoaComputingImp
from addon_toolkit.credentials import UsernamePasswordCredentials
from addon_toolkit.interfaces.computing import ComputingConfig


logger = logging.getLogger(__name__)


class TestBoaComputingImp(unittest.IsolatedAsyncioTestCase):

@patch.object(BoaComputingImp, "create_client")
def setUp(self, create_client_mock):
self.base_url = BOA_API_ENDPOINT
self.config = ComputingConfig(external_api_url=self.base_url)
self.client = MagicMock()
self.credentials = UsernamePasswordCredentials(username="dog", password="woof")
self.imp = BoaComputingImp(config=self.config, credentials=self.credentials)
self.imp.client = self.client

@patch.object(BoaComputingImp, "create_client")
def test_confirm_credentials_success(self, create_client_mock):
creds = UsernamePasswordCredentials(username="dog", password="woof")
self.imp.confirm_credentials(creds)

create_client_mock.assert_called_once_with(creds)
create_client_mock.return_value.close.assert_called_once_with()

@patch.object(BoaComputingImp, "create_client", side_effect=BoaException("nope"))
def test_confirm_credentials_fail(self, create_client_mock):
creds = UsernamePasswordCredentials(username="dog", password="woof")
create_client_mock.return_value.side_effect = BoaException("could not login")
with self.assertRaises(ValidationError):
self.imp.confirm_credentials(creds)

create_client_mock.assert_called_once_with(creds)

@patch(f"{BoaComputingImp.__module__}.BoaClient")
def test_create_client(self, mock_cls):
mock_obj = MagicMock()
mock_cls.return_value = mock_obj
creds = UsernamePasswordCredentials(username="dog", password="woof")
BoaComputingImp.create_client(creds)

mock_cls.assert_called_once_with(endpoint=BOA_API_ENDPOINT)
mock_obj.login.assert_called_once_with("dog", "woof")
30 changes: 29 additions & 1 deletion addon_service/addon_imp/instantiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
CitationAddonImp,
CitationConfig,
)
from addon_toolkit.interfaces.computing import (
ComputingAddonImp,
ComputingConfig,
)
from addon_toolkit.interfaces.storage import (
StorageAddonClientRequestorImp,
StorageAddonHttpRequestorImp,
Expand All @@ -23,19 +27,22 @@
from addon_service.authorized_account.models import AuthorizedAccount
from addon_service.models import (
AuthorizedCitationAccount,
AuthorizedComputingAccount,
AuthorizedStorageAccount,
)


async def get_addon_instance(
imp_cls: type[AddonImp],
account: AuthorizedAccount,
config: StorageConfig | CitationConfig,
config: StorageConfig | CitationConfig | ComputingConfig,
) -> AddonImp:
if issubclass(imp_cls, StorageAddonImp):
return await get_storage_addon_instance(imp_cls, account, config)
elif issubclass(imp_cls, CitationAddonImp):
return await get_citation_addon_instance(imp_cls, account, config)
elif issubclass(imp_cls, ComputingAddonImp):
return await get_computing_addon_instance(imp_cls, account, config)
raise ValueError(f"unknown addon type {imp_cls}")


Expand Down Expand Up @@ -96,3 +103,24 @@ async def get_citation_addon_instance(


get_citation_addon_instance__blocking = async_to_sync(get_citation_addon_instance)


async def get_computing_addon_instance(
imp_cls: type[ComputingAddonImp],
account: AuthorizedComputingAccount,
config: ComputingConfig,
) -> ComputingAddonImp:
"""create an instance of a `ComputingAddonImp`"""

assert issubclass(imp_cls, ComputingAddonImp)
return imp_cls(
config=config,
network=GravyvaletHttpRequestor(
client_session=await get_singleton_client_session(),
prefix_url=config.external_api_url,
account=account,
),
)


get_computing_addon_instance__blocking = async_to_sync(get_computing_addon_instance)
3 changes: 2 additions & 1 deletion addon_service/addon_operation_invocation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from addon_service.models import AddonOperationModel
from addon_toolkit import AddonImp
from addon_toolkit.interfaces.citation import CitationConfig
from addon_toolkit.interfaces.computing import ComputingConfig
from addon_toolkit.interfaces.storage import StorageConfig


Expand Down Expand Up @@ -70,7 +71,7 @@ def imp_cls(self) -> type[AddonImp]:
return self.thru_account.imp_cls

@property
def config(self) -> StorageConfig | CitationConfig:
def config(self) -> StorageConfig | CitationConfig | ComputingConfig:
if self.thru_addon:
return get_config_for_addon(self.thru_addon)
return get_config_for_account(self.thru_account)
Expand Down
20 changes: 18 additions & 2 deletions addon_service/addon_operation_invocation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
from ..authorized_account.citation.serializers import (
AuthorizedCitationAccountSerializer,
)
from ..authorized_account.computing.serializers import (
AuthorizedComputingAccountSerializer,
)
from ..authorized_account.models import AuthorizedAccount
from ..authorized_account.storage.serializers import AuthorizedStorageAccountSerializer
from ..configured_addon.citation.serializers import ConfiguredCitationAddonSerializer
from ..configured_addon.computing.serializers import ConfiguredComputingAddonSerializer
from ..configured_addon.models import ConfiguredAddon
from ..configured_addon.storage.serializers import ConfiguredStorageAddonSerializer
from .models import AddonOperationInvocation
Expand Down Expand Up @@ -51,19 +55,31 @@ def retrieve_related(self, request, *args, **kwargs):
serializer = AuthorizedStorageAccountSerializer(
instance, context={"request": request}
)
else:
elif hasattr(instance, "authorizedcitationaccount"):
serializer = AuthorizedCitationAccountSerializer(
instance, context={"request": request}
)
elif hasattr(instance, "authorizedcomputingaccount"):
serializer = AuthorizedComputingAccountSerializer(
instance, context={"request": request}
)
else:
raise ValueError("unknown authorized account type")
elif isinstance(instance, ConfiguredAddon):
if hasattr(instance, "configuredstorageaddon"):
serializer = ConfiguredStorageAddonSerializer(
instance, context={"request": request}
)
else:
elif hasattr(instance, "configuredcitationaddon"):
serializer = ConfiguredCitationAddonSerializer(
instance, context={"request": request}
)
elif hasattr(instance, "configuredcomputingaddon"):
serializer = ConfiguredComputingAddonSerializer(
instance, context={"request": request}
)
else:
raise ValueError("unknown configured addon type")
else:
serializer = self.get_related_serializer(instance)
return Response(serializer.data)
Expand Down
20 changes: 20 additions & 0 deletions addon_service/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from addon_service.common import known_imps
from addon_service.common.credentials_formats import CredentialsFormats
from addon_service.common.service_types import ServiceTypes
from addon_service.external_service.computing.models import ComputingSupportedFeatures
from addon_service.external_service.storage.models import StorageSupportedFeatures

from ..external_service.citation.models import CitationSupportedFeatures
Expand Down Expand Up @@ -49,6 +50,25 @@ class ExternalCitationServiceAdmin(GravyvaletModelAdmin):
}


@admin.register(models.ExternalComputingService)
class ExternalComputingServiceAdmin(GravyvaletModelAdmin):
list_display = ("display_name", "created", "modified")
readonly_fields = (
"id",
"created",
"modified",
)
raw_id_fields = ("oauth2_client_config",)
enum_choice_fields = {
"int_addon_imp": known_imps.AddonImpNumbers,
"int_credentials_format": CredentialsFormats,
"int_service_type": ServiceTypes,
}
enum_multiple_choice_fields = {
"int_supported_features": ComputingSupportedFeatures,
}


@admin.register(models.OAuth2ClientConfig)
@linked_many_field("external_storage_services")
@linked_many_field("external_citation_services")
Expand Down
Empty file.
35 changes: 35 additions & 0 deletions addon_service/authorized_account/computing/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from addon_service.addon_imp.instantiation import get_computing_addon_instance
from addon_service.authorized_account.models import AuthorizedAccount
from addon_toolkit.interfaces.computing import ComputingConfig


class AuthorizedComputingAccount(AuthorizedAccount):
"""Model for describing a user's account on an ExternalComputingService.

This model collects all of the information required to actually perform remote
operations against the service and to aggregate accounts under a known user.
"""

class Meta:
verbose_name = "Authorized Computing Account"
verbose_name_plural = "Authorized Computing Accounts"
app_label = "addon_service"

class JSONAPIMeta:
resource_name = "authorized-computing-accounts"

async def execute_post_auth_hook(self, auth_extras: dict | None = None):
imp = await get_computing_addon_instance(
self.imp_cls,
self,
self.computing_imp_config,
)
self.external_account_id = await imp.get_external_account_id(auth_extras or {})
await self.asave()

@property
def config(self) -> ComputingConfig:
return ComputingConfig(
external_api_url=self.api_base_url,
external_account_id=self.external_account_id,
)
76 changes: 76 additions & 0 deletions addon_service/authorized_account/computing/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from rest_framework_json_api import serializers
from rest_framework_json_api.relations import (
HyperlinkedRelatedField,
ResourceRelatedField,
)
from rest_framework_json_api.utils import get_resource_type_from_model

from addon_service.addon_operation.models import AddonOperationModel
from addon_service.authorized_account.serializers import AuthorizedAccountSerializer
from addon_service.common import view_names
from addon_service.common.serializer_fields import (
DataclassRelatedLinkField,
ReadOnlyResourceRelatedField,
)
from addon_service.models import (
AuthorizedComputingAccount,
ConfiguredComputingAddon,
ExternalComputingService,
UserReference,
)


RESOURCE_TYPE = get_resource_type_from_model(AuthorizedComputingAccount)


class AuthorizedComputingAccountSerializer(AuthorizedAccountSerializer):
external_computing_service = ResourceRelatedField(
queryset=ExternalComputingService.objects.all(),
many=False,
source="external_service.externalcomputingservice",
related_link_view_name=view_names.related_view(RESOURCE_TYPE),
)
configured_computing_addons = HyperlinkedRelatedField(
many=True,
queryset=ConfiguredComputingAddon.objects.active(),
related_link_view_name=view_names.related_view(RESOURCE_TYPE),
required=False,
)
url = serializers.HyperlinkedIdentityField(
view_name=view_names.detail_view(RESOURCE_TYPE), required=False
)
account_owner = ReadOnlyResourceRelatedField(
many=False,
queryset=UserReference.objects.all(),
related_link_view_name=view_names.related_view(RESOURCE_TYPE),
)
authorized_operations = DataclassRelatedLinkField(
dataclass_model=AddonOperationModel,
related_link_view_name=view_names.related_view(RESOURCE_TYPE),
)

included_serializers = {
"account_owner": "addon_service.serializers.UserReferenceSerializer",
"external_computing_service": "addon_service.serializers.ExternalComputingServiceSerializer",
"configured_computing_addons": "addon_service.serializers.ConfiguredComputingAddonSerializer",
"authorized_operations": "addon_service.serializers.AddonOperationSerializer",
}

class Meta:
model = AuthorizedComputingAccount
fields = [
"id",
"url",
"display_name",
"account_owner",
"api_base_url",
"auth_url",
"authorized_capabilities",
"authorized_operations",
"authorized_operation_names",
"configured_computing_addons",
"credentials",
"external_computing_service",
"initiate_oauth",
"credentials_available",
]
9 changes: 9 additions & 0 deletions addon_service/authorized_account/computing/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from addon_service.authorized_account.views import AuthorizedAccountViewSet

from .models import AuthorizedComputingAccount
from .serializers import AuthorizedComputingAccountSerializer


class AuthorizedComputingAccountViewSet(AuthorizedAccountViewSet):
queryset = AuthorizedComputingAccount.objects.all()
serializer_class = AuthorizedComputingAccountSerializer
4 changes: 4 additions & 0 deletions addon_service/authorized_account/polymorphic_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from addon_service.authorized_account.citation.serializers import (
AuthorizedCitationAccountSerializer,
)
from addon_service.authorized_account.computing.serializers import (
AuthorizedComputingAccountSerializer,
)
from addon_service.authorized_account.models import AuthorizedAccount
from addon_service.authorized_account.storage.serializers import (
AuthorizedStorageAccountSerializer,
Expand All @@ -12,6 +15,7 @@
class AuthorizedAccountPolymorphicSerializer(serializers.PolymorphicModelSerializer):
polymorphic_serializers = [
AuthorizedCitationAccountSerializer,
AuthorizedComputingAccountSerializer,
AuthorizedStorageAccountSerializer,
]

Expand Down
Loading
Loading