From 6a3c1aed46a0bf6438c3fcc41b6da77bfa4b8ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radovan=20=C5=BDivkovi=C4=87?= <115744253+radovanZRasa@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:23:44 +0200 Subject: [PATCH] [ATO-2731] Initialise gRPC health check service (#1129) * Initialize the built-in gRPC health service * Do not consider deleted files for filtering * Remove comment which indicates that grpc code-in-sync check is not run on CI * Prefix private functions from gRPC server module with underscore * Add integration tests for gRPC * Add changelog for healthcheck service in gRPC mode --- .dockerignore | 2 +- .github/actions/auth-aws-ecr/action.yml | 30 ++ .github/actions/debug-grpc/action.yml | 39 +++ .github/workflows/continuous-integration.yml | 119 ++++++- .gitignore | 3 + Dockerfile.dev | 62 ++++ Makefile | 77 ++++- certs/.gitignore | 9 + certs/Makefile | 32 ++ certs/config/ca-config.json | 10 + certs/config/ca-csr.json | 16 + certs/config/client-csr.json | 16 + certs/config/server-csr.json | 16 + changelog/1129.bugfix.md | 1 + integration_tests/__init__.py | 0 integration_tests/conftest.py | 150 +++++++++ integration_tests/grpc_server/__init__.py | 0 .../grpc_server/setup/actions/action_hi.py | 17 + .../grpc_server/setup/certs/ca-key.pem | 27 ++ .../grpc_server/setup/certs/ca.pem | 23 ++ .../grpc_server/setup/certs/client-key.pem | 27 ++ .../grpc_server/setup/certs/client.pem | 24 ++ .../grpc_server/setup/certs/server-key.pem | 27 ++ .../grpc_server/setup/certs/server.pem | 26 ++ .../grpc_server/setup/docker-compose.yml | 38 +++ .../grpc_server/test_docker_grpc_server.py | 136 ++++++++ .../test_standalone_grpc_server.py | 291 ++++++++++++++++++ poetry.lock | 17 +- proto/health.proto | 11 - pyproject.toml | 1 + rasa_sdk/__main__.py | 14 +- rasa_sdk/endpoint.py | 26 +- rasa_sdk/executor.py | 16 +- rasa_sdk/grpc_py/health_pb2.py | 30 -- rasa_sdk/grpc_py/health_pb2.pyi | 13 - rasa_sdk/grpc_py/health_pb2_grpc.py | 66 ---- rasa_sdk/grpc_server.py | 192 ++++++++---- scripts/lint_python_docstrings.sh | 4 +- tests/test_endpoint.py | 35 ++- tests/test_grpc_server.py | 26 +- tests/tracing/instrumentation/test_tracing.py | 8 +- 41 files changed, 1427 insertions(+), 250 deletions(-) create mode 100644 .github/actions/auth-aws-ecr/action.yml create mode 100644 .github/actions/debug-grpc/action.yml create mode 100644 Dockerfile.dev create mode 100644 certs/.gitignore create mode 100644 certs/Makefile create mode 100644 certs/config/ca-config.json create mode 100644 certs/config/ca-csr.json create mode 100644 certs/config/client-csr.json create mode 100644 certs/config/server-csr.json create mode 100644 changelog/1129.bugfix.md create mode 100644 integration_tests/__init__.py create mode 100644 integration_tests/conftest.py create mode 100644 integration_tests/grpc_server/__init__.py create mode 100644 integration_tests/grpc_server/setup/actions/action_hi.py create mode 100644 integration_tests/grpc_server/setup/certs/ca-key.pem create mode 100644 integration_tests/grpc_server/setup/certs/ca.pem create mode 100644 integration_tests/grpc_server/setup/certs/client-key.pem create mode 100644 integration_tests/grpc_server/setup/certs/client.pem create mode 100644 integration_tests/grpc_server/setup/certs/server-key.pem create mode 100644 integration_tests/grpc_server/setup/certs/server.pem create mode 100644 integration_tests/grpc_server/setup/docker-compose.yml create mode 100644 integration_tests/grpc_server/test_docker_grpc_server.py create mode 100644 integration_tests/test_standalone_grpc_server.py delete mode 100644 proto/health.proto delete mode 100644 rasa_sdk/grpc_py/health_pb2.py delete mode 100644 rasa_sdk/grpc_py/health_pb2.pyi delete mode 100644 rasa_sdk/grpc_py/health_pb2_grpc.py diff --git a/.dockerignore b/.dockerignore index 191baf68c..e480d9d0e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,4 @@ CHANGELOG.txt .pre-commit-config.yaml changelog/* gha-creds-*.json -**/gha-creds-*.json \ No newline at end of file +**/gha-creds-*.json diff --git a/.github/actions/auth-aws-ecr/action.yml b/.github/actions/auth-aws-ecr/action.yml new file mode 100644 index 000000000..62745c3c9 --- /dev/null +++ b/.github/actions/auth-aws-ecr/action.yml @@ -0,0 +1,30 @@ +name: Authenticate to AWS ECR +description: Encapsulates steps for Authenticating to ECR + +permissions: + id-token: write + contents: read + +inputs: + AWS_REGION: + description: 'AWS Region' + required: true + AWS_ARN_ROLE_TO_ASSUME: + description: 'AWS role ARN' + required: true + +runs: + using: 'composite' + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@8c3f20df09ac63af7b3ae3d7c91f105f857d8497 # v3.0.1 + with: + role-to-assume: ${{ inputs.AWS_ARN_ROLE_TO_ASSUME }} + aws-region: ${{ inputs.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + with: + mask-password: "true" + \ No newline at end of file diff --git a/.github/actions/debug-grpc/action.yml b/.github/actions/debug-grpc/action.yml new file mode 100644 index 000000000..cef1a29e1 --- /dev/null +++ b/.github/actions/debug-grpc/action.yml @@ -0,0 +1,39 @@ +name: Debug custom actions integration test containers +description: Encapsulates steps for custom actions test debugging + +inputs: + COMPOSE_FILE_PATH: + description: 'Custom action docker compose path' + required: true + RASA_SDK_REPOSITORY: + description: 'Rasa SDK repository path' + required: true + RASA_SDK_IMAGE_TAG: + description: 'Rasa SDK image tag' + required: true + +runs: + using: 'composite' + + steps: + - name: List containers + run: sudo docker ps -a + shell: bash + + - name: Check logs for action server without TLS + env: + RASA_SDK_REPOSITORY: ${{ inputs.RASA_SDK_REPOSITORY }} + RASA_SDK_IMAGE_TAG: ${{ inputs.RASA_SDK_IMAGE_TAG }} + run: | + docker compose -f ${{ inputs.COMPOSE_FILE_PATH }} \ + logs action-server-grpc-no-tls + shell: bash + + - name: Check logs for action server with TLS + env: + RASA_SDK_REPOSITORY: ${{ inputs.RASA_SDK_REPOSITORY }} + RASA_SDK_IMAGE_TAG: ${{ inputs.RASA_SDK_IMAGE_TAG }} + run: | + docker compose -f ${{ inputs.COMPOSE_FILE_PATH }} \ + logs action-server-grpc-tls + shell: bash \ No newline at end of file diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index f4301719b..fd9390409 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -12,6 +12,10 @@ env: IS_TAG_BUILD: ${{ startsWith(github.event.ref, 'refs/tags') }} DEV_REPOSITORY: 329710836760.dkr.ecr.us-east-1.amazonaws.com/rasa-sdk-dev AWS_REGION: us-east-1 + # This tag is used to build the image without dev dependencies + DEV_IMAGE_TAG: pr${{ github.event.number }} + # This tag is used to build the image with dev dependencies + DEV_IMAGE_WITH_DEV_DEPS_TAG: pr${{ github.event.number }}-with-dev-deps # SECRETS # - PYPI_TOKEN: publishing token for amn41 account, needs to be maintainer of @@ -143,17 +147,37 @@ jobs: - name: Check out code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@8c3f20df09ac63af7b3ae3d7c91f105f857d8497 # v3.0.1 + - name: Authenticate to AWS ECR + uses: ./.github/actions/auth-aws-ecr with: - role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_SESSION_TOKEN }} - aws-region: ${{ env.AWS_REGION }} + AWS_REGION: ${{ env.AWS_REGION }} + AWS_ARN_ROLE_TO_ASSUME: ${{ secrets.AWS_ASSUME_ROLE_SESSION_TOKEN }} - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + - name: Set up QEMU + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@0d103c3126aa41d772a8362f6aa67afac040f80c # v3.1.0 + + - name: Build and push docker image to AWS + run: | + IMAGE_NAME=${{ env.DEV_REPOSITORY }} \ + IMAGE_TAG=${{ env.DEV_IMAGE_TAG }} \ + make build-and-push-multi-platform-docker + + rasa-sdk-with-dev-deps-docker-image: + name: Build dev Docker image with dev dependencies + runs-on: ubuntu-22.04 + + steps: + - name: Check out code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Authenticate to AWS ECR + uses: ./.github/actions/auth-aws-ecr with: - mask-password: "true" + AWS_REGION: ${{ env.AWS_REGION }} + AWS_ARN_ROLE_TO_ASSUME: ${{ secrets.AWS_ASSUME_ROLE_SESSION_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 @@ -163,6 +187,79 @@ jobs: - name: Build and push docker image to AWS run: | - IMAGE_NAME=${{ env.DEV_REPOSITORY }} \ - IMAGE_TAG=pr${{ github.event.number }} \ - make build-and-push-multi-platform-docker \ No newline at end of file + IMAGE_WITH_DEV_DEPS=${{ env.DEV_REPOSITORY }} \ + IMAGE_TAG=${{ env.DEV_IMAGE_WITH_DEV_DEPS_TAG }} \ + make build-and-push-multi-platform-docker-with-dev-deps + + grpc_standalone_integration_tests: + name: Run gRPC integration tests using standalone server + runs-on: ubuntu-22.04 + needs: [rasa-sdk-with-dev-deps-docker-image] + + steps: + - name: Check out code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Authenticate to AWS ECR + uses: ./.github/actions/auth-aws-ecr + with: + AWS_REGION: ${{ env.AWS_REGION }} + AWS_ARN_ROLE_TO_ASSUME: ${{ secrets.AWS_ASSUME_ROLE_SESSION_TOKEN }} + + - name: Docker version + run: docker --version && docker compose version + + - name: gRPC Server Integration Testing - Run tests on gRPC server with and without TLS 🩺 + run: | + make run-grpc-standalone-integration-tests + env: + IMAGE_WITH_DEV_DEPS: ${{ env.DEV_REPOSITORY }} + IMAGE_TAG: ${{ env.DEV_IMAGE_WITH_DEV_DEPS_TAG }} + + + grpc_docker_integration_tests: + name: Run gRPC integration tests using Docker containers + runs-on: ubuntu-22.04 + needs: [rasa-sdk-dev-docker-image, rasa-sdk-with-dev-deps-docker-image] + + steps: + - name: Check out code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Authenticate to AWS ECR + uses: ./.github/actions/auth-aws-ecr + with: + AWS_REGION: ${{ env.AWS_REGION }} + AWS_ARN_ROLE_TO_ASSUME: ${{ secrets.AWS_ASSUME_ROLE_SESSION_TOKEN }} + + - name: Docker version + run: docker --version && docker compose version + + - name: gRPC Server Integration Testing - Run env docker containers + run: | + make start-grpc-integration-test-env + env: + IMAGE_NAME: ${{ env.DEV_REPOSITORY }} + IMAGE_TAG: ${{ env.DEV_IMAGE_TAG }} + + - name: gRPC Server Integration Testing - Run tests on gRPC server with and without TLS 🩺 + run: | + make run-grpc-integration-tests + env: + IMAGE_WITH_DEV_DEPS: ${{ env.DEV_REPOSITORY }} + IMAGE_TAG: ${{ env.DEV_IMAGE_WITH_DEV_DEPS_TAG }} + + - name: gRPC Server Integration Testing - Stop env docker containers + run: | + make stop-grpc-integration-test-env + env: + IMAGE_NAME: ${{ env.DEV_REPOSITORY }} + IMAGE_TAG: ${{ env.DEV_IMAGE_TAG }} + + - name: Show container logs + if: always() + uses: ./.github/actions/debug-grpc + with: + COMPOSE_FILE_PATH: integration_tests/grpc_server/setup/docker-compose.yml + RASA_SDK_REPOSITORY: ${{ env.DEV_REPOSITORY }} + RASA_SDK_IMAGE_TAG: ${{ env.DEV_IMAGE_TAG }} diff --git a/.gitignore b/.gitignore index 7dd402508..55c30301d 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ examples/moodbot/models/ *.DS_Store tests/executor_test_packages .pytype/ + +grpc-standalone-server-integration-test-results.xml +grpc-server-docker-integration-test-results.xml diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..425897ea4 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,62 @@ +FROM ubuntu:22.04 AS base + +# hadolint ignore=DL3005,DL3008 +RUN apt-get update -qq \ + # Make sure that all security updates are installed + && apt-get dist-upgrade -y --no-install-recommends \ + && apt-get install -y --no-install-recommends \ + python3 \ + python3-venv \ + python3-pip \ + python3-dev \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3 100 \ + && update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 100 + +FROM base AS python_builder + +ARG POETRY_VERSION=1.8.2 + +# hadolint ignore=DL3008 +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + curl \ + && apt-get autoremove -y + +# install poetry +# keep this in sync with the version in pyproject.toml and Dockerfile +ENV POETRY_VERSION=$POETRY_VERSION +ENV PYTHONUNBUFFERED=1 +ENV PYTHONIOENCODING="utf-8" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN curl -sSL https://install.python-poetry.org | python +ENV PATH="/root/.local/bin:/opt/venv/bin:${PATH}" + +# install dependencies +COPY . /app/ + +WORKDIR /app + +# hadolint ignore=SC1091,DL3013 +# install dependencies and build wheels +RUN python -m venv /opt/venv && \ + . /opt/venv/bin/activate && \ + pip install --no-cache-dir -U pip && \ + pip install --no-cache-dir wheel && \ + poetry install --no-root --no-interaction + +# build the Rasa SDK wheel and install it +# hadolint ignore=SC1091,DL3013 +RUN poetry build -f wheel -n && \ + pip install --no-deps dist/*.whl && \ + rm -rf dist *.egg-info + +RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && rm -rf /root/.cache/pip/* + +EXPOSE 5055 +ENTRYPOINT [""] \ No newline at end of file diff --git a/Makefile b/Makefile index 41bbecc3d..cd36690b2 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ .PHONY: clean test lint init check-readme docs TEST_PATH=./ +INTEGRATION_TEST_FOLDER = integration_tests +GRPC_SERVER_INTEGRATION_TEST_FOLDER = $(INTEGRATION_TEST_FOLDER)/grpc_server help: ## show help message - @grep -E '^[a-z.A-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' install: ## install dependencies poetry run python -m pip install -U pip @@ -39,10 +41,12 @@ lint-docstrings: ## check docstrings ./scripts/lint_python_docstrings.sh $(BRANCH) IMAGE_NAME ?= rasa/rasa-sdk +IMAGE_WITH_DEV_DEPS ?= rasa/rasa-sdk-with-dev-deps IMAGE_TAG ?= latest PLATFORM ?= linux/arm64 POETRY_VERSION ?= $(shell ./scripts/poetry-version.sh) +# Builds a docker image with runtime dependencies installed build-docker: ## build docker image for one platform docker build . \ --build-arg POETRY_VERSION=$(POETRY_VERSION) \ @@ -50,8 +54,16 @@ build-docker: ## build docker image for one platform -f Dockerfile \ -t $(IMAGE_NAME):$(IMAGE_TAG) +# Builds a docker image with runtime and dev dependencies installed +build-docker-with-dev-deps: ## build docker image with dev dependencies for one platform + docker build . \ + --build-arg POETRY_VERSION=$(POETRY_VERSION) \ + --platform=$(PLATFORM) \ + -f Dockerfile.dev \ + -t $(IMAGE_WITH_DEV_DEPS):$(IMAGE_TAG) + # To be able to build a multiplatform docker image -# make sure that builder with appropriate docker driver is enabled and set as current +# make sure that builder with appropriate docker driver is enabled and set as current builder build-and-push-multi-platform-docker: PLATFORM = linux/amd64,linux/arm64 build-and-push-multi-platform-docker: ## build and push multi-platform docker image docker buildx build . \ @@ -61,8 +73,22 @@ build-and-push-multi-platform-docker: ## build and push multi-platform docker i -t $(IMAGE_NAME):$(IMAGE_TAG) \ --push +# To be able to build a multiplatform docker image with dev dependencies +# make sure that builder with appropriate docker driver is enabled and set as current builder +build-and-push-multi-platform-docker-with-dev-deps: PLATFORM = linux/amd64,linux/arm64 +build-and-push-multi-platform-docker-with-dev-deps: ## build and push multi-platform docker image with dev dependencies + docker buildx build . \ + --build-arg POETRY_VERSION=$(POETRY_VERSION) \ + --platform=$(PLATFORM) \ + -f Dockerfile.dev \ + -t $(IMAGE_WITH_DEV_DEPS):$(IMAGE_TAG) \ + --push + test: clean ## run tests - poetry run py.test tests --cov rasa_sdk -v + poetry run \ + pytest tests \ + --cov rasa_sdk \ + -v generate-pending-changelog: ## generate a changelog for the next release poetry run python -c "from scripts import release; release.generate_changelog('major.minor.patch')" @@ -84,11 +110,48 @@ generate-grpc: ## generate grpc code --python_out=. \ --grpc_python_out=. \ --pyi_out=. \ - proto/action_webhook.proto \ - proto/health.proto + proto/action_webhook.proto check-generate-grpc-code-in-sync: generate-grpc check-generate-grpc-code-in-sync: ## check if the generated code is in sync with the proto files # this is a helper to check if the generated code is in sync with the proto files - # it's not run on CI at the moment - git diff --exit-code rasa_sdk/grpc_py | if [ "$$(wc -c)" -eq 0 ]; then echo "Generated code is in sync with proto files"; else echo "Generated code is not in sync with proto files"; exit 1; fi \ No newline at end of file + git diff --exit-code rasa_sdk/grpc_py | if [ "$$(wc -c)" -eq 0 ]; then echo "Generated code is in sync with proto files"; else echo "Generated code is not in sync with proto files"; exit 1; fi + +GRPC_STANDALONE_SERVER_INTEGRATION_TEST_RESULTS_FILE = grpc-standalone-server-integration-test-results.xml + +run-grpc-standalone-integration-tests: ## run the grpc standalone integration tests + docker run --rm \ + -v $(PWD):/app \ + $(IMAGE_WITH_DEV_DEPS):$(IMAGE_TAG) \ + poetry run \ + pytest $(INTEGRATION_TEST_FOLDER)/test_standalone_grpc_server.py \ + --junitxml=$(GRPC_STANDALONE_SERVER_INTEGRATION_TEST_RESULTS_FILE) \ + --verbose + +GRPC_SERVER_DOCKER_COMPOSE_FILE = $(GRPC_SERVER_INTEGRATION_TEST_FOLDER)/setup/docker-compose.yml + +start-grpc-integration-test-env: ## run the rnv for the grpc integration tests + RASA_SDK_REPOSITORY=$(IMAGE_NAME) \ + RASA_SDK_IMAGE_TAG=$(IMAGE_TAG) \ + docker compose -f $(GRPC_SERVER_DOCKER_COMPOSE_FILE) up --wait + +stop-grpc-integration-test-env: ## stop the env for the grpc integration tests + RASA_SDK_REPOSITORY=$(IMAGE_NAME) \ + RASA_SDK_IMAGE_TAG=$(IMAGE_TAG) \ + docker compose -f $(GRPC_SERVER_DOCKER_COMPOSE_FILE) down + +GRPC_SERVER_DOCKER_INTEGRATION_TEST_RESULTS_FILE = grpc-server-docker-integration-test-results.xml + +# Runs the gRPC integration tests in a docker container created from the image with dev dependencies +# Make sure to first start the environment with `make start-grpc-integration-test-env` before running this target +run-grpc-integration-tests: ## run the grpc integration tests + docker run --rm \ + -v $(PWD):/app \ + --network setup_rasa-pro-network \ + -e GRPC_ACTION_SERVER_HOST="action-server-grpc-no-tls" \ + -e GRPC_ACTION_SERVER_TLS_HOST="action-server-grpc-tls" \ + $(IMAGE_WITH_DEV_DEPS):$(IMAGE_TAG) \ + poetry run \ + pytest $(GRPC_SERVER_INTEGRATION_TEST_FOLDER)/test_docker_grpc_server.py \ + --junitxml=$(GRPC_SERVER_DOCKER_INTEGRATION_TEST_RESULTS_FILE) \ + --verbose \ No newline at end of file diff --git a/certs/.gitignore b/certs/.gitignore new file mode 100644 index 000000000..adad26a53 --- /dev/null +++ b/certs/.gitignore @@ -0,0 +1,9 @@ +ca-key.pem +ca.csr +ca.pem +client-key.pem +client.csr +client.pem +server-key.pem +server.csr +server.pem \ No newline at end of file diff --git a/certs/Makefile b/certs/Makefile new file mode 100644 index 000000000..2dffe42cf --- /dev/null +++ b/certs/Makefile @@ -0,0 +1,32 @@ +HOSTNAME ?= "localhost,127.0.0.1,action-server-grpc-tls,action-server-https" + +install-cfssl-mac-os: ## install cfssl on mac os + brew install cfssl + +install-cfssl-ubuntu: ## install cfssl on ubuntu + sudo apt install -y golang-cfssl + +generate-ca: ## generate ca + cfssl gencert \ + -initca config/ca-csr.json \ + | cfssljson -bare ca + +generate-client-cert: generate-ca ## generate client cert + cfssl gencert \ + -ca=ca.pem \ + -ca-key=ca-key.pem \ + -config=config/ca-config.json \ + config/client-csr.json | \ + cfssljson -bare client + +generate-server-cert: generate-ca ## generate server cert + cfssl gencert \ + -ca=ca.pem \ + -ca-key=ca-key.pem \ + -config=config/ca-config.json \ + -hostname=$(HOSTNAME) \ + config/server-csr.json | \ + cfssljson -bare server + +generate-certs: generate-ca generate-client-cert generate-server-cert ## generate client and server certs + diff --git a/certs/config/ca-config.json b/certs/config/ca-config.json new file mode 100644 index 000000000..778320c6f --- /dev/null +++ b/certs/config/ca-config.json @@ -0,0 +1,10 @@ +{ + "signing": { + "profiles": { + "default": { + "usages": ["signing", "key encipherment", "server auth", "client auth"], + "expiry": "8760h" + } + } + } +} \ No newline at end of file diff --git a/certs/config/ca-csr.json b/certs/config/ca-csr.json new file mode 100644 index 000000000..ab1c67a44 --- /dev/null +++ b/certs/config/ca-csr.json @@ -0,0 +1,16 @@ +{ + "CN": "Example CA", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "US", + "L": "San Francisco", + "O": "Example", + "OU": "CertificateAuthority", + "ST": "California" + } + ] +} \ No newline at end of file diff --git a/certs/config/client-csr.json b/certs/config/client-csr.json new file mode 100644 index 000000000..77e732586 --- /dev/null +++ b/certs/config/client-csr.json @@ -0,0 +1,16 @@ +{ + "CN": "TestClient", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "US", + "L": "San Francisco", + "O": "Example", + "OU": "SRE-Operations", + "ST": "California" + } + ] +} \ No newline at end of file diff --git a/certs/config/server-csr.json b/certs/config/server-csr.json new file mode 100644 index 000000000..5d5fcee20 --- /dev/null +++ b/certs/config/server-csr.json @@ -0,0 +1,16 @@ +{ + "CN": "server.example.com", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "US", + "L": "San Francisco", + "O": "Example", + "OU": "SRE-Operations", + "ST": "California" + } + ] +} \ No newline at end of file diff --git a/changelog/1129.bugfix.md b/changelog/1129.bugfix.md new file mode 100644 index 000000000..5505d32f3 --- /dev/null +++ b/changelog/1129.bugfix.md @@ -0,0 +1 @@ +Fixed health check for the case when the action service is run in gRPC mode. \ No newline at end of file diff --git a/integration_tests/__init__.py b/integration_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py new file mode 100644 index 000000000..4643b0290 --- /dev/null +++ b/integration_tests/conftest.py @@ -0,0 +1,150 @@ +def ca_cert() -> bytes: + """Return a test CA certificate.""" + return """ +-----BEGIN CERTIFICATE----- +MIID0jCCArqgAwIBAgIUVLV90N7w8fTANdhr9M/CxalVuVswDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yOTA4MDUxNDEzMDBaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECBMK +Q2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEQMA4GA1UEChMHRXhh +bXBsZTEdMBsGA1UECxMUQ2VydGlmaWNhdGVBdXRob3JpdHkxEzARBgNVBAMTCkV4 +YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDB2llL5tEF +1WvVGALRKbHI3kjo7ewetOrfKYyyVQEvJLRt5GL3AQaQ57OrS8Ogu3CP9YgsefER +2hcy/gYVJQKgo2nUk9+L6chC4mHX/6FyKfJn80cv2WiaCpQAp3p9JLQgISyah0IS +nKWn9xWsk2uLoc1j6CwPQNhKp9d8PZlmCt4FIYaFLTPP5u8PUNJo4kAXrEh2SaPb +3RPMUwYaA5zzJ5zBenHGwrrexHzsxA77hYJuQ6TzVr15KjX2vbcSsqIvdHIE8Ka8 +mqJ/rk/x9NthFDFifOHDceAbcIF953upkof0YvE7V746IfJumNE7Fm1hgQQAun/K +e44WkDFgSdQDAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBQYNSawZjUI+zgiZ0b/ujc/ZqDmHjANBgkqhkiG9w0BAQsF +AAOCAQEAKzKf5Fr94KQ0rY6uVzP6pi9AAqBZ0hBH2S5ooKUae7z5/shd1uo3+QPp +a9gOKWkH3UJDh8OJWokflPHbcNezOj2+FxFHhfmEwuITQzjFm8hiTliZyA3avicg +eUbRUwPzhMMJ9OngitE2HYyE6JdGEhzngCF9/M8zWZsZ8nn28NnPreTpigq4lWyg +iBCqWmPp7wbZ3b1YS1/QwzAk/eXodT3u8MFC06yons+jIuNgvxGZ73feOsAP7ZS0 +43QKF+iWGOoXLS1WcbIxbQEapYTZ+llV/JJFD+ML6XNPwCSlvbOwnUuy0sX4Mrfg +Ouz+SGo1ry05xHLQybY6QE9KvsYhlw== +-----END CERTIFICATE-----""".encode() + + +def client_key() -> bytes: + """Return a test client key.""" + return """ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA2YPKz8ZIPNfjcc3vPhDVDfNRIWfoEwh0NvbXUC8pCEv+GzEW +mX0zNA0PUW2u4fLBgK0Hqg5AdWmQ5HLfK8P5s9EKsqjT5jLEwUi3nJykd1AvR/zZ +AtXUkekNIKRrTvFFBbJsGWZkXp+R48fthPXDjDBnXA7qatmLpHXqC8mzyp8yFmC0 +izP+xllnXjRWSmTen60rcNdELlAVYkTj6e0CUHRoup8OBAGbm1XCY7XV91ETjq/6 +AHMdhKAoHvZN76yFxUA8bg0Z2gzXIE2pv6xfNkYumRzC2m0r1rF3U/g8nd5UqrN3 +CgwFeSijZOVNwpNthUzqSMe4+tD03TcvQrGpcQIDAQABAoIBAQDLWlFDyqZCa7tx +7AudRPNKpY3V42SuVpr/v+owRsbfwNwB4/Sy7r/uC7+kaxyylNefSyT9MXHF0zno +uhQ2wHM0T1znBruEXTZhVXCDdFa2TTrG3HauFeczumPRfqXsGdhjqRky7e0sIZat +E37VbUayS5Z2FGPIHTZWPP7gomP6Kv1QaONF7tfIAVBLlABTGU7csLLVib/y/rTh +Im7n/hjHBiWdLMKOI0iihVWSye+Wl6ymDZa7JFiE2IkPhUvUGoHEDVLn7tpjBuun +w5sjaKMnXojuuchwHCrdjFfLgo6AFnN6CY2C3tFDlOpICZ3Oomc4cLXEGE6XBd4M ++h+u2W2hAoGBAOCrZEAWdW9zGquhKiXowRgRpb5HPiNYuWBOOraaoTE9CUPkbyAq +tqF9gNQVhi4vc+rPxMPzK1BAuzyb0eNURT1AsZWPxg+qi+YUTiqmNQb67ndY7ToT +G/RBr/jVS+XZsAALm8khk5phBkHJI6P2Lzo1eSZcoSrzrjG/uBOWNWAtAoGBAPfY ++zgvk9fFqDUP5I9udcgmoMk7oQKypcqQOlfq46ANXVYb8gJGzg2axxEvS10jftn0 +MtS/Cvfeqwbe/rK3p7y2Q+Y64n5bhSAVHjYGwcxeqqtJkSZIEAheiI3LzUNo+6aF +YjzNPD4xgPXQhQNFGXpFdQb/Eb6OfeKD53Xz7bTVAoGAAaYgYTwI9p1wp6vSJF8V +87hFcCUTtqyzB5rrYWW3IyZgiAgILMNDfeHu7R+PUY11m1aVCh8hxUAEX8iA/Nsk +evObmg5pFLpatoCVpkh8ASYcU/HqI8/6F4vX38qo+PHlEcsEBLDjZXGq2xa/1Tc8 +V4AG+JobcLZDJAhVMIecsq0CgYB9PtsEs5ZEbY/o8JURnkJK2KpbxpRA5sI9MNEq +6HoKwXYvM4QCfoFWAqciGgI9mNhbj7m4JKqIQ6+tkzamXYSYKor5ZzxZmioV4lYX ++yYn/pbEZDLDY5smf48GCL07mWvB5JmWHCibTSzcC3mMA3kyfrL6zB7NavhWZU2s +H4452QKBgQCMnShga0yQ2ryeJJU2vrimSX9BIfNIy07lmFNIwmC6J476HIylT3yF +RSaDg+rNqfCtrP6nqezPjLr1AXewi9x2C0uWrjYYmuFai+bKMTyS/k1Mco76utHc +Db/bq3mrc2P+OOqj0QMu+xhl+/frAvyza7xSlzn2GoM9GudGjOUdgg== +-----END RSA PRIVATE KEY-----""".encode() + + +def client_cert() -> bytes: + """Return a test client certificate.""" + return """ +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIUeXi9LcpLRtBCeTmQ6KS1ov/4BbQwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yNTA4MDYxNDEzMDBaMHoxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpD +YWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFt +cGxlMRcwFQYDVQQLEw5TUkUtT3BlcmF0aW9uczETMBEGA1UEAxMKVGVzdENsaWVu +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANmDys/GSDzX43HN7z4Q +1Q3zUSFn6BMIdDb211AvKQhL/hsxFpl9MzQND1FtruHywYCtB6oOQHVpkORy3yvD ++bPRCrKo0+YyxMFIt5ycpHdQL0f82QLV1JHpDSCka07xRQWybBlmZF6fkePH7YT1 +w4wwZ1wO6mrZi6R16gvJs8qfMhZgtIsz/sZZZ140Vkpk3p+tK3DXRC5QFWJE4+nt +AlB0aLqfDgQBm5tVwmO11fdRE46v+gBzHYSgKB72Te+shcVAPG4NGdoM1yBNqb+s +XzZGLpkcwtptK9axd1P4PJ3eVKqzdwoMBXkoo2TlTcKTbYVM6kjHuPrQ9N03L0Kx +qXECAwEAAaN/MH0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB +BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQXqwHvxyKa6Mg2kdrB +c/ybhyNENDAfBgNVHSMEGDAWgBQYNSawZjUI+zgiZ0b/ujc/ZqDmHjANBgkqhkiG +9w0BAQsFAAOCAQEAKzpdtsYvjqdVrzIkFnPsVDljgMBv4ag/SNnaEYxqwOIdojFh +rQ4FvrzEbwmECUzVU7CUDkg9XoBGj7Qf+nqYzw7UO5woSOLFF2KjVQCdstH+3Kwg +nJAy7JOVKA4uWTDPf05INFVY9bbtfzG+EpMKiFXBa/CNgp9kReVTj1jMDB597yeI +hQcrocqenlnXz1EziDW9CFyKuYRcdGLhg4SAjiOPVAuZw0ryBgMjIDKUhlOBN67G +sOZMP+aju2tgmeCzVhsb+20fYDASLXar1b5fxRdzTOrQAhMjkzJ5cz7a+bzzPAQR +KDJEf9W6uMHE4/yFbHpnGjXO+HIdjeOKNhM3mw== +-----END CERTIFICATE-----""".encode() + + +def server_cert() -> bytes: + """Return a test server certificate.""" + return """ +-----BEGIN CERTIFICATE----- +MIIEXDCCA0SgAwIBAgIUAOmo9+aG+09fh0x7PmUWqblbvTMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yNTA4MDYxNDEzMDBaMIGCMQswCQYDVQQGEwJVUzETMBEGA1UECBMK +Q2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEQMA4GA1UEChMHRXhh +bXBsZTEXMBUGA1UECxMOU1JFLU9wZXJhdGlvbnMxGzAZBgNVBAMTEnNlcnZlci5l +eGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL12Uc82 +W2Nyy5WL67AgvQSKcec83WymBpywltv537Zfm7kBQsYzxNY4PT8UEt7eUTT3CUbe +DG3Nn7NEqNO7eR4aQvrn7n7vwfIq45ifX/SRvm64nAzqPRwf2lRL8RzQSThEFjbJ +IJElgKnBgZKa35p7tlEBzLZ0f+BDMXd8TNjaPQ6c2QmIDZqZTZTXclfDIGlk2BPM +H1I96rhMP09I2WogZCG0L3Wwyg4ZZQwZUvBrAbJX7Yz7LXgogIOn5sZCNT8jYPj9 +nOJzXxGGYet3j2qvpKBiG0mihjng64DAwp97W6yHRSTk/a7dDNWR2+W1B9dGM1ZM +L0goLAmpaZnJ+UsCAwEAAaOByTCBxjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYw +FAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFMGp +Cj9YDz8jfs1/qPSmlj2dS5KyMB8GA1UdIwQYMBaAFBg1JrBmNQj7OCJnRv+6Nz9m +oOYeMEcGA1UdEQRAMD6CCWxvY2FsaG9zdIIWYWN0aW9uLXNlcnZlci1ncnBjLXRs +c4ITYWN0aW9uLXNlcnZlci1odHRwc4cEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEA +rk297Fmax47njNZTQXrYDujlh5jEJU1h1YnQETG9cXMhXTPfht+P0NVEWwBYR+oH +98hEnfvvRK+qY6piuHAk6TQ3omgppEXf9RK6Miswx3JcSiuoakl/N48IKE2vX2bV +cS4KvLwPjk93EZjo4c0yJ3NKw4m3ub3wG+smK4Sgu+OS6Pp6PLODA9XQoVNJYFv9 +im7m353JI8Wl01DOHyvM5JJITqhTJEEOBoMIueFZJjKalGViqlv4iyO8ju4RD68L +9XbMV5Dfx84VZgw/9x0lVPzs1nuVqIDlFO9pcEORZzhowFTncm8hLZaekglfoz2g +HZgIZxn/ZUdXHbJnES88rg== +-----END CERTIFICATE-----""".encode() + + +def server_cert_key() -> bytes: + """Return a test server key.""" + return """ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvXZRzzZbY3LLlYvrsCC9BIpx5zzdbKYGnLCW2/nftl+buQFC +xjPE1jg9PxQS3t5RNPcJRt4Mbc2fs0So07t5HhpC+ufufu/B8irjmJ9f9JG+bric +DOo9HB/aVEvxHNBJOEQWNskgkSWAqcGBkprfmnu2UQHMtnR/4EMxd3xM2No9DpzZ +CYgNmplNlNdyV8MgaWTYE8wfUj3quEw/T0jZaiBkIbQvdbDKDhllDBlS8GsBslft +jPsteCiAg6fmxkI1PyNg+P2c4nNfEYZh63ePaq+koGIbSaKGOeDrgMDCn3tbrIdF +JOT9rt0M1ZHb5bUH10YzVkwvSCgsCalpmcn5SwIDAQABAoIBAGX0PPP3+Vfb14hD +QnKGO5Es+zyHeVRVR6ucL6JypZtbec4lyowkIaFxpCvEnp8GHccVxQ04wpF4HJ94 +rC08bmHvmemxU5tFZCXwRIfGA/n1T/f/KiKdDoOK9I92SmrJ/wzjPtz+gKL6/nDb +a+G7q+ZS+no/MK1ipcnF/uGgpy3t3Lj1+IVOqCIwjMGy1vDyHf2jokcdRfugYiSG +SH+pepTNMoyHnulgqWk53IZ3R6wiux5EG8kTqTuXfFTHoiuVwlX8WHyrL8wDS7Yk +7bfD7HCcUt/zkLH1b3SZ0G47pBeh/xfTyukaKIAyB8W0WBzp5MNpkHIQ+BXEjkXV +76iwVykCgYEAxuVpOS0wKunRcQsLcOtAC/w8XRNs4M9DanvjpoOqto9D8jtKEhQz +oNk7HclF93blpjgrdt78i5uTZYj7Cni2fHq+ImwYgiNSMhI9mKrZHU124tULmjGi +7OIVwYkybuRYqrdTssOLNXw3xRIQfWdYnnQWnqqF74NJz3v5FRkdYM8CgYEA89uG +um0isGMtkQPHT8kOMoB4NKg81DhasL947eJ9nylndTC5Be8+d6AWcT6D5nj5PJro +49hXA7eZr7KlM+i8C33XV/4kwSX+tIhKpxZCLvrYYH5tMry9C9ocYjdJ5vgANqHG +jzOee7VHx03/TEsrzRRABfEUWLv8fjPz3uOkZsUCgYAwGLnhQbQsLG6hT3Js0/ag +71YJi/EATB6ZWWwrkBtwGiTsJro6tPfnJaDOCyYIOZA/KcYY8MNRX0W9f+p1FcvL +PkNMP6eNkM0HnrGWgXoPQ/RD1hEAMJCdh+6K5opzlnR6k+qBiBfZzzgNR9kE+kDL +0HbH16SbzrvCmNAa8f/QsQKBgHPADB+JliGrsgFXjc/tW3PVTzmPcfA6H7It8LTa +AU7/VEBoNCsA+OHHd1+hkPJhEc0Wqt0b35HAII8peQk+u6OoxALce43C/zeqSeUt +x5SNNQ/4ctTWidWKXlawb7/WkCNefuWSVvPZhIyyFTCdrdjWd91aJZJJCPzOpD90 +RqERAoGAJpkCQxJns6fN42vIxw4xGiRgsOUI0fEX5RdEksPv9K4piaT+xD7UY5V9 +hEr5DLQa1+7Sj2kR3Az+O6Q8VLgOcsnL/B0Vn5VikHgo79Us5lN7QDEu1ravFObb +/IByW5uI0N1CKns8fbREqNw7z9KJjg0Gx599SrdcRUEPvc+BHts= +-----END RSA PRIVATE KEY-----""".encode() \ No newline at end of file diff --git a/integration_tests/grpc_server/__init__.py b/integration_tests/grpc_server/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration_tests/grpc_server/setup/actions/action_hi.py b/integration_tests/grpc_server/setup/actions/action_hi.py new file mode 100644 index 000000000..23eaf2366 --- /dev/null +++ b/integration_tests/grpc_server/setup/actions/action_hi.py @@ -0,0 +1,17 @@ +from typing import Any, Dict + +from rasa_sdk import Action, Tracker +from rasa_sdk.executor import CollectingDispatcher + + +class ActionHi(Action): + + def name(self) -> str: + return "action_hi" + + def run(self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[str, Any]): + dispatcher.utter_message(text="Hi") + return [] diff --git a/integration_tests/grpc_server/setup/certs/ca-key.pem b/integration_tests/grpc_server/setup/certs/ca-key.pem new file mode 100644 index 000000000..766c2c881 --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/ca-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAwdpZS+bRBdVr1RgC0SmxyN5I6O3sHrTq3ymMslUBLyS0beRi +9wEGkOezq0vDoLtwj/WILHnxEdoXMv4GFSUCoKNp1JPfi+nIQuJh1/+hcinyZ/NH +L9lomgqUAKd6fSS0ICEsmodCEpylp/cVrJNri6HNY+gsD0DYSqfXfD2ZZgreBSGG +hS0zz+bvD1DSaOJAF6xIdkmj290TzFMGGgOc8yecwXpxxsK63sR87MQO+4WCbkOk +81a9eSo19r23ErKiL3RyBPCmvJqif65P8fTbYRQxYnzhw3HgG3CBfed7qZKH9GLx +O1e+OiHybpjROxZtYYEEALp/ynuOFpAxYEnUAwIDAQABAoIBAQC+EFd9E9Hc5mPT +irc4XKjzSP4zYxMfCENAinxoXO/MSTCejk5534eQi5ydVqt37E9w1kutT+IMnsgg +Nu+/y4nH9nDM/C8x/wVajptgLEMerAH/6YbiY0crwTNbcNmn2VTCK8SPvg4KMYw6 +IwgdLG1Fel7mlbydN/bZO7cLGVpNsKUdykyYY5nWyGdzrz7a4rTEufIvrlczWb66 +5S5tNeN1TcwV7wJAJtbLn/F6qXkzROypMOhN7ARBuMwX2CEziSLcu//AOTOrG7qf +lSgva1Lvh8MdRubB147vkPkC9lnd8oWC2jd+89TD8dMyw9B/uPuB0BvahzLmhYmw +e7cmDNABAoGBAOHMVrGlDBkKJIQqWHtRwMC6bDczW0ygTsZ5FWlsG9Q+2v60w/G6 +N02ng8CZ7haJTrO27wpJxXMjhpLvmI5PLWBjppjjS4xfkYJQEfbQ6t8DD86gxnko +Uzkl6oGLOZ+8X1Qfw6hOfG70e78lxuFYDQale9jDsOL1HG7ufW6G+q6ZAoGBANvI +KD2Iu+6u7gaz7GjmCnyoSISaAS6Oe/+6nphFeU+r8ZV+MtWIz6t8ImeQbwHEEfaQ +dKl0tKkBQKZGvvCfJQpQhphhrEpo9VeJC75WgdErK3NwFDmnntcfNooRV9DhrTJX +z3+lSiW+OoGsK61SriMvetn8IgmHH3CI8bX3IET7AoGADJplNGEr/bHNO/tJGQkF +IzzEkYgyTmKcQwO7KDk6jGw9uP3J+hIUyCbTecqduXBMjkdlrENV3Aldl5H8B+Vv +ePPW9q2pJ9qluopRll8u3OfE2BmtOlH/4y60mip1Ou8+uulS+G/5DhBjX9Xx9d3+ +TCKsePrjQqe/wIlbQvIRx/ECgYEAszoytpt+xlIZAZZQP+r4uldxa9E7DYBcVlsV +Yg+lsfcB92sGgWQkFjAQzmvWWpZOH8gFmvr1KK1Az1f59beSOSsZ18IvAi89g7ja +vslE7BJMSMMpeyraOYvWqhkih9DBsMAzEmD0ansKsxx4Mcuu/jqF8KXQC/0JAnhq +xH1W72kCgYEAxijxa4ldUzm93DC5kVSCFmg5hfE15T/JC4vEeQ6Hyk3F/yzMoPem +zJbEw7ptXQv8pfiKWFMFsmWUc03ahH88L4R4/c5100Cr0IkZmI5+bJIJoPnTbEry +fvZT8yky2uTNF60+/wkZ2H8Mgi1w+5oXj1ayf5CzC8w43et89jyqFKw= +-----END RSA PRIVATE KEY----- diff --git a/integration_tests/grpc_server/setup/certs/ca.pem b/integration_tests/grpc_server/setup/certs/ca.pem new file mode 100644 index 000000000..c21186a3f --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID0jCCArqgAwIBAgIUVLV90N7w8fTANdhr9M/CxalVuVswDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yOTA4MDUxNDEzMDBaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECBMK +Q2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEQMA4GA1UEChMHRXhh +bXBsZTEdMBsGA1UECxMUQ2VydGlmaWNhdGVBdXRob3JpdHkxEzARBgNVBAMTCkV4 +YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDB2llL5tEF +1WvVGALRKbHI3kjo7ewetOrfKYyyVQEvJLRt5GL3AQaQ57OrS8Ogu3CP9YgsefER +2hcy/gYVJQKgo2nUk9+L6chC4mHX/6FyKfJn80cv2WiaCpQAp3p9JLQgISyah0IS +nKWn9xWsk2uLoc1j6CwPQNhKp9d8PZlmCt4FIYaFLTPP5u8PUNJo4kAXrEh2SaPb +3RPMUwYaA5zzJ5zBenHGwrrexHzsxA77hYJuQ6TzVr15KjX2vbcSsqIvdHIE8Ka8 +mqJ/rk/x9NthFDFifOHDceAbcIF953upkof0YvE7V746IfJumNE7Fm1hgQQAun/K +e44WkDFgSdQDAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBQYNSawZjUI+zgiZ0b/ujc/ZqDmHjANBgkqhkiG9w0BAQsF +AAOCAQEAKzKf5Fr94KQ0rY6uVzP6pi9AAqBZ0hBH2S5ooKUae7z5/shd1uo3+QPp +a9gOKWkH3UJDh8OJWokflPHbcNezOj2+FxFHhfmEwuITQzjFm8hiTliZyA3avicg +eUbRUwPzhMMJ9OngitE2HYyE6JdGEhzngCF9/M8zWZsZ8nn28NnPreTpigq4lWyg +iBCqWmPp7wbZ3b1YS1/QwzAk/eXodT3u8MFC06yons+jIuNgvxGZ73feOsAP7ZS0 +43QKF+iWGOoXLS1WcbIxbQEapYTZ+llV/JJFD+ML6XNPwCSlvbOwnUuy0sX4Mrfg +Ouz+SGo1ry05xHLQybY6QE9KvsYhlw== +-----END CERTIFICATE----- diff --git a/integration_tests/grpc_server/setup/certs/client-key.pem b/integration_tests/grpc_server/setup/certs/client-key.pem new file mode 100644 index 000000000..3b34a2ecf --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/client-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA2YPKz8ZIPNfjcc3vPhDVDfNRIWfoEwh0NvbXUC8pCEv+GzEW +mX0zNA0PUW2u4fLBgK0Hqg5AdWmQ5HLfK8P5s9EKsqjT5jLEwUi3nJykd1AvR/zZ +AtXUkekNIKRrTvFFBbJsGWZkXp+R48fthPXDjDBnXA7qatmLpHXqC8mzyp8yFmC0 +izP+xllnXjRWSmTen60rcNdELlAVYkTj6e0CUHRoup8OBAGbm1XCY7XV91ETjq/6 +AHMdhKAoHvZN76yFxUA8bg0Z2gzXIE2pv6xfNkYumRzC2m0r1rF3U/g8nd5UqrN3 +CgwFeSijZOVNwpNthUzqSMe4+tD03TcvQrGpcQIDAQABAoIBAQDLWlFDyqZCa7tx +7AudRPNKpY3V42SuVpr/v+owRsbfwNwB4/Sy7r/uC7+kaxyylNefSyT9MXHF0zno +uhQ2wHM0T1znBruEXTZhVXCDdFa2TTrG3HauFeczumPRfqXsGdhjqRky7e0sIZat +E37VbUayS5Z2FGPIHTZWPP7gomP6Kv1QaONF7tfIAVBLlABTGU7csLLVib/y/rTh +Im7n/hjHBiWdLMKOI0iihVWSye+Wl6ymDZa7JFiE2IkPhUvUGoHEDVLn7tpjBuun +w5sjaKMnXojuuchwHCrdjFfLgo6AFnN6CY2C3tFDlOpICZ3Oomc4cLXEGE6XBd4M ++h+u2W2hAoGBAOCrZEAWdW9zGquhKiXowRgRpb5HPiNYuWBOOraaoTE9CUPkbyAq +tqF9gNQVhi4vc+rPxMPzK1BAuzyb0eNURT1AsZWPxg+qi+YUTiqmNQb67ndY7ToT +G/RBr/jVS+XZsAALm8khk5phBkHJI6P2Lzo1eSZcoSrzrjG/uBOWNWAtAoGBAPfY ++zgvk9fFqDUP5I9udcgmoMk7oQKypcqQOlfq46ANXVYb8gJGzg2axxEvS10jftn0 +MtS/Cvfeqwbe/rK3p7y2Q+Y64n5bhSAVHjYGwcxeqqtJkSZIEAheiI3LzUNo+6aF +YjzNPD4xgPXQhQNFGXpFdQb/Eb6OfeKD53Xz7bTVAoGAAaYgYTwI9p1wp6vSJF8V +87hFcCUTtqyzB5rrYWW3IyZgiAgILMNDfeHu7R+PUY11m1aVCh8hxUAEX8iA/Nsk +evObmg5pFLpatoCVpkh8ASYcU/HqI8/6F4vX38qo+PHlEcsEBLDjZXGq2xa/1Tc8 +V4AG+JobcLZDJAhVMIecsq0CgYB9PtsEs5ZEbY/o8JURnkJK2KpbxpRA5sI9MNEq +6HoKwXYvM4QCfoFWAqciGgI9mNhbj7m4JKqIQ6+tkzamXYSYKor5ZzxZmioV4lYX ++yYn/pbEZDLDY5smf48GCL07mWvB5JmWHCibTSzcC3mMA3kyfrL6zB7NavhWZU2s +H4452QKBgQCMnShga0yQ2ryeJJU2vrimSX9BIfNIy07lmFNIwmC6J476HIylT3yF +RSaDg+rNqfCtrP6nqezPjLr1AXewi9x2C0uWrjYYmuFai+bKMTyS/k1Mco76utHc +Db/bq3mrc2P+OOqj0QMu+xhl+/frAvyza7xSlzn2GoM9GudGjOUdgg== +-----END RSA PRIVATE KEY----- diff --git a/integration_tests/grpc_server/setup/certs/client.pem b/integration_tests/grpc_server/setup/certs/client.pem new file mode 100644 index 000000000..48cd53231 --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/client.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIUeXi9LcpLRtBCeTmQ6KS1ov/4BbQwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yNTA4MDYxNDEzMDBaMHoxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpD +YWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFt +cGxlMRcwFQYDVQQLEw5TUkUtT3BlcmF0aW9uczETMBEGA1UEAxMKVGVzdENsaWVu +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANmDys/GSDzX43HN7z4Q +1Q3zUSFn6BMIdDb211AvKQhL/hsxFpl9MzQND1FtruHywYCtB6oOQHVpkORy3yvD ++bPRCrKo0+YyxMFIt5ycpHdQL0f82QLV1JHpDSCka07xRQWybBlmZF6fkePH7YT1 +w4wwZ1wO6mrZi6R16gvJs8qfMhZgtIsz/sZZZ140Vkpk3p+tK3DXRC5QFWJE4+nt +AlB0aLqfDgQBm5tVwmO11fdRE46v+gBzHYSgKB72Te+shcVAPG4NGdoM1yBNqb+s +XzZGLpkcwtptK9axd1P4PJ3eVKqzdwoMBXkoo2TlTcKTbYVM6kjHuPrQ9N03L0Kx +qXECAwEAAaN/MH0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB +BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQXqwHvxyKa6Mg2kdrB +c/ybhyNENDAfBgNVHSMEGDAWgBQYNSawZjUI+zgiZ0b/ujc/ZqDmHjANBgkqhkiG +9w0BAQsFAAOCAQEAKzpdtsYvjqdVrzIkFnPsVDljgMBv4ag/SNnaEYxqwOIdojFh +rQ4FvrzEbwmECUzVU7CUDkg9XoBGj7Qf+nqYzw7UO5woSOLFF2KjVQCdstH+3Kwg +nJAy7JOVKA4uWTDPf05INFVY9bbtfzG+EpMKiFXBa/CNgp9kReVTj1jMDB597yeI +hQcrocqenlnXz1EziDW9CFyKuYRcdGLhg4SAjiOPVAuZw0ryBgMjIDKUhlOBN67G +sOZMP+aju2tgmeCzVhsb+20fYDASLXar1b5fxRdzTOrQAhMjkzJ5cz7a+bzzPAQR +KDJEf9W6uMHE4/yFbHpnGjXO+HIdjeOKNhM3mw== +-----END CERTIFICATE----- diff --git a/integration_tests/grpc_server/setup/certs/server-key.pem b/integration_tests/grpc_server/setup/certs/server-key.pem new file mode 100644 index 000000000..08724cf97 --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/server-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvXZRzzZbY3LLlYvrsCC9BIpx5zzdbKYGnLCW2/nftl+buQFC +xjPE1jg9PxQS3t5RNPcJRt4Mbc2fs0So07t5HhpC+ufufu/B8irjmJ9f9JG+bric +DOo9HB/aVEvxHNBJOEQWNskgkSWAqcGBkprfmnu2UQHMtnR/4EMxd3xM2No9DpzZ +CYgNmplNlNdyV8MgaWTYE8wfUj3quEw/T0jZaiBkIbQvdbDKDhllDBlS8GsBslft +jPsteCiAg6fmxkI1PyNg+P2c4nNfEYZh63ePaq+koGIbSaKGOeDrgMDCn3tbrIdF +JOT9rt0M1ZHb5bUH10YzVkwvSCgsCalpmcn5SwIDAQABAoIBAGX0PPP3+Vfb14hD +QnKGO5Es+zyHeVRVR6ucL6JypZtbec4lyowkIaFxpCvEnp8GHccVxQ04wpF4HJ94 +rC08bmHvmemxU5tFZCXwRIfGA/n1T/f/KiKdDoOK9I92SmrJ/wzjPtz+gKL6/nDb +a+G7q+ZS+no/MK1ipcnF/uGgpy3t3Lj1+IVOqCIwjMGy1vDyHf2jokcdRfugYiSG +SH+pepTNMoyHnulgqWk53IZ3R6wiux5EG8kTqTuXfFTHoiuVwlX8WHyrL8wDS7Yk +7bfD7HCcUt/zkLH1b3SZ0G47pBeh/xfTyukaKIAyB8W0WBzp5MNpkHIQ+BXEjkXV +76iwVykCgYEAxuVpOS0wKunRcQsLcOtAC/w8XRNs4M9DanvjpoOqto9D8jtKEhQz +oNk7HclF93blpjgrdt78i5uTZYj7Cni2fHq+ImwYgiNSMhI9mKrZHU124tULmjGi +7OIVwYkybuRYqrdTssOLNXw3xRIQfWdYnnQWnqqF74NJz3v5FRkdYM8CgYEA89uG +um0isGMtkQPHT8kOMoB4NKg81DhasL947eJ9nylndTC5Be8+d6AWcT6D5nj5PJro +49hXA7eZr7KlM+i8C33XV/4kwSX+tIhKpxZCLvrYYH5tMry9C9ocYjdJ5vgANqHG +jzOee7VHx03/TEsrzRRABfEUWLv8fjPz3uOkZsUCgYAwGLnhQbQsLG6hT3Js0/ag +71YJi/EATB6ZWWwrkBtwGiTsJro6tPfnJaDOCyYIOZA/KcYY8MNRX0W9f+p1FcvL +PkNMP6eNkM0HnrGWgXoPQ/RD1hEAMJCdh+6K5opzlnR6k+qBiBfZzzgNR9kE+kDL +0HbH16SbzrvCmNAa8f/QsQKBgHPADB+JliGrsgFXjc/tW3PVTzmPcfA6H7It8LTa +AU7/VEBoNCsA+OHHd1+hkPJhEc0Wqt0b35HAII8peQk+u6OoxALce43C/zeqSeUt +x5SNNQ/4ctTWidWKXlawb7/WkCNefuWSVvPZhIyyFTCdrdjWd91aJZJJCPzOpD90 +RqERAoGAJpkCQxJns6fN42vIxw4xGiRgsOUI0fEX5RdEksPv9K4piaT+xD7UY5V9 +hEr5DLQa1+7Sj2kR3Az+O6Q8VLgOcsnL/B0Vn5VikHgo79Us5lN7QDEu1ravFObb +/IByW5uI0N1CKns8fbREqNw7z9KJjg0Gx599SrdcRUEPvc+BHts= +-----END RSA PRIVATE KEY----- diff --git a/integration_tests/grpc_server/setup/certs/server.pem b/integration_tests/grpc_server/setup/certs/server.pem new file mode 100644 index 000000000..87645e23a --- /dev/null +++ b/integration_tests/grpc_server/setup/certs/server.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEXDCCA0SgAwIBAgIUAOmo9+aG+09fh0x7PmUWqblbvTMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRAwDgYDVQQKEwdFeGFtcGxlMR0wGwYDVQQLExRDZXJ0 +aWZpY2F0ZUF1dGhvcml0eTETMBEGA1UEAxMKRXhhbXBsZSBDQTAeFw0yNDA4MDYx +NDEzMDBaFw0yNTA4MDYxNDEzMDBaMIGCMQswCQYDVQQGEwJVUzETMBEGA1UECBMK +Q2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEQMA4GA1UEChMHRXhh +bXBsZTEXMBUGA1UECxMOU1JFLU9wZXJhdGlvbnMxGzAZBgNVBAMTEnNlcnZlci5l +eGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL12Uc82 +W2Nyy5WL67AgvQSKcec83WymBpywltv537Zfm7kBQsYzxNY4PT8UEt7eUTT3CUbe +DG3Nn7NEqNO7eR4aQvrn7n7vwfIq45ifX/SRvm64nAzqPRwf2lRL8RzQSThEFjbJ +IJElgKnBgZKa35p7tlEBzLZ0f+BDMXd8TNjaPQ6c2QmIDZqZTZTXclfDIGlk2BPM +H1I96rhMP09I2WogZCG0L3Wwyg4ZZQwZUvBrAbJX7Yz7LXgogIOn5sZCNT8jYPj9 +nOJzXxGGYet3j2qvpKBiG0mihjng64DAwp97W6yHRSTk/a7dDNWR2+W1B9dGM1ZM +L0goLAmpaZnJ+UsCAwEAAaOByTCBxjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYw +FAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFMGp +Cj9YDz8jfs1/qPSmlj2dS5KyMB8GA1UdIwQYMBaAFBg1JrBmNQj7OCJnRv+6Nz9m +oOYeMEcGA1UdEQRAMD6CCWxvY2FsaG9zdIIWYWN0aW9uLXNlcnZlci1ncnBjLXRs +c4ITYWN0aW9uLXNlcnZlci1odHRwc4cEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEA +rk297Fmax47njNZTQXrYDujlh5jEJU1h1YnQETG9cXMhXTPfht+P0NVEWwBYR+oH +98hEnfvvRK+qY6piuHAk6TQ3omgppEXf9RK6Miswx3JcSiuoakl/N48IKE2vX2bV +cS4KvLwPjk93EZjo4c0yJ3NKw4m3ub3wG+smK4Sgu+OS6Pp6PLODA9XQoVNJYFv9 +im7m353JI8Wl01DOHyvM5JJITqhTJEEOBoMIueFZJjKalGViqlv4iyO8ju4RD68L +9XbMV5Dfx84VZgw/9x0lVPzs1nuVqIDlFO9pcEORZzhowFTncm8hLZaekglfoz2g +HZgIZxn/ZUdXHbJnES88rg== +-----END CERTIFICATE----- diff --git a/integration_tests/grpc_server/setup/docker-compose.yml b/integration_tests/grpc_server/setup/docker-compose.yml new file mode 100644 index 000000000..6748e1721 --- /dev/null +++ b/integration_tests/grpc_server/setup/docker-compose.yml @@ -0,0 +1,38 @@ +configs: + action-server-endpoints.yml: + content: | + empty: "empty" + + +services: + action-server-grpc-no-tls: + container_name: action-server-grpc-no-tls + image: ${RASA_SDK_REPOSITORY}:${RASA_SDK_IMAGE_TAG} + volumes: + - "./actions:/app/actions" + configs: + - source: action-server-endpoints.yml + target: /app/endpoints.yml + networks: [ 'rasa-pro-network' ] + entrypoint: "python -m rasa_sdk" + command: --actions actions -p 7010 --grpc + ports: + - "7010:7010" + + action-server-grpc-tls: + container_name: action-server-grpc-tls + image: ${RASA_SDK_REPOSITORY}:${RASA_SDK_IMAGE_TAG} + volumes: + - "./certs/server.pem:/certs/server.pem" + - "./certs/server-key.pem:/certs/server-key.pem" + - "./actions:/app/actions" + configs: + - source: action-server-endpoints.yml + target: /app/endpoints.yml + networks: [ 'rasa-pro-network' ] + entrypoint: "python -m rasa_sdk" + command: --actions actions -p 7011 --grpc --ssl-certificate /certs/server.pem --ssl-keyfile /certs/server-key.pem + ports: + - "7011:7011" + +networks: { rasa-pro-network: { } } diff --git a/integration_tests/grpc_server/test_docker_grpc_server.py b/integration_tests/grpc_server/test_docker_grpc_server.py new file mode 100644 index 000000000..e1608e888 --- /dev/null +++ b/integration_tests/grpc_server/test_docker_grpc_server.py @@ -0,0 +1,136 @@ +import logging +import os +from typing import Text, List, Tuple + +import grpc +import pytest + +from google.protobuf.json_format import MessageToDict +from grpc_health.v1 import health_pb2 +from grpc_health.v1.health_pb2_grpc import HealthStub + +from rasa_sdk.grpc_py import action_webhook_pb2_grpc, action_webhook_pb2 +from rasa_sdk.grpc_server import GRPC_ACTION_SERVER_NAME +from integration_tests.conftest import ca_cert, client_key, client_cert + +GRPC_HOST = os.getenv("GRPC_ACTION_SERVER_HOST", "localhost") +GRPC_NO_TLS_PORT = os.getenv("GRPC_NO_TLS_PORT", 7010) +GRPC_TLS_HOST = os.getenv("GRPC_ACTION_SERVER_TLS_HOST", "localhost") +GRPC_TLS_PORT = os.getenv("GRPC_TLS_PORT", 7011) +GRPC_REQUEST_TIMEOUT_IN_SECONDS = os.getenv("GRPC_REQUEST_TIMEOUT_IN_SECONDS", 5) + +logger = logging.getLogger(__name__) + + +def create_no_tls_channel() -> grpc.Channel: + """Create a gRPC channel for the action server.""" + logger.info(f"Creating insecure channel to {GRPC_HOST}:{GRPC_NO_TLS_PORT}") + + return grpc.insecure_channel( + target=f"{GRPC_HOST}:{GRPC_NO_TLS_PORT}", + compression=grpc.Compression.Gzip) + + +def create_tls_channel( +) -> grpc.Channel: + """Create a gRPC channel for the action server.""" + credentials = grpc.ssl_channel_credentials( + root_certificates=ca_cert(), + private_key=client_key(), + certificate_chain=client_cert(), + ) + + logger.info(f"Creating secure channel to {GRPC_TLS_HOST}:{GRPC_TLS_PORT}") + return grpc.secure_channel( + target=f"{GRPC_TLS_HOST}:{GRPC_TLS_PORT}", + credentials=credentials, + compression=grpc.Compression.Gzip) + + +GrpcMetadata = List[Tuple[Text, Text]] + + +@pytest.fixture(scope='session') +def grpc_metadata() -> GrpcMetadata: + return [('test-key', 'test-value')] + + +@pytest.fixture(scope='session') +def grpc_webhook_request() -> action_webhook_pb2.WebhookRequest: + return action_webhook_pb2.WebhookRequest( + next_action="action_hi", + sender_id="test_sender_id", + version="test_version", + domain_digest="test_domain_digest", + tracker=action_webhook_pb2.Tracker( + sender_id="test_sender_id", + slots={}, + latest_message={}, + events=[], + paused=False, + followup_action="", + active_loop={}, + latest_action_name="", + stack={}, + ), + domain=action_webhook_pb2.Domain( + config={}, + session_config={}, + intents=[], + entities=[], + slots={}, + responses={}, + actions=[], + forms={}, + e2e_actions=[], + ), + ) + + +@pytest.mark.parametrize("grpc_channel", [ + create_no_tls_channel(), + create_tls_channel(), +]) +def test_grpc_server_webhook( + grpc_channel: grpc.Channel, + grpc_webhook_request: action_webhook_pb2.WebhookRequest, + grpc_metadata: GrpcMetadata, +) -> None: + """Test Webhook invocation of the gRPC server.""" + + client = action_webhook_pb2_grpc.ActionServiceStub(grpc_channel) + + # Invoke Webhook method + rpc_response = client.Webhook( + grpc_webhook_request, + metadata=grpc_metadata, + timeout=GRPC_REQUEST_TIMEOUT_IN_SECONDS, + wait_for_ready=True, + ) + + response = MessageToDict( + rpc_response, + ) + + # Verify the response + assert set(response.keys()) == {'responses'} + assert len(response['responses']) == 1 + assert response['responses'][0]['text'] == 'Hi' + + +@pytest.mark.parametrize("grpc_channel", [ + create_no_tls_channel(), + create_tls_channel(), +]) +def test_grpc_server_healthcheck( + grpc_channel: grpc.Channel, +) -> None: + """Test healthcheck endpoint of the gRPC server.""" + client = HealthStub(grpc_channel) + + response = client.Check( + health_pb2.HealthCheckRequest(service=GRPC_ACTION_SERVER_NAME), + wait_for_ready=True, + timeout=GRPC_REQUEST_TIMEOUT_IN_SECONDS, + ) + assert response.status == health_pb2.HealthCheckResponse.SERVING diff --git a/integration_tests/test_standalone_grpc_server.py b/integration_tests/test_standalone_grpc_server.py new file mode 100644 index 000000000..881d94572 --- /dev/null +++ b/integration_tests/test_standalone_grpc_server.py @@ -0,0 +1,291 @@ +import asyncio +import threading +from typing import Text, List, Tuple, Set, Union +from unittest.mock import AsyncMock + +import grpc +import pytest + +from google.protobuf.json_format import MessageToDict +from grpc_health.v1 import health_pb2 +from grpc_health.v1.health_pb2_grpc import HealthStub + +from rasa_sdk.executor import ActionExecutor, ActionExecutorRunResult +from rasa_sdk.grpc_errors import ResourceNotFound, ResourceNotFoundType +from rasa_sdk.grpc_py import action_webhook_pb2_grpc, action_webhook_pb2 +from rasa_sdk.grpc_server import GRPC_ACTION_SERVER_NAME, _initialise_grpc_server +from rasa_sdk.interfaces import ActionNotFoundException, ActionMissingDomainException +from integration_tests.conftest import server_cert, server_cert_key, ca_cert, client_key, client_cert + +GRPC_PORT = 6000 +GRPC_TLS_PORT = 6001 +GRPC_HOST = 'localhost' + + +@pytest.fixture(scope="module") +def mock_executor() -> AsyncMock: + """Create a mock action executor.""" + return AsyncMock(spec=ActionExecutor) + + +@pytest.fixture(scope="module") +def grpc_action_server(mock_executor: AsyncMock) -> None: + """Create a gRPC server for the action server.""" + + _event = asyncio.Event() + + async def _run_grpc_server() -> None: + _grpc_action_server = _initialise_grpc_server( + action_executor=mock_executor, + port=GRPC_PORT, + max_number_of_workers=2, + ) + await _grpc_action_server.start() + await _event.wait() + await _grpc_action_server.stop(None) + + thread = threading.Thread(target=asyncio.run, args=(_run_grpc_server(),), daemon=True) + thread.start() + yield + _event.set() + + +@pytest.fixture(scope="module") +def grpc_tls_action_server(mock_executor: AsyncMock) -> None: + """Create a gRPC server for the action server.""" + + _event = asyncio.Event() + + async def _run_grpc_server() -> None: + _grpc_action_server = _initialise_grpc_server( + action_executor=mock_executor, + port=GRPC_TLS_PORT, + max_number_of_workers=2, + ssl_server_cert=server_cert(), + ssl_server_cert_key=server_cert_key(), + ) + await _grpc_action_server.start() + await _event.wait() + await _grpc_action_server.stop(None) + + thread = threading.Thread(target=asyncio.run, args=(_run_grpc_server(),), daemon=True) + thread.start() + yield + _event.set() + + +@pytest.fixture +def grpc_channel() -> grpc.Channel: + """Create a gRPC channel for the action server.""" + return grpc.insecure_channel(target=f"{GRPC_HOST}:{GRPC_PORT}", compression=grpc.Compression.Gzip) + + +@pytest.fixture +def grpc_action_client(grpc_channel: grpc.Channel) -> action_webhook_pb2_grpc.ActionServiceStub: + """Create a gRPC client for the action server.""" + client = action_webhook_pb2_grpc.ActionServiceStub(grpc_channel) + return client + + +@pytest.fixture +def grpc_tls_channel() -> grpc.Channel: + """Create a gRPC channel for the action server.""" + credentials = grpc.ssl_channel_credentials( + root_certificates=ca_cert(), + private_key=client_key(), + certificate_chain=client_cert(), + ) + return grpc.secure_channel( + target=f"{GRPC_HOST}:{GRPC_TLS_PORT}", + compression=grpc.Compression.Gzip, + credentials=credentials, + ) + + +@pytest.fixture +def grpc_tls_action_client(grpc_tls_channel: grpc.Channel) -> action_webhook_pb2_grpc.ActionServiceStub: + """Create a gRPC client for the action server.""" + client = action_webhook_pb2_grpc.ActionServiceStub(grpc_tls_channel) + return client + + +GrpcMetadata = List[Tuple[Text, Text]] + + +@pytest.fixture +def grpc_metadata() -> GrpcMetadata: + return [('test-key', 'test-value')] + + +@pytest.fixture +def grpc_webhook_request() -> action_webhook_pb2.WebhookRequest: + return action_webhook_pb2.WebhookRequest( + next_action="action_listen", + sender_id="test_sender_id", + version="test_version", + domain_digest="test_domain_digest", + tracker=action_webhook_pb2.Tracker( + sender_id="test_sender_id", + slots={}, + latest_message={}, + events=[], + paused=False, + followup_action="", + active_loop={}, + latest_action_name="", + stack={}, + ), + domain=action_webhook_pb2.Domain( + config={}, + session_config={}, + intents=[], + entities=[], + slots={}, + responses={}, + actions=[], + forms={}, + e2e_actions=[], + ), + ) + + +@pytest.mark.usefixtures("grpc_action_server", "grpc_tls_action_server") +@pytest.mark.parametrize( + "executor_result, expected_keys", [ + ( + ActionExecutorRunResult( + events=[], + responses=[], + ), + set(), + ), + ( + ActionExecutorRunResult( + events=[], + responses=[{"recipient_id": "test_sender_id", "text": "test_response"}], + ), + {"responses"}, + ), + ( + ActionExecutorRunResult( + events=[{"event": "action", "name": "action_listen"}], + responses=[], + ), + {"events"}, + ), + ( + ActionExecutorRunResult( + events=[{"event": "action", "name": "action_listen"}], + responses=[{"recipient_id": "test_sender_id", "text": "test_response"}], + ), + {"events", "responses"}, + ), +]) +@pytest.mark.parametrize("action_client_name", ["grpc_action_client", "grpc_tls_action_client"]) +def test_grpc_server_webhook( + action_client_name: str, + executor_result: ActionExecutorRunResult, + expected_keys: Set[str], + mock_executor: AsyncMock, + grpc_webhook_request: action_webhook_pb2.WebhookRequest, + grpc_metadata: GrpcMetadata, + request: pytest.FixtureRequest, +) -> None: + """Test connectivity to the gRPC server without SSL.""" + action_client = request.getfixturevalue(action_client_name) + + # Given a mock executor + mock_executor.run.return_value = executor_result + + # Invoke Webhook method + rpc_response = action_client.Webhook( + grpc_webhook_request, + metadata=grpc_metadata, + timeout=5, + wait_for_ready=True, + ) + + response = MessageToDict( + rpc_response, + ) + + # Verify the response + assert set(response.keys()) == expected_keys + assert response.get("events", []) == executor_result.events + assert response.get("responses", []) == executor_result.responses + + +@pytest.mark.usefixtures("grpc_action_server", "grpc_tls_action_server") +@pytest.mark.parametrize("exception, resource_type", [ + (ActionMissingDomainException("test_action"), ResourceNotFoundType.DOMAIN), + (ActionNotFoundException("test_action"), ResourceNotFoundType.ACTION), +]) +@pytest.mark.parametrize("action_client_name", ["grpc_action_client", "grpc_tls_action_client"]) +def test_grpc_server_action_missing_domain( + action_client_name: str, + exception: Union[ActionMissingDomainException, ActionNotFoundException], + resource_type: ResourceNotFoundType, + mock_executor: AsyncMock, + grpc_webhook_request: action_webhook_pb2.WebhookRequest, + grpc_metadata: GrpcMetadata, + request: pytest.FixtureRequest, +) -> None: + """Test connectivity to the gRPC server when domain is missing.""" + + # Given a mock executor + action_name = "test_action" + mock_executor.run.side_effect = exception + + action_client = request.getfixturevalue(action_client_name) + + # Invoke Webhook method + with pytest.raises(grpc.RpcError) as exc: + action_client.Webhook( + grpc_webhook_request, + metadata=grpc_metadata, + timeout=5, + wait_for_ready=True, + ) + + # Verify the response is a gRPC error + assert exc.value.code() == grpc.StatusCode.NOT_FOUND + + # Verify the error details + resource_not_found = ResourceNotFound.model_validate_json(exc.value.details()) + assert resource_not_found.action_name == action_name + assert resource_not_found.resource_type == resource_type + assert resource_not_found.message == exception.message + + +@pytest.fixture +def grpc_healthcheck_client(grpc_channel: grpc.Channel) -> HealthStub: + """Create a gRPC client for the action server.""" + client = HealthStub(grpc_channel) + + return client + + +@pytest.fixture +def grpc_tls_healthcheck_client(grpc_tls_channel: grpc.Channel) -> HealthStub: + """Create a gRPC client for the action server.""" + client = HealthStub(grpc_tls_channel) + + return client + + +@pytest.mark.usefixtures("grpc_action_server", "grpc_tls_action_server") +@pytest.mark.parametrize("health_client_name", + ["grpc_healthcheck_client", "grpc_tls_healthcheck_client"]) +def test_grpc_server_healthcheck( + health_client_name, + request: pytest.FixtureRequest, +) -> None: + """Test healthcheck endpoint of the gRPC server.""" + health_client = request.getfixturevalue(health_client_name) + + response = health_client.Check( + health_pb2.HealthCheckRequest(service=GRPC_ACTION_SERVER_NAME), + wait_for_ready=True, + timeout=5, + ) + assert response.status == health_pb2.HealthCheckResponse.SERVING diff --git a/poetry.lock b/poetry.lock index e0f6693b9..e47859819 100644 --- a/poetry.lock +++ b/poetry.lock @@ -462,6 +462,21 @@ files = [ [package.extras] protobuf = ["grpcio-tools (>=1.59.3)"] +[[package]] +name = "grpcio-health-checking" +version = "1.59.3" +description = "Standard Health Checking Service for gRPC" +optional = false +python-versions = ">=3.6" +files = [ + {file = "grpcio-health-checking-1.59.3.tar.gz", hash = "sha256:015017ce4164fc7dce81da3a1718a4b3153230e481b2cdebf392468b613f0766"}, + {file = "grpcio_health_checking-1.59.3-py3-none-any.whl", hash = "sha256:30b0184173d5a7a48788b1643968a9f75154b2bf8d47baf795c03402727a84cf"}, +] + +[package.dependencies] +grpcio = ">=1.59.3" +protobuf = ">=4.21.6" + [[package]] name = "grpcio-tools" version = "1.56.2" @@ -2043,4 +2058,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.11" -content-hash = "3d6c98a410fac33dd7cadf4d2535f99443e3eed6981f463254f4f12ddd2b2b60" +content-hash = "73c3b65eb0052bae1164bd81d1bfb8e4e2703cd708037ea9d84e7d2929cbaf7f" diff --git a/proto/health.proto b/proto/health.proto deleted file mode 100644 index d6d4f8a14..000000000 --- a/proto/health.proto +++ /dev/null @@ -1,11 +0,0 @@ -syntax = "proto3"; - -package grpc.health.v1; - -message HealthCheckRequest {} - -message HealthCheckResponse {} - -service HealthService { - rpc Check(HealthCheckRequest) returns (HealthCheckResponse); -} diff --git a/pyproject.toml b/pyproject.toml index a5a5aed8e..fa8bf036a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ grpcio = "1.59.3" protobuf = "4.25.3" grpcio-tools = "1.56.2" pydantic = "2.6.4" +grpcio-health-checking = "1.59.3" [tool.poetry.dev-dependencies] pytest-cov = "^4.1.0" diff --git a/rasa_sdk/__main__.py b/rasa_sdk/__main__.py index ab5ffc243..d9bcfb60c 100644 --- a/rasa_sdk/__main__.py +++ b/rasa_sdk/__main__.py @@ -1,9 +1,10 @@ -import logging import asyncio +import logging from rasa_sdk import utils -from rasa_sdk.endpoint import create_argument_parser, run from rasa_sdk.constants import APPLICATION_ROOT_LOGGER_NAME +from rasa_sdk.endpoint import create_argument_parser, run +from rasa_sdk.executor import ActionExecutor from rasa_sdk.grpc_server import run_grpc logger = logging.getLogger(__name__) @@ -22,10 +23,15 @@ def main_from_args(args): ) utils.update_sanic_log_level() + action_executor = ActionExecutor() + action_executor.register_package( + args.actions_module or args.actions, + ) + if args.grpc: asyncio.run( run_grpc( - args.actions_module or args.actions, + action_executor, args.port, args.ssl_certificate, args.ssl_keyfile, @@ -36,7 +42,7 @@ def main_from_args(args): ) else: run( - args.actions_module or args.actions, + action_executor, args.port, args.cors, args.ssl_certificate, diff --git a/rasa_sdk/endpoint.py b/rasa_sdk/endpoint.py index 93d2e46be..b202ad3f6 100644 --- a/rasa_sdk/endpoint.py +++ b/rasa_sdk/endpoint.py @@ -1,7 +1,6 @@ import argparse import logging import os -import types import warnings import zlib import json @@ -97,15 +96,14 @@ async def load_tracer_provider(endpoints: str, app: Sanic): def create_app( - action_package_name: Union[Text, types.ModuleType], + action_executor: ActionExecutor, cors_origins: Union[Text, List[Text], None] = "*", auto_reload: bool = False, ) -> Sanic: """Create a Sanic application and return it. Args: - action_package_name: Name of the package or module to load actions - from. + action_executor: The action executor to use. cors_origins: CORS origins to allow. auto_reload: When `True`, auto-reloading of actions is enabled. @@ -119,9 +117,6 @@ def create_app( configure_cors(app, cors_origins) - executor = ActionExecutor() - executor.register_package(action_package_name) - app.ctx.tracer_provider = None @app.get("/health") @@ -165,9 +160,9 @@ def header_to_multi_dict(headers: Header) -> MultiDict: utils.check_version_compatibility(action_call.get("version")) if auto_reload: - executor.reload() + action_executor.reload() try: - result = await executor.run(action_call) + result = await action_executor.run(action_call) except ActionExecutionRejection as e: logger.debug(e) body = {"error": e.message, "action_name": e.action_name} @@ -188,17 +183,20 @@ def header_to_multi_dict(headers: Header) -> MultiDict: route="/webhook", ) - return response.json(result, status=200) + return response.json( + result.model_dump() if result else None, + status=200, + ) @app.get("/actions") async def actions(_) -> HTTPResponse: """List all registered actions.""" if auto_reload: - executor.reload() + action_executor.reload() body = [ action_name_item.model_dump() - for action_name_item in executor.list_actions() + for action_name_item in action_executor.list_actions() ] return response.json(body, status=200) @@ -215,7 +213,7 @@ async def exception_handler(request, exception: Exception): def run( - action_package_name: Union[Text, types.ModuleType], + action_executor: ActionExecutor, port: int = DEFAULT_SERVER_PORT, cors_origins: Union[Text, List[Text], None] = "*", ssl_certificate: Optional[Text] = None, @@ -230,7 +228,7 @@ def run( loader = AppLoader( factory=partial( create_app, - action_package_name, + action_executor, cors_origins=cors_origins, auto_reload=auto_reload, ), diff --git a/rasa_sdk/executor.py b/rasa_sdk/executor.py index 24d4705ec..1790a47d7 100644 --- a/rasa_sdk/executor.py +++ b/rasa_sdk/executor.py @@ -170,6 +170,13 @@ def utter_image_url(self, image: Text, **kwargs: Any) -> None: TimestampModule = namedtuple("TimestampModule", ["timestamp", "module"]) +class ActionExecutorRunResult(BaseModel): + """Model for action executor run result.""" + + events: List[Dict[Text, Any]] = Field(alias="events") + responses: List[Dict[Text, Any]] = Field(alias="responses") + + class ActionExecutor: """Executes actions.""" @@ -372,8 +379,8 @@ def reload(self) -> None: @staticmethod def _create_api_response( events: List[Dict[Text, Any]], messages: List[Dict[Text, Any]] - ) -> Dict[Text, Any]: - return {"events": events, "responses": messages} + ) -> ActionExecutorRunResult: + return ActionExecutorRunResult(events=events, responses=messages) @staticmethod def validate_events( @@ -471,7 +478,10 @@ def update_and_return_domain( return self.domain - async def run(self, action_call: Dict[Text, Any]) -> Optional[Dict[Text, Any]]: + async def run( + self, + action_call: Dict[Text, Any], + ) -> Optional[ActionExecutorRunResult]: """Run the action and return the response. Args: diff --git a/rasa_sdk/grpc_py/health_pb2.py b/rasa_sdk/grpc_py/health_pb2.py deleted file mode 100644 index 8f58d31d7..000000000 --- a/rasa_sdk/grpc_py/health_pb2.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: rasa_sdk/grpc_py/health.proto -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1drasa_sdk/grpc_py/health.proto\x12\x0egrpc.health.v1\"\x14\n\x12HealthCheckRequest\"\x15\n\x13HealthCheckResponse2a\n\rHealthService\x12P\n\x05\x43heck\x12\".grpc.health.v1.HealthCheckRequest\x1a#.grpc.health.v1.HealthCheckResponseb\x06proto3') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rasa_sdk.grpc_py.health_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - _globals['_HEALTHCHECKREQUEST']._serialized_start=49 - _globals['_HEALTHCHECKREQUEST']._serialized_end=69 - _globals['_HEALTHCHECKRESPONSE']._serialized_start=71 - _globals['_HEALTHCHECKRESPONSE']._serialized_end=92 - _globals['_HEALTHSERVICE']._serialized_start=94 - _globals['_HEALTHSERVICE']._serialized_end=191 -# @@protoc_insertion_point(module_scope) diff --git a/rasa_sdk/grpc_py/health_pb2.pyi b/rasa_sdk/grpc_py/health_pb2.pyi deleted file mode 100644 index a58962527..000000000 --- a/rasa_sdk/grpc_py/health_pb2.pyi +++ /dev/null @@ -1,13 +0,0 @@ -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from typing import ClassVar as _ClassVar - -DESCRIPTOR: _descriptor.FileDescriptor - -class HealthCheckRequest(_message.Message): - __slots__ = [] - def __init__(self) -> None: ... - -class HealthCheckResponse(_message.Message): - __slots__ = [] - def __init__(self) -> None: ... diff --git a/rasa_sdk/grpc_py/health_pb2_grpc.py b/rasa_sdk/grpc_py/health_pb2_grpc.py deleted file mode 100644 index 904f921bb..000000000 --- a/rasa_sdk/grpc_py/health_pb2_grpc.py +++ /dev/null @@ -1,66 +0,0 @@ -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc - -from rasa_sdk.grpc_py import health_pb2 as rasa__sdk_dot_grpc__py_dot_health__pb2 - - -class HealthServiceStub(object): - """Missing associated documentation comment in .proto file.""" - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.Check = channel.unary_unary( - '/grpc.health.v1.HealthService/Check', - request_serializer=rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckRequest.SerializeToString, - response_deserializer=rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckResponse.FromString, - ) - - -class HealthServiceServicer(object): - """Missing associated documentation comment in .proto file.""" - - def Check(self, request, context): - """Missing associated documentation comment in .proto file.""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_HealthServiceServicer_to_server(servicer, server): - rpc_method_handlers = { - 'Check': grpc.unary_unary_rpc_method_handler( - servicer.Check, - request_deserializer=rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckRequest.FromString, - response_serializer=rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckResponse.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'grpc.health.v1.HealthService', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - - - # This class is part of an EXPERIMENTAL API. -class HealthService(object): - """Missing associated documentation comment in .proto file.""" - - @staticmethod - def Check(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary(request, target, '/grpc.health.v1.HealthService/Check', - rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckRequest.SerializeToString, - rasa__sdk_dot_grpc__py_dot_health__pb2.HealthCheckResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/rasa_sdk/grpc_server.py b/rasa_sdk/grpc_server.py index 71a9fc681..7ac6994da 100644 --- a/rasa_sdk/grpc_server.py +++ b/rasa_sdk/grpc_server.py @@ -6,10 +6,12 @@ import grpc import logging -import types -from typing import Union, Optional, Any, Dict +from typing import Optional, Any, Dict from concurrent import futures from grpc import aio +from grpc_health.v1 import health +from grpc_health.v1 import health_pb2 +from grpc_health.v1 import health_pb2_grpc from google.protobuf.json_format import MessageToDict, ParseDict from grpc.aio import Metadata from multidict import MultiDict @@ -28,14 +30,12 @@ from rasa_sdk.grpc_py import ( action_webhook_pb2, action_webhook_pb2_grpc, - health_pb2_grpc, ) from rasa_sdk.grpc_py.action_webhook_pb2 import ( ActionsResponse, ActionsRequest, WebhookRequest, ) -from rasa_sdk.grpc_py.health_pb2 import HealthCheckRequest, HealthCheckResponse from rasa_sdk.interfaces import ( ActionExecutionRejection, ActionNotFoundException, @@ -56,25 +56,7 @@ logger = logging.getLogger(__name__) -class GRPCActionServerHealthCheck(health_pb2_grpc.HealthServiceServicer): - """Runs health check RPC which is served through gRPC server.""" - - def __init__(self) -> None: - """Initializes the HealthServicer.""" - pass - - def Check(self, request: HealthCheckRequest, context) -> HealthCheckResponse: - """Handle RPC request for the health check. - - Args: - request: The health check request. - context: The context of the request. - - Returns: - gRPC response. - """ - response = HealthCheckResponse() - return response +GRPC_ACTION_SERVER_NAME = "ActionServer" class GRPCActionServerWebhook(action_webhook_pb2_grpc.ActionServiceServicer): @@ -193,12 +175,12 @@ def convert_metadata_to_multidict( if not result: return action_webhook_pb2.WebhookResponse() - set_grpc_span_attributes(span, action_call, method_name="Webhook") + _set_grpc_span_attributes(span, action_call, method_name="Webhook") response = action_webhook_pb2.WebhookResponse() - return ParseDict(result, response) + return ParseDict(result.model_dump(), response) -def set_grpc_span_attributes( +def _set_grpc_span_attributes( span: Any, action_call: Dict[str, Any], method_name: str ) -> None: """Sets grpc span attributes.""" @@ -207,18 +189,18 @@ def set_grpc_span_attributes( span.set_attribute("grpc.method", method_name) -def get_signal_name(signal_number: int) -> str: +def _get_signal_name(signal_number: int) -> str: """Return the signal name for the given signal number.""" return signal.Signals(signal_number).name -def initialise_interrupts(server: grpc.aio.Server) -> None: +def _initialise_interrupts(server: grpc.Server) -> None: """Initialise handlers for kernel signal interrupts.""" async def handle_sigint(signal_received: int): """Handle the received signal.""" logger.info( - f"Received {get_signal_name(signal_received)} signal." + f"Received {_get_signal_name(signal_received)} signal." "Stopping gRPC server..." ) await server.stop(NO_GRACE_PERIOD) @@ -233,57 +215,59 @@ async def handle_sigint(signal_received: int): ) -async def run_grpc( - action_package_name: Union[str, types.ModuleType], - port: int = DEFAULT_SERVER_PORT, - ssl_certificate: Optional[str] = None, - ssl_keyfile: Optional[str] = None, - ssl_ca_file: Optional[str] = None, - auto_reload: bool = False, - endpoints: str = DEFAULT_ENDPOINTS_PATH, +def _initialise_health_service(server: grpc.Server): + """Initialise the health service. + + Args: + server: The gRPC server. + """ + health_servicer = health.HealthServicer( + experimental_non_blocking=True, + experimental_thread_pool=futures.ThreadPoolExecutor(max_workers=10), + ) + health_servicer.set(GRPC_ACTION_SERVER_NAME, health_pb2.HealthCheckResponse.SERVING) + health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server) + + +def _initialise_action_service( + server: grpc.Server, + action_executor: ActionExecutor, + auto_reload: bool, + endpoints: str, ): - """Start a gRPC server to handle incoming action requests. + """Initialise the action service. Args: - action_package_name: Name of the package which contains the custom actions. - port: Port to start the server on. - ssl_certificate: File path to the SSL certificate. - ssl_keyfile: File path to the SSL key file. - ssl_ca_file: File path to the SSL CA certificate file. + server: The gRPC server. + action_executor: The action executor. auto_reload: Enable auto-reloading of modules containing Action subclasses. endpoints: Path to the endpoints file. """ - workers = number_of_sanic_workers() - server = aio.server( - futures.ThreadPoolExecutor(max_workers=workers), - compression=grpc.Compression.Gzip, - ) - initialise_interrupts(server) - executor = ActionExecutor() - executor.register_package(action_package_name) tracer_provider = get_tracer_provider(endpoints) action_webhook_pb2_grpc.add_ActionServiceServicer_to_server( - GRPCActionServerWebhook(executor, auto_reload, tracer_provider), server + GRPCActionServerWebhook(action_executor, auto_reload, tracer_provider), server ) - health_pb2_grpc.add_HealthServiceServicer_to_server( - GRPCActionServerHealthCheck(), server - ) - - ca_cert = file_as_bytes(ssl_ca_file) if ssl_ca_file else None - if ssl_certificate and ssl_keyfile: +def _initialise_port( + server: grpc.Server, + port: int = DEFAULT_SERVER_PORT, + ssl_server_cert: Optional[bytes] = None, + ssl_server_cert_key: Optional[bytes] = None, + ssl_ca_cert: Optional[bytes] = None, +) -> None: + if ssl_server_cert and ssl_server_cert_key: # Use SSL/TLS if certificate and key are provided grpc.ssl_channel_credentials() - private_key = file_as_bytes(ssl_keyfile) - certificate_chain = file_as_bytes(ssl_certificate) logger.info(f"Starting gRPC server with SSL support on port {port}") server.add_secure_port( f"[::]:{port}", server_credentials=grpc.ssl_server_credentials( - private_key_certificate_chain_pairs=[(private_key, certificate_chain)], - root_certificates=ca_cert, - require_client_auth=True if ca_cert else False, + private_key_certificate_chain_pairs=[ + (ssl_server_cert_key, ssl_server_cert) + ], + root_certificates=ssl_ca_cert, + require_client_auth=True if ssl_ca_cert else False, ), ) else: @@ -291,6 +275,88 @@ async def run_grpc( # Use insecure connection if no SSL/TLS information is provided server.add_insecure_port(f"[::]:{port}") + +def _initialise_grpc_server( + action_executor: ActionExecutor, + port: int = DEFAULT_SERVER_PORT, + max_number_of_workers: int = 10, + ssl_server_cert: Optional[bytes] = None, + ssl_server_cert_key: Optional[bytes] = None, + ssl_ca_cert: Optional[bytes] = None, + auto_reload: bool = False, + endpoints: str = DEFAULT_ENDPOINTS_PATH, +) -> grpc.Server: + """Create a gRPC server to handle incoming action requests. + + Args: + action_executor: The action executor. + port: Port to start the server on. + max_number_of_workers: Maximum number of workers to use. + ssl_server_cert: File path to the SSL certificate. + ssl_server_cert_key: File path to the SSL key file. + ssl_ca_cert: File path to the SSL CA certificate file. + auto_reload: Enable auto-reloading of modules containing Action subclasses. + endpoints: Path to the endpoints file. + + Returns: + The gRPC server. + """ + server = aio.server( + futures.ThreadPoolExecutor(max_workers=max_number_of_workers), + compression=grpc.Compression.Gzip, + ) + + _initialise_health_service(server) + _initialise_action_service(server, action_executor, auto_reload, endpoints) + _initialise_port(server, port, ssl_server_cert, ssl_server_cert_key, ssl_ca_cert) + + return server + + +async def run_grpc( + action_executor: ActionExecutor, + port: int = DEFAULT_SERVER_PORT, + ssl_server_cert_path: Optional[str] = None, + ssl_server_cert_key_file_path: Optional[str] = None, + ssl_ca_file_path: Optional[str] = None, + auto_reload: bool = False, + endpoints: str = DEFAULT_ENDPOINTS_PATH, +): + """Start a gRPC server to handle incoming action requests. + + Args: + action_executor: The action executor. + port: Port to start the server on. + ssl_server_cert_path: File path to the client SSL certificate. + ssl_server_cert_key_file_path: File path to the SSL key for client cert. + ssl_ca_file_path: File path to the SSL CA certificate file. + auto_reload: Enable auto-reloading of modules containing Action subclasses. + endpoints: Path to the endpoints file. + """ + max_number_of_workers = number_of_sanic_workers() + ssl_server_cert = ( + file_as_bytes(ssl_server_cert_path) if (ssl_server_cert_path) else None + ) + ssl_server_cert_key = ( + file_as_bytes(ssl_server_cert_key_file_path) + if (ssl_server_cert_key_file_path) + else None + ) + ssl_ca_cert = file_as_bytes(ssl_ca_file_path) if (ssl_ca_file_path) else None + + server = _initialise_grpc_server( + action_executor, + port, + max_number_of_workers, + ssl_server_cert, + ssl_server_cert_key, + ssl_ca_cert, + auto_reload, + endpoints, + ) + + _initialise_interrupts(server) + await server.start() logger.info(f"gRPC Server started on port {port}") await server.wait_for_termination() diff --git a/scripts/lint_python_docstrings.sh b/scripts/lint_python_docstrings.sh index 73cbc2db2..3248ec9e5 100755 --- a/scripts/lint_python_docstrings.sh +++ b/scripts/lint_python_docstrings.sh @@ -6,7 +6,7 @@ # Compare against `main` if no branch was provided BRANCH="${1:-main}" # Diff of committed changes (shows only changes introduced by your branch -FILES_WITH_DIFF=`git diff $BRANCH...HEAD --name-only -- rasa_sdk` +FILES_WITH_DIFF=`git diff --diff-filter=d $BRANCH...HEAD --name-only -- rasa_sdk` NB_FILES_WITH_DIFF=`echo $FILES_WITH_DIFF | grep '\S' | wc -l` if [ "$NB_FILES_WITH_DIFF" -gt 0 ] @@ -16,6 +16,8 @@ else echo "No python files in diff." fi +echo "Checking for uncommitted changes in rasa_sdk" + # Diff of uncommitted changes for running locally DEV_FILES_WITH_DIFF=`git diff HEAD --name-only -- rasa_sdk` NB_DEV_FILES_WITH_DIFF=`echo $DEV_FILES_WITH_DIFF | grep '\S' | wc -l` diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index 820652825..84ca38861 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -15,13 +15,15 @@ @pytest.fixture -def sanic_app(): - return ep.create_app("tests") +def action_executor() -> ep.ActionExecutor: + _executor = ep.ActionExecutor() + _executor.register_package("tests") + return _executor -def test_endpoint_exit_for_unknown_actions_package(): - with pytest.raises(SystemExit): - ep.create_app("non-existing-actions-package") +@pytest.fixture +def sanic_app(action_executor: ep.ActionExecutor) -> Sanic: + return ep.create_app(action_executor) def test_server_health_returns_200(sanic_app: Sanic): @@ -30,8 +32,15 @@ def test_server_health_returns_200(sanic_app: Sanic): assert response.json == {"status": "ok"} -def test_server_list_actions_returns_200(sanic_app: Sanic): +def test_server_list_actions_returns_200( + sanic_app: Sanic, +): + """Test that the server returns a list of actions.""" + + # When we request the list of actions request, response = sanic_app.test_client.get("/actions") + + # Then the server should return a list of actions assert response.status == 200 assert len(response.json) == 9 print(response.json) @@ -52,16 +61,20 @@ def test_server_list_actions_returns_200(sanic_app: Sanic): assert response.json == expected -def test_server_webhook_unknown_action_returns_404(sanic_app: Sanic): +def test_server_webhook_unknown_action_returns_404( + sanic_app: Sanic, +): data = { - "next_action": "test_action_1", + "next_action": "non_existing_action", "tracker": {"sender_id": "1", "conversation_id": "default"}, } request, response = sanic_app.test_client.post("/webhook", data=json.dumps(data)) assert response.status == 404 -def test_server_webhook_handles_action_exception(sanic_app: Sanic): +def test_server_webhook_handles_action_exception( + sanic_app: Sanic, +): data = { "next_action": "custom_action_exception", "tracker": {"sender_id": "1", "conversation_id": "default"}, @@ -73,7 +86,9 @@ def test_server_webhook_handles_action_exception(sanic_app: Sanic): assert response.json.get("request_body") == data -def test_server_webhook_custom_action_returns_200(sanic_app: Sanic): +def test_server_webhook_custom_action_returns_200( + sanic_app: Sanic, +): data = { "next_action": "custom_action", "tracker": {"sender_id": "1", "conversation_id": "default"}, diff --git a/tests/test_grpc_server.py b/tests/test_grpc_server.py index 9c9f02b95..cfb836132 100644 --- a/tests/test_grpc_server.py +++ b/tests/test_grpc_server.py @@ -1,4 +1,4 @@ -from typing import Union, Any, Dict, Text, List +from typing import Union, List from unittest.mock import MagicMock, AsyncMock import grpc @@ -6,7 +6,7 @@ from google.protobuf.json_format import MessageToDict, ParseDict from rasa_sdk import ActionExecutionRejection -from rasa_sdk.executor import ActionName, ActionExecutor +from rasa_sdk.executor import ActionName, ActionExecutor, ActionExecutorRunResult from rasa_sdk.grpc_errors import ( ActionExecutionFailed, ResourceNotFound, @@ -85,21 +85,21 @@ def grpc_action_server_webhook(mock_executor: AsyncMock) -> GRPCActionServerWebh @pytest.fixture -def executor_response() -> Dict[Text, Any]: +def executor_response() -> ActionExecutorRunResult: """Create an executor response.""" - return { - "events": [{"event": "slot", "name": "test", "value": "foo"}], - "responses": [{"utter": "Hi"}], - } + return ActionExecutorRunResult( + events=[{"event": "slot", "name": "test", "value": "foo"}], + responses=[{"utter": "Hi"}], + ) @pytest.fixture def expected_grpc_webhook_response( - executor_response: Dict[Text, Any], + executor_response: ActionExecutorRunResult, ) -> action_webhook_pb2.WebhookResponse: """Create a gRPC webhook response.""" result = action_webhook_pb2.WebhookResponse() - return ParseDict(executor_response, result) + return ParseDict(executor_response.model_dump(), result) def action_names() -> List[ActionName]: @@ -133,9 +133,9 @@ async def test_grpc_action_server_webhook_no_errors( grpc_webhook_request: action_webhook_pb2.WebhookRequest, mock_executor: AsyncMock, mock_grpc_service_context: MagicMock, - executor_response: Dict[Text, Any], + executor_response: ActionExecutorRunResult, expected_grpc_webhook_response: action_webhook_pb2.WebhookResponse, -): +) -> None: """Test that the gRPC action server webhook can handle a request without errors.""" grpc_action_server_webhook.auto_reload = auto_reload mock_executor.run.return_value = executor_response @@ -198,7 +198,7 @@ async def test_grpc_action_server_webhook_action_execution_rejected( grpc_webhook_request: action_webhook_pb2.WebhookRequest, mock_executor: AsyncMock, mock_grpc_service_context: MagicMock, -): +) -> None: """Test that the gRPC action server webhook can handle a request with an action execution rejection.""" # noqa: E501 mock_executor.run.side_effect = exception response = await grpc_action_server_webhook.Webhook( @@ -231,7 +231,7 @@ async def test_grpc_action_server_actions( grpc_action_server_webhook: GRPCActionServerWebhook, mock_grpc_service_context: MagicMock, mock_executor: AsyncMock, -): +) -> None: """Test that the gRPC action server webhook can handle a request for actions.""" mock_executor.list_actions.return_value = given_action_names diff --git a/tests/tracing/instrumentation/test_tracing.py b/tests/tracing/instrumentation/test_tracing.py index de472b4a4..75f14c68f 100644 --- a/tests/tracing/instrumentation/test_tracing.py +++ b/tests/tracing/instrumentation/test_tracing.py @@ -46,7 +46,9 @@ def test_server_webhook_custom_action_is_instrumented( ) data["next_action"] = action_name data["domain"] = {} - app = ep.create_app(action_package) + action_executor = ep.ActionExecutor() + action_executor.register_package(action_package) + app = ep.create_app(action_executor) app.register_listener( partial(ep.load_tracer_provider, "endpoints.yml"), @@ -89,7 +91,9 @@ def test_server_webhook_custom_action_is_not_instrumented( ) -> None: """Tests that the server is not instrumented if no tracer provider is provided.""" data["next_action"] = action_name - app = ep.create_app(action_package) + action_executor = ep.ActionExecutor() + action_executor.register_package(action_package) + app = ep.create_app(action_executor) _, response = app.test_client.post("/webhook", data=json.dumps(data)) assert response.status == 200