diff --git a/README.md b/README.md index 46d270160be0002d4ccc6c1903b69e05c836a278..cc96338274d298de0339e86ae03ab18d8775db89 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,14 @@ # perxis-python ## Расширения -### Пример подключения расширения Рє Perxis -... +### Локальная разработка +Для работы расширения требуется указание системного контекста РїСЂРё выполнении запросов. Рто РІРѕР·РјРѕР¶РЅРѕ только РІ случае РїСЂСЏРјРѕР№ +работы СЃ сервисами perxis РјРёРЅСѓСЏ envoy, для этого РѕРЅРё должны быть РІ РѕРґРЅРѕР№ сети. Поэтому нужен локально запущенный экземпляр +perxis. Для его запуска РЅСѓР¶РЅРѕ РІ каталоге perxis выполнить команду make run-local. После этого РІ контейнере РІ той Р¶Рµ сети +РјРѕР¶РЅРѕ запустить сервис СЃ расширением ### Пример написания сервиса расширений -```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 - } - ) - ] -``` +Готовый пример СЃ реализацией простого сервиса РјРѕР¶РЅРѕ посмотреть РІ каталоге examples/extension_service ## Аутентификация diff --git a/examples/extension_service/.gitignore b/examples/extension_service/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..fbc67727e75973ecf335d3365c3d1b1b8cb6078a --- /dev/null +++ b/examples/extension_service/.gitignore @@ -0,0 +1 @@ +.docker-compose.override.yml \ No newline at end of file diff --git a/examples/extension_service/Dockerfile b/examples/extension_service/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..86eeb56492bc4929e53d34290b7540f4a28b5a9d --- /dev/null +++ b/examples/extension_service/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.9-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV USER=perx + +RUN mkdir -p /home/${USER}/data /home/${USER}/app /home/${USER}/logs +WORKDIR /home/${USER}/app + +ARG PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} +ENV PIP_EXTRA_INDEX_URL=$PIP_EXTRA_INDEX_URL + +COPY . /home/${USER}/app +RUN pip install perxis==0.0.14 + +ENV PYTHONPATH="/home/perx/app" +ENV PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + +CMD ["python", "/home/perx/app/server.py"] diff --git a/examples/extension_service/constants/__init__.py b/examples/extension_service/constants/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/examples/extension_service/constants/collections.py b/examples/extension_service/constants/collections.py new file mode 100644 index 0000000000000000000000000000000000000000..ef5a4a02a74a90fcafd394ec580003fc0d6c5069 --- /dev/null +++ b/examples/extension_service/constants/collections.py @@ -0,0 +1,3 @@ +schemes_mapping = { + "test_collection": "Тестовая коллекция", +} diff --git a/examples/extension_service/constants/extension.py b/examples/extension_service/constants/extension.py new file mode 100644 index 0000000000000000000000000000000000000000..c623179157615892d6ca3c2aaade3cdfa9d56528 --- /dev/null +++ b/examples/extension_service/constants/extension.py @@ -0,0 +1,21 @@ +from perxis.extensions import manager_pb2 + + +ID = "demo-extension" +NAME = "Демонстрационное расширение" +VERSION = "v0.0.1" +DESCRIPTION = "Демонстрационное расширение" +VERSION_DESCRIPTION = "Описание расширения" +DEPENDENCIES = [] + + +def get_extension_descriptor(host: str) -> manager_pb2.ExtensionDescriptor: + return manager_pb2.ExtensionDescriptor( + extension=ID, + title=NAME, + description=DESCRIPTION, + version=VERSION, + version_description=VERSION_DESCRIPTION, + deps=DEPENDENCIES, + url=host + ) diff --git a/examples/extension_service/docker-compose.yml b/examples/extension_service/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..52b018e514a73f7a9d14919948738a9bcb76b70d --- /dev/null +++ b/examples/extension_service/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.9" + +services: + demo-ext-backend: + ports: + - 50051:50051 + build: + context: . + args: + - PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} + restart: unless-stopped + volumes: + - .:/home/perx/app + networks: + - storage + - default + + +networks: + default: + storage: + external: + name: storage diff --git a/examples/extension_service/helpers.py b/examples/extension_service/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..849c587e3130c0b74532fcbd56007f202a94b85c --- /dev/null +++ b/examples/extension_service/helpers.py @@ -0,0 +1,114 @@ +# TODO: убрать дублирование РєРѕРґР°. РќР° момент написания сервиса ещё РЅРµ было версии пакета СЃ perxis-python РІ которую РІС…РѕРґРёР» +# TODO: Р±С‹ написанный РЅРёР¶Рµ РєРѕРґ. Удалить после того как станет РЅРµ РЅСѓР¶РЅРѕ + +import grpc +import collections + +from typing import Optional + +from perxis.collections import collections_pb2 +from perxis.extensions import manager_pb2 + + +def make_descriptor( + extension_id: str, name: str, description: str, + version: str, version_description: str, host: str, + dependencies: Optional[list[str]] = None +) -> manager_pb2.ExtensionDescriptor: + if dependencies is None: + dependencies = [] + + return manager_pb2.ExtensionDescriptor( + extension=extension_id, + title=name, + description=description, + version=version, + version_description=version_description, + deps=dependencies, + url=host + ) + + +def make_collection_instances(schemes_dir: str, schemes_mapping: dict[str, str]) -> list[collections_pb2.Collection]: + collections = [] + + for collection_id, collection_name in schemes_mapping.items(): + with open(f"{schemes_dir}/{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 + + +class _GenericClientInterceptor(grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor): + + def __init__(self, interceptor_function): + self._fn = interceptor_function + + def intercept_unary_unary(self, continuation, client_call_details, request): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, False) + response = continuation(new_details, next(new_request_iterator)) + return postprocess(response) if postprocess else response + + def intercept_unary_stream(self, continuation, client_call_details, + request): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, True) + response_it = continuation(new_details, next(new_request_iterator)) + return postprocess(response_it) if postprocess else response_it + + def intercept_stream_unary(self, continuation, client_call_details, + request_iterator): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, False) + response = continuation(new_details, new_request_iterator) + return postprocess(response) if postprocess else response + + def intercept_stream_stream(self, continuation, client_call_details, + request_iterator): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, True) + response_it = continuation(new_details, new_request_iterator) + return postprocess(response_it) if postprocess else response_it + + +def create(intercept_call): + return _GenericClientInterceptor(intercept_call) + + +class _ClientCallDetails( + collections.namedtuple( + '_ClientCallDetails', + ('method', 'timeout', 'metadata', 'credentials')), + grpc.ClientCallDetails): + pass + + +def header_adder_interceptor(header, value): + def intercept_call(client_call_details, request_iterator, request_streaming, + response_streaming): + metadata = [] + if client_call_details.metadata is not None: + metadata = list(client_call_details.metadata) + metadata.append(( + header, + value, + )) + + client_call_details = _ClientCallDetails( + client_call_details.method, client_call_details.timeout, metadata, + client_call_details.credentials) + return client_call_details, request_iterator, None + + return create(intercept_call) \ No newline at end of file diff --git a/examples/extension_service/readme.md b/examples/extension_service/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..fad54dbbdc89b218c50e66cbb552a0cbc7fcf0ca --- /dev/null +++ b/examples/extension_service/readme.md @@ -0,0 +1,5 @@ +Для запуска необходимо: +1. Создать файл .env +2. Указать переменную PIP_EXTRA_INDEX_URL +3. docker-compose up + diff --git a/examples/extension_service/schemes/test_collection.json b/examples/extension_service/schemes/test_collection.json new file mode 100644 index 0000000000000000000000000000000000000000..3189935a5dbdfc9b7e5fcd0111ac33bff62d6d37 --- /dev/null +++ b/examples/extension_service/schemes/test_collection.json @@ -0,0 +1,33 @@ +{ + "ui": { + "options": { + "fields": [ + "aaa", + "bbb" + ] + } + }, + "type": "object", + "params": { + "inline": false, + "fields": { + "bbb": { + "title": "Ещё поле", + "ui": { + "widget": "StringInput" + }, + "type": "string", + "params": {} + }, + "aaa": { + "title": "Какое то поле", + "ui": { + "widget": "StringInput" + }, + "type": "string", + "params": {} + } + } + }, + "loaded": false +} diff --git a/examples/extension_service/server.py b/examples/extension_service/server.py new file mode 100644 index 0000000000000000000000000000000000000000..e3c45ff767af18ab4bdcfa858f6f46fbab996f29 --- /dev/null +++ b/examples/extension_service/server.py @@ -0,0 +1,62 @@ +import grpc + +from concurrent import futures +from perxis.collections import collections_pb2_grpc +from perxis.environments import environments_pb2_grpc +from perxis.roles import roles_pb2_grpc +from perxis.clients import clients_pb2_grpc +from perxis.extensions import extension_pb2_grpc, manager_pb2_grpc, manager_pb2 + +from servicer import Servicer +from helpers import header_adder_interceptor +from constants.extension import get_extension_descriptor + + +def main(): + my_extension_descriptor = get_extension_descriptor(host="demo-ext-backend:50051") + + interceptor = header_adder_interceptor( + 'x-perxis-access', 'system' + ) + + with grpc.insecure_channel("extension-manager:9030") as extensions_manager_channel: + intercept_channel_extensions_manager_channel = grpc.intercept_channel(extensions_manager_channel, interceptor) + + ext_manager_stub = manager_pb2_grpc.ExtensionManagerStub(intercept_channel_extensions_manager_channel) + + registered_extensions: manager_pb2.ListExtensionsResponse = ext_manager_stub.ListExtensions(manager_pb2.ListExtensionsRequest()) + + for ext in registered_extensions.extensions: + if ext.extension == my_extension_descriptor.extension: + if ext.version != my_extension_descriptor.version: + ext_manager_stub.UnregisterExtensions( + manager_pb2.UnregisterExtensionsRequest( + extensions=[my_extension_descriptor] + ) + ) + + ext_manager_stub.RegisterExtensions(manager_pb2.RegisterExtensionsRequest( + extensions=[my_extension_descriptor] + )) + + with grpc.insecure_channel("content:9020") as content_channel: + intercepted_content_channel = grpc.intercept_channel(content_channel, interceptor) + + collections_stub = collections_pb2_grpc.CollectionsStub(intercepted_content_channel) + roles_stub = roles_pb2_grpc.RolesStub(intercepted_content_channel) + clients_stub = clients_pb2_grpc.ClientsStub(intercepted_content_channel) + environments_stub = environments_pb2_grpc.EnvironmentsStub(intercepted_content_channel) + + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + extension_pb2_grpc.add_ExtensionServicer_to_server( + Servicer( + collections_stub, environments_stub, roles_stub, clients_stub + ), server + ) + server.add_insecure_port("[::]:50051") + server.start() + server.wait_for_termination() + + +if __name__ == "__main__": + main() diff --git a/examples/extension_service/servicer.py b/examples/extension_service/servicer.py new file mode 100644 index 0000000000000000000000000000000000000000..82ec915f31aaa482e818b34250ee6c98e4a43983 --- /dev/null +++ b/examples/extension_service/servicer.py @@ -0,0 +1,39 @@ +from constants import extension +from helpers import make_collection_instances +from constants import collections + + +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 + + +class Servicer(ExtensionService): + extension_id = extension.ID + collections = make_collection_instances("./schemes", collections.schemes_mapping) + roles = [ + roles_pb2.Role( + id="demo-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="demo-client", + name="Демонстрационный клиент", + description="Описание созданного расширением клиента", + role_id="demo-role", + api_key={ + "rotate": True + } + ) + ] diff --git a/perxis/collections/helpers.py b/perxis/collections/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..06ad1e1e3f106d9d8c0ae174e8200e8d3739b1ce --- /dev/null +++ b/perxis/collections/helpers.py @@ -0,0 +1,19 @@ +from perxis.collections import collections_pb2 + + +def make_collection_instances(schemes_dir: str, schemes_mapping: dict[str, str]) -> list[collections_pb2.Collection]: + collections = [] + + for collection_id, collection_name in schemes_mapping.items(): + with open(f"{schemes_dir}/{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 \ No newline at end of file diff --git a/perxis/extensions/extension_setup.py b/perxis/extensions/extension_setup.py index fa1981b441040251ed230365cec7c2992c73440d..2445a38a7a69683bf199cc1cbc2932a027ffb422 100644 --- a/perxis/extensions/extension_setup.py +++ b/perxis/extensions/extension_setup.py @@ -250,7 +250,7 @@ class ExtensionSetup: def __update_collections(self, space_id: str, env_id: str) -> list[str]: """ - Метод __обновления__ коллекций подразумевает что сами коллекции СѓР¶Рµ созданы. Миграция окружения требуется + Метод __обновления__ коллекций. Миграция окружения требуется только РІ случае если РѕРґРЅР° или несколько схем коллекций изменялись. Алгоритм работы: 1. Получить фактически существующую коллекцию РёР· БД 2. Обновить её РІ perxis @@ -271,22 +271,37 @@ class ExtensionSetup: collection = get_response.collection except grpc.RpcError as e: - errors_list.append(f"РќРµ удалось получить коллекцию {local_collection.id}, {e.details()}") + collection = None - continue + cloned_collection = copy.deepcopy(local_collection) + cloned_collection.space_id = space_id + cloned_collection.env_id = env_id - try: - cloned_collection = copy.deepcopy(local_collection) - cloned_collection.space_id = space_id - cloned_collection.env_id = env_id + # Коллекция может быть РЅРµ найдена РІ случае если РѕРЅР° добавлена РІ РЅРѕРІРѕР№ версии + if not collection: + try: + create_response, state = self.collections_service.Create.with_call( + collections_pb2.CreateRequest(collection=cloned_collection) + ) - 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()}") + collection = create_response.collection + except grpc.RpcError as e: + errors_list.append(f"РќРµ удалось создать коллекцию {local_collection.id}, {e.details()}") - continue + continue + else: + 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 "{}"), diff --git a/perxis/interceptors.py b/perxis/interceptors.py new file mode 100644 index 0000000000000000000000000000000000000000..87eb273818fccc901092ac4b3cfafb65a27f650b --- /dev/null +++ b/perxis/interceptors.py @@ -0,0 +1,69 @@ +import grpc +import collections + + +class _GenericClientInterceptor(grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor): + + def __init__(self, interceptor_function): + self._fn = interceptor_function + + def intercept_unary_unary(self, continuation, client_call_details, request): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, False) + response = continuation(new_details, next(new_request_iterator)) + return postprocess(response) if postprocess else response + + def intercept_unary_stream(self, continuation, client_call_details, + request): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, True) + response_it = continuation(new_details, next(new_request_iterator)) + return postprocess(response_it) if postprocess else response_it + + def intercept_stream_unary(self, continuation, client_call_details, + request_iterator): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, False) + response = continuation(new_details, new_request_iterator) + return postprocess(response) if postprocess else response + + def intercept_stream_stream(self, continuation, client_call_details, + request_iterator): + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, True) + response_it = continuation(new_details, new_request_iterator) + return postprocess(response_it) if postprocess else response_it + + +def create(intercept_call): + return _GenericClientInterceptor(intercept_call) + + +class _ClientCallDetails( + collections.namedtuple( + '_ClientCallDetails', + ('method', 'timeout', 'metadata', 'credentials')), + grpc.ClientCallDetails): + pass + + +def header_adder_interceptor(header, value): + def intercept_call(client_call_details, request_iterator, request_streaming, + response_streaming): + metadata = [] + if client_call_details.metadata is not None: + metadata = list(client_call_details.metadata) + metadata.append(( + header, + value, + )) + + client_call_details = _ClientCallDetails( + client_call_details.method, client_call_details.timeout, metadata, + client_call_details.credentials) + return client_call_details, request_iterator, None + + return create(intercept_call)