diff --git a/README.md b/README.md index 058313719a501d26def5f55905e01d3af8a465a0..46d270160be0002d4ccc6c1903b69e05c836a278 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,84 @@ # perxis-python +## Расширения +### Пример подключения расширения Рє Perxis +... + +### Пример написания сервиса расширений +```python +from perxis.extensions.extension_service import ExtensionService +from perxis.roles import roles_pb2 +from perxis.common import common_pb2 +from perxis.clients import clients_pb2 + +from perxis.collections import collections_pb2 + + +def make_collection_instances(schemes_mapping: dict[str, str]) -> list[collections_pb2.Collection]: + collections = [] + + for collection_id, collection_name in schemes_mapping.items(): + with open(f"./schemes/{collection_id}.json", "r") as file: + collection_schema = file.read() + + collection = collections_pb2.Collection( + id=collection_id, + name=collection_name, + schema=collection_schema + ) + + collections.append(collection) + + return collections + +... + +schemes_mapping = { + "dealers_cities": "Дилеры/Города", + "dealers_contacts": "Дилеры/Контакты", + "dealers_countries": "Дилеры/Страны", + "dealers_dealers": "Дилеры/Дилеры", + "dealers_dealerships": "Дилеры/Дилерские центры", + "dealers_department_types": "Дилеры/РўРёРїС‹ отделов", + "dealers_departments": "Дилеры/Отделы", + "dealers_events": "Дилеры/События", + "dealers_identifiers": "Дилеры/Рдентификаторы", + "dealers_schedules": "Дилеры/Графики работы", + "dealers_services": "Дилеры/Услуги" +} + +... + +class Servicer(ExtensionService): + extension_id = "some_id" + collections = make_collection_instances(schemes_mapping) + roles = [ + roles_pb2.Role( + id="my-role", + description="Описание Рє роли", + rules=[ + common_pb2.Rule( + collection_id="dealers_cities", + actions=[common_pb2.CREATE, common_pb2.UPDATE, common_pb2.DELETE], + ) + ], + environments=["*"], + allow_management=False, + ) + ] + clients = [ + clients_pb2.Client( + id="my-client", + name="Клиент, созданный расширением", + description="Описание созданного расширением клиента", + role_id="my-role", + api_key={ + "rotate": True + } + ) + ] +``` + ## Аутентификация gRPC Python предоставляет СЃРїРѕСЃРѕР± перехвата RPC Рё добавления метаданных, diff --git a/perxis/extensions/extension_service.py b/perxis/extensions/extension_service.py new file mode 100644 index 0000000000000000000000000000000000000000..aabf5b6938f468206306ed85c70493c2b91b4bcc --- /dev/null +++ b/perxis/extensions/extension_service.py @@ -0,0 +1,126 @@ +import grpc + +from perxis.extensions import extension_pb2, extension_pb2_grpc +from perxis.roles import roles_pb2_grpc, roles_pb2 +from perxis.clients import clients_pb2_grpc, clients_pb2 +from perxis.collections import collections_pb2_grpc, collections_pb2 +from perxis.environments import environments_pb2_grpc +from perxis.extensions.extension_setup import ExtensionSetup + + +class ExtensionService(extension_pb2_grpc.ExtensionServicer): + extension_id: str + collections: list[collections_pb2.Collection] = [] + roles: list[roles_pb2.Role] = [] + clients: list[clients_pb2.Client] = [] + + def __init__(self, + collections_service: collections_pb2_grpc.CollectionsStub, + environments_service: environments_pb2_grpc.EnvironmentsStub, + roles_service: roles_pb2_grpc.RolesStub, + clients_service: clients_pb2_grpc.ClientsStub, + ): + self.collections_service = collections_service + self.environments_service = environments_service + self.roles_service = roles_service + self.clients_service = clients_service + + self.extension_setup = ExtensionSetup( + self.collections_service, self.environments_service, + self.roles_service, self.clients_service + ) + + for collection in self.collections or []: + self.extension_setup.add_collection(collection) + + for role in self.roles or []: + self.extension_setup.add_role(role) + + for client in self.clients or []: + self.extension_setup.add_client(client) + + def Install(self, request: extension_pb2.InstallRequest, context): + errors_list = self.extension_setup.install( + request.space_id, request.env_id, request.force + ) + + if errors_list: + context.set_code(grpc.StatusCode.UNKNOWN) + context.set_details("; ".join(errors_list)) + + response_state = extension_pb2.ExtensionRequestResult.State.OK \ + if not errors_list \ + else extension_pb2.ExtensionRequestResult.State.ERROR + + return extension_pb2.InstallResponse( + results=[extension_pb2.ExtensionRequestResult( + extension=self.extension_id, + state=response_state, + error="; ".join(errors_list) if errors_list else None, + msg="Ok" if not errors_list else None + )] + ) + + def Update(self, request: extension_pb2.UpdateRequest, context): + errors_list = self.extension_setup.update( + request.space_id, request.env_id, request.force + ) + + if errors_list: + context.set_code(grpc.StatusCode.UNKNOWN) + context.set_details("; ".join(errors_list)) + + response_state = extension_pb2.ExtensionRequestResult.State.OK \ + if not errors_list \ + else extension_pb2.ExtensionRequestResult.State.ERROR + + return extension_pb2.UpdateResponse( + results=[extension_pb2.ExtensionRequestResult( + extension=self.extension_id, + state=response_state, + error="; ".join(errors_list) if errors_list else None, + msg="Ok" if not errors_list else None + )] + ) + + def Uninstall(self, request: extension_pb2.UninstallRequest, context): + errors_list: list[str] = self.extension_setup.uninstall(request.space_id, request.env_id, request.remove) + + response_state = extension_pb2.ExtensionRequestResult.State.OK \ + if not errors_list \ + else extension_pb2.ExtensionRequestResult.State.ERROR + + return extension_pb2.UninstallResponse( + results=[extension_pb2.ExtensionRequestResult( + extension=self.extension_id, + state=response_state, + error="; ".join(errors_list) if errors_list else None, + msg="Ok" if not errors_list else None + )] + ) + + def Check(self, request: extension_pb2.CheckRequest, context): + errors_list = self.extension_setup.check(request.space_id, request.env_id) + + if errors_list: + context.set_code(grpc.StatusCode.UNKNOWN) + context.set_details("; ".join(errors_list)) + + response_state = extension_pb2.ExtensionRequestResult.State.OK \ + if not errors_list \ + else extension_pb2.ExtensionRequestResult.State.ERROR + + return extension_pb2.CheckResponse( + results=[extension_pb2.ExtensionRequestResult( + extension=self.extension_id, + state=response_state, + error="; ".join(errors_list) if errors_list else None, + msg="Ok" if not errors_list else None + )] + ) + + def Action(self, request: extension_pb2.ActionRequest, context): + context.set_code(grpc.StatusCode.UNKNOWN) + context.set_details("Unknown action") + + return None diff --git a/perxis/extensions/extension_setup.py b/perxis/extensions/extension_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..fa1981b441040251ed230365cec7c2992c73440d --- /dev/null +++ b/perxis/extensions/extension_setup.py @@ -0,0 +1,460 @@ +import grpc +import json +import time +import copy +import typing + + +from deepdiff import DeepDiff +from perxis.collections import collections_pb2_grpc, collections_pb2 +from perxis.roles import roles_pb2_grpc, roles_pb2 +from perxis.clients import clients_pb2_grpc, clients_pb2 +from perxis.environments import environments_pb2_grpc, environments_pb2 + + +class ExtensionSetup: + def __init__( + self, + collections_service: collections_pb2_grpc.CollectionsStub, + environments_service: environments_pb2_grpc.EnvironmentsStub, + roles_service: roles_pb2_grpc.RolesStub, + clients_service: clients_pb2_grpc.ClientsStub, + ): + self.collections = [] + self.clients = [] + self.roles = [] + + self.roles_service = roles_service + self.clients_service = clients_service + self.collections_service = collections_service + self.environments_service = environments_service + + self.__max_attempts_count = 5 + self.__sleep_time = 1 + + def add_collection(self, collection: collections_pb2.Collection): + self.collections.append(collection) + + def set_collections(self, collections: list[collections_pb2.Collection]): + self.collections = collections + + def add_role(self, role: roles_pb2.Role): + self.roles.append(role) + + def set_roles(self, roles: list[roles_pb2.Role]): + self.roles = roles + + def add_client(self, client: clients_pb2.Client): + self.clients.append(client) + + def set_clients(self, clients: list[clients_pb2.Client]): + self.clients = clients + + # Работа СЃ ролями + def __remove_roles(self, space_id: str) -> list[str]: + errors_list: list[str] = [] + + for role in self.roles: + try: + self.roles_service.Delete.with_call( + roles_pb2.DeleteRequest( + space_id=space_id, role_id=role.id + ) + ) + except grpc.RpcError as e: + # Если роли РЅРµ существует считать это ошибкой РЅРµ надо + if "not found" not in e.details(): + errors_list.append(f"РќРµ удалось удалить роль {role.id}, {e.details()}") + + return errors_list + + def __check_roles(self, space_id: str) -> list[str]: + errors_list = [] + + for role in self.roles: + try: + self.roles_service.Get.with_call( + roles_pb2.GetRequest(space_id=space_id, role_id=role.id) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось получить роль {role.id}, ошибка {e.details()}") + + return errors_list + + def __update_roles(self, space_id: str) -> list[str]: + errors_list = [] + + for local_role in self.roles: + try: + cloned_role = copy.deepcopy(local_role) + cloned_role.space_id = space_id + + # Полностью замещать данные роли тем что имеется РІ расширении + # TODO: РЅСѓР¶РЅРѕ ли мержить права чтобы РЅРµ терять изменения? + self.roles_service.Update.with_call( + roles_pb2.UpdateRequest( + role=cloned_role + ) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось обновить роль {local_role.id}, {e.details()}") + + return errors_list + + def __create_roles(self, space_id: str) -> list[str]: + errors_list = [] + + for local_role in self.roles: + try: + cloned_role = copy.deepcopy(local_role) + cloned_role.space_id = space_id + + response, _ = self.roles_service.Create.with_call( + roles_pb2.CreateRequest( + role=cloned_role + ), + ) + except grpc.RpcError as e: + # РќР° этапе install считается что ролей __нет__. РџСЂРё install СЃ указанием force роли предварительно + # удаляются + errors_list.append(f"РќРµ удалось создать роль {local_role.id}, {e.details()}") + + return errors_list + + # Работа СЃ клиентами + def __check_clients(self, space_id: str) -> list[str]: + errors_list = [] + + for client in self.clients: + try: + self.clients_service.Get.with_call( + clients_pb2.GetRequest(space_id=space_id, id=client.id) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось получить клиент {client.id}, ошибка {e.details()}") + + return errors_list + + def __remove_clients(self, space_id: str) -> list[str]: + errors_list: list[str] = [] + + for client in self.clients: + try: + self.clients_service.Delete.with_call( + clients_pb2.DeleteRequest( + space_id=space_id, id=client.id + ) + ) + except grpc.RpcError as e: + # Отсутствие клиента ошибкой РЅРµ считается + if "not found" not in e.details(): + errors_list.append(f"РќРµ удалось удалить клиент {client.id}, {e.details()}") + + return errors_list + + def __update_clients(self, space_id: str) -> list[str]: + errors_list = [] + + for local_client in self.clients: + try: + # Перед обновлением клиента предварительно РЅСѓР¶РЅРѕ получить текущую запись чтобы скопировать оттуда + # токены. Рначе после обновления расширения перестанут работать РІСЃРµ приложения которые использовали + # токен клиента + get_response, state = self.clients_service.Get.with_call( + clients_pb2.GetRequest( + space_id=space_id, + id=local_client.id + ) + ) + + client = get_response.client + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось получить клиент {local_client.id}, {e.details()}") + + continue + + try: + # РќСѓР¶РЅРѕ чтобы Сѓ клиента каждый раз РЅРµ слетали данные токенов РїСЂРё переустановке + # свойства oauth, api_key Рё tls должны браться РёР· __созданного__ клиента + new_client = clients_pb2.Client( + id=local_client.id, + space_id=space_id, + name=local_client.name, + description=local_client.description, + disabled=client.disabled, + role_id=local_client.role_id, + oauth=client.oauth, + api_key=client.api_key, + tls=client.tls + ) + + self.clients_service.Update.with_call( + clients_pb2.UpdateRequest( + client=new_client + ) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось обновить клиент {local_client.id}, {e.details()}") + + return errors_list + + def __create_clients(self, space_id: str) -> list[str]: + errors_list = [] + + for local_client in self.clients: + try: + cloned_client = copy.deepcopy(local_client) + cloned_client.space_id = space_id + + response, _ = self.clients_service.Create.with_call( + clients_pb2.CreateRequest( + client=cloned_client + ), + ) + except grpc.RpcError as e: + # Как Рё СЃ ролями считается что РїСЂРё install записей быть РЅРµ должно РІ любом случае С‚.Рє. force РІСЃС‘ удаляет + errors_list.append(f"РќРµ удалось создать клиент {local_client.id}, {e.details()}") + + return errors_list + + # Работа СЃ коллекциями + def __remove_collections(self, space_id: str, env_id: str) -> list[str]: + errors_list: list[str] = [] + + for collection in self.collections: + try: + self.collections_service.Delete.with_call( + collections_pb2.DeleteRequest( + space_id=space_id, env_id=env_id, collection_id=collection.id + ) + ) + except grpc.RpcError as e: + # Отсутствие коллекции это РЅРµ ошибка + if "not found" not in e.details(): + errors_list.append(f"РќРµ удалось удалить коллекцию {collection.id}, {e.details()}") + + return errors_list + + def __check_collections(self, space_id: str, env_id: str) -> list[str]: + errors_list = [] + + for collection in self.collections: + try: + self.collections_service.Get.with_call( + collections_pb2.GetRequest(space_id=space_id, env_id=env_id, collection_id=collection.id) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось получить коллекцию {collection.id}, ошибка {e.details()}") + + return errors_list + + def __update_collections(self, space_id: str, env_id: str) -> list[str]: + """ + Метод __обновления__ коллекций подразумевает что сами коллекции СѓР¶Рµ созданы. Миграция окружения требуется + только РІ случае если РѕРґРЅР° или несколько схем коллекций изменялись. Алгоритм работы: + 1. Получить фактически существующую коллекцию РёР· БД + 2. Обновить её РІ perxis + 3. Сравнить схему коллекции РІ расширении Рё РІ perxis + 4. Если схема изменена - обновить схему РІ perxis + 5. Если обновлялась хотя Р±С‹ РѕРґРЅР° схема РІ perxis - запустить миграцию окружения + """ + + need_to_migrate_environment = False + errors_list = [] + + for local_collection in self.collections: + try: + # Необходимо получить текущую версию коллекции для того чтобы сравнить схемы + get_response, state = self.collections_service.Get.with_call( + collections_pb2.GetRequest(space_id=space_id, env_id=env_id, collection_id=local_collection.id) + ) + + collection = get_response.collection + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось получить коллекцию {local_collection.id}, {e.details()}") + + continue + + try: + cloned_collection = copy.deepcopy(local_collection) + cloned_collection.space_id = space_id + cloned_collection.env_id = env_id + + self.collections_service.Update.with_call( + collections_pb2.UpdateRequest(collection=cloned_collection) + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось обновить коллекцию {local_collection.id}, {e.details()}") + + continue + + diff = DeepDiff( + json.loads(collection.schema or "{}"), + json.loads(local_collection.schema or "{}"), + ignore_numeric_type_changes=True, + exclude_paths=["root['loaded']"] + ) + if diff: + need_to_migrate_environment = True + + set_schema_error_message = self.__set_collection_schema( + space_id, env_id, local_collection.id, local_collection.schema + ) + if set_schema_error_message: + errors_list.append(set_schema_error_message) + + if need_to_migrate_environment: + migrate_environment_error_message = self.__migrate_environment(space_id, env_id) + if migrate_environment_error_message: + errors_list.append(migrate_environment_error_message) + + return errors_list + + def __migrate_environment(self, space_id: str, env_id: str) -> typing.Optional[str]: + # Так как perxis может РЅРµ сразу выставить коллекции / окружению статус ready операцию необходимо выполнять + # СЃ попытками + + attempt = 0 + is_ok = False + error_message = None + + while attempt <= self.__max_attempts_count and not is_ok: + time.sleep(self.__sleep_time) + + try: + self.environments_service.Migrate(environments_pb2.MigrateRequest( + space_id=space_id, + env_id=env_id + )) + + is_ok = True + except grpc.RpcError as e: + # Если РЅРµ удалось мигрировать окружение РїРѕ любой причине РєСЂРѕРјРµ подготовки - это ошибка + if "is preparing" not in e.details(): + error_message = e.details() + + # Для принудительного выхода РёР· цикла + attempt = self.__max_attempts_count + + attempt += 1 + + return error_message + + def __set_collection_schema(self, space_id: str, env_id: str, collection_id: str, schema: str) -> typing.Optional[str]: + # Так как perxis может РЅРµ сразу выставить коллекции / окружению статус ready операцию необходимо выполнять + # СЃ попытками + + attempt = 0 + is_ok = False + error_message = None + + while attempt <= self.__max_attempts_count and not is_ok: + time.sleep(self.__sleep_time) + + try: + self.collections_service.SetSchema.with_call( + collections_pb2.SetSchemaRequest( + space_id=space_id, + env_id=env_id, + collection_id=collection_id, + schema=schema + ) + ) + + is_ok = True + except grpc.RpcError as e: + # Если РЅРµ удалось установить схему РїРѕ любой причине РєСЂРѕРјРµ подготовки - это ошибка + if "is preparing" not in e.details(): + error_message = e.details() + + # Для принудительного выхода РёР· цикла + attempt = self.__max_attempts_count + + attempt += 1 + + return error_message + + def __create_collections(self, space_id: str, env_id: str) -> list[str]: + errors_list = [] + + for local_collection in self.collections: + try: + cloned_collection = copy.deepcopy(local_collection) + cloned_collection.space_id = space_id + cloned_collection.env_id = env_id + + response, _ = self.collections_service.Create.with_call( + collections_pb2.CreateRequest( + collection=cloned_collection + ), + ) + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось создать коллекцию {local_collection.id}, {e.details()}") + + # Если коллекцию создать РЅРµ удалось (РїРѕ любой причине) дальнейшая обработка коллекции смысла + # РЅРµ имеет + continue + + set_schema_error_message = self.__set_collection_schema( + space_id, env_id, local_collection.id, local_collection.schema + ) + if set_schema_error_message: + errors_list.append(set_schema_error_message) + + # Миграция окружения РЅСѓР¶РЅР° РІ любом случае С‚.Рє. РІСЃРµ коллекции были __созданы__ + migrate_environment_error_message = self.__migrate_environment(space_id, env_id) + if migrate_environment_error_message: + errors_list.append(migrate_environment_error_message) + + return errors_list + + def install(self, space_id: str, env_id: str, use_force: bool) -> list[str]: + errors = [] + + if use_force: + errors += self.__remove_collections(space_id, env_id) + errors += self.__remove_clients(space_id) + errors += self.__remove_roles(space_id) + + errors += self.__create_collections(space_id, env_id) + errors += self.__create_roles(space_id) + errors += self.__create_clients(space_id) + + return errors + + def update(self, space_id: str, env_id: str, use_force: bool) -> list[str]: + errors = [] + + # Р’ случае обновление расширения СЃ флагом force РЅСѓР¶РЅРѕ предварительно удалить РІСЃРµ сущности. + # Фактически это переустановка Р° РЅРµ удаление + if use_force: + errors += self.__remove_clients(space_id) + errors += self.__remove_roles(space_id) + errors += self.__remove_collections(space_id, env_id) + + errors += self.__create_collections(space_id, env_id) + errors += self.__create_roles(space_id) + errors += self.__create_clients(space_id) + else: + errors += self.__update_collections(space_id, env_id) + errors += self.__update_roles(space_id) + errors += self.__update_clients(space_id) + + return errors + + def check(self, space_id: str, env_id: str) -> list[str]: + errors = self.__check_collections(space_id, env_id) + errors += self.__check_roles(space_id) + errors += self.__check_clients(space_id) + + return errors + + def uninstall(self, space_id: str, env_id: str, use_remove: bool) -> list[str]: + errors = [] + + if use_remove: + errors += self.__remove_collections(space_id, env_id) + errors += self.__remove_clients(space_id) + errors += self.__remove_roles(space_id) + + return errors diff --git a/requirements.txt b/requirements.txt index 037b5b3ed1043d3f0ba286fb106ff72117e98cae..a8f172046b0e79f409cc81decd34e4996a8210c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ requests==2.28.1 requests-oauthlib==1.3.1 six==1.16.0 urllib3==1.26.12 +deepdiff==6.3.0 diff --git a/setup.py b/setup.py index 34a07cd434ba3c6b57c56a4e903020b5a551b46d..d0b11a0581f2bd7eb99de468dda0210aa91ef4a3 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def load_requirements(): setup( name='perxis', - version='0.0.13', + version='0.0.14', description='Perxis python client', long_description=long_description, long_description_content_type='text/markdown',