diff --git a/.config/cspell/project-software-terms.txt b/.config/cspell/project-software-terms.txt index 0613a25ef..f90a3667e 100644 --- a/.config/cspell/project-software-terms.txt +++ b/.config/cspell/project-software-terms.txt @@ -37,6 +37,7 @@ NISHAREDDIR # NI Linux install directory environment variable nicai # NI-DAQmx C library filename (Windows DLL) nicaiu # NI-DAQmx C library filename (Windows DLL, Unicode) nidevice # NI package name; ni-device-grpc +nitlsconfig # Dependency: nitlsconfig nitypes # Dependency: nitypes nptdms # Dependency: nptdms numpy # Dependency: numpy diff --git a/CHANGELOG.md b/CHANGELOG.md index aad341c7a..c82904df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to this project will be documented in this file. * ### Major Changes * Add support for Analog Path Delay + * Added `nitlsconfig[grpc]` to the `grpc` extra to enable using NI TLS to create TLS-encrypted gRPC channels. `nitlsconfig` is the Python API for reading the NI TLS configuration installed on the system and creating NI gRPC Device client channels from it. * ### Known Issues * ... diff --git a/generated/nidaqmx/_grpc_interpreter.py b/generated/nidaqmx/_grpc_interpreter.py index 38782fe17..48ea86073 100644 --- a/generated/nidaqmx/_grpc_interpreter.py +++ b/generated/nidaqmx/_grpc_interpreter.py @@ -12,6 +12,7 @@ import google.protobuf.message import grpc +import nitlsconfig import numpy from . import errors as errors @@ -101,14 +102,21 @@ class GrpcStubInterpreter(BaseInterpreter): def __init__(self, grpc_options): self._grpc_options = grpc_options self._client = nidaqmx_grpc.NiDAQmxStub(grpc_options.grpc_channel) + # Querying the driver version is the first contact with the server, so it decides + # whether this interpreter connected. + connected = False try: major_version = self.get_system_info_attribute_uint32(0x1272) minor_version = self.get_system_info_attribute_uint32(0x1923) update_version = self.get_system_info_attribute_uint32(0x2f22) + connected = True except Exception: major_version = 0 minor_version = 0 update_version = 0 + finally: + # Record the outcome of a driver's gRPC session initialize RPC. + nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version) def _invoke(self, func, request, metadata=None): @@ -141,7 +149,11 @@ def _handle_rpc_error(self, rpc_error): error_message += f'\nSamples per channel written: {entry.value}' grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: - error_message = 'Failed to connect to server' + # gRPC reports a rejected TLS handshake and an unreachable server with the + # same code, so ask nitlsconfig whether it built this channel and can say more. + error_message = nitlsconfig.get_tls_connection_error_elaboration( + self._grpc_options.grpc_channel + ) or 'Failed to connect to server' elif grpc_error == grpc.StatusCode.UNIMPLEMENTED: error_message = ( 'This operation is not supported by the NI gRPC Device Server being used. Upgrade NI gRPC Device Server.' diff --git a/poetry.lock b/poetry.lock index 9441a7fb0..54802a2eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2998,6 +2998,26 @@ pycodestyle = [ ] toml = ">=0.10.1" +[[package]] +name = "nitlsconfig" +version = "1.0.0a4" +description = "Python API for reading nitlsconfig configurations and creating NI gRPC Device client channels from them" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"grpc\"" +files = [ + {file = "nitlsconfig-1.0.0a4-py3-none-any.whl", hash = "sha256:674aaa36ae47bf862d545e9b21486dc14184f75a157d8b25366976149e01e6f0"}, + {file = "nitlsconfig-1.0.0a4.tar.gz", hash = "sha256:9fcd034424e3a80e3d0c560bcaf4eaae44b2a03a98bfc1af7892bf53b26d8fe9"}, +] + +[package.dependencies] +grpcio = {version = ">=1.49.0,<2.0", optional = true, markers = "extra == \"grpc\""} +pywin32 = {version = ">=306", optional = true, markers = "sys_platform == \"win32\" and extra == \"grpc\""} + +[package.extras] +grpc = ["grpcio (>=1.49.0,<2.0)", "pywin32 (>=306) ; sys_platform == \"win32\""] + [[package]] name = "nitypes" version = "1.1.0" @@ -4067,6 +4087,38 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"grpc\" and sys_platform == \"win32\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4924,9 +4976,9 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [extras] -grpc = ["grpcio", "ni-grpcdevice-v1-proto", "ni-protobuf-types", "protobuf"] +grpc = ["grpcio", "ni-grpcdevice-v1-proto", "ni-protobuf-types", "nitlsconfig", "protobuf"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "37345c5a039da001b927e4df3967f6e40b7789fae867146c87fb1f95ddae117d" +content-hash = "b9c8390793367c2cdc2301bba6bf7d42795c57284aaf635d76a0b938afce2e0a" diff --git a/poetry.toml b/poetry.toml index aa4a8a1fb..7a70eef7e 100644 --- a/poetry.toml +++ b/poetry.toml @@ -9,5 +9,6 @@ min-release-age-exclude = [ "ni-grpcdevice-v1-proto", "ni-protobuf-types", "ni-python-styleguide", + "nitlsconfig", "nitypes", ] diff --git a/pyproject.toml b/pyproject.toml index f8db0c3de..cf66d64bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ documentation = "https://nidaqmx-python.readthedocs.io" nidaqmx = 'nidaqmx.__main__:main' [project.optional-dependencies] -grpc = ['grpcio (>=1.49.0,<2.0)', 'protobuf (>=4.21)', 'ni-grpcdevice-v1-proto (>=1.0.0)', 'ni-protobuf-types (>=1.0.0)'] +grpc = ['grpcio (>=1.49.0,<2.0)', 'protobuf (>=4.21)', 'ni-grpcdevice-v1-proto (>=1.0.0)', 'ni-protobuf-types (>=1.0.0)', 'nitlsconfig[grpc] (>=1.0.0a4)'] [tool.poetry] diff --git a/src/codegen/templates/_grpc_interpreter.py.mako b/src/codegen/templates/_grpc_interpreter.py.mako index a348eeeee..dd6c0d1f3 100644 --- a/src/codegen/templates/_grpc_interpreter.py.mako +++ b/src/codegen/templates/_grpc_interpreter.py.mako @@ -29,6 +29,7 @@ from collections.abc import Callable, Sequence import google.protobuf.message import grpc +import nitlsconfig import numpy from . import errors as errors @@ -118,14 +119,21 @@ class GrpcStubInterpreter(BaseInterpreter): def __init__(self, grpc_options): self._grpc_options = grpc_options self._client = nidaqmx_grpc.NiDAQmxStub(grpc_options.grpc_channel) + # Querying the driver version is the first contact with the server, so it decides + # whether this interpreter connected. + connected = False try: major_version = self.get_system_info_attribute_uint32(0x1272) minor_version = self.get_system_info_attribute_uint32(0x1923) update_version = self.get_system_info_attribute_uint32(0x2f22) + connected = True except Exception: major_version = 0 minor_version = 0 update_version = 0 + finally: + # Record the outcome of a driver's gRPC session initialize RPC. + nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version) def _invoke(self, func, request, metadata=None): @@ -158,7 +166,11 @@ class GrpcStubInterpreter(BaseInterpreter): error_message += f'\nSamples per channel written: {entry.value}' grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: - error_message = 'Failed to connect to server' + # gRPC reports a rejected TLS handshake and an unreachable server with the + # same code, so ask nitlsconfig whether it built this channel and can say more. + error_message = nitlsconfig.get_tls_connection_error_elaboration( + self._grpc_options.grpc_channel + ) or 'Failed to connect to server' elif grpc_error == grpc.StatusCode.UNIMPLEMENTED: error_message = ( 'This operation is not supported by the NI gRPC Device Server being used. Upgrade NI gRPC Device Server.' diff --git a/tests/unit/test_nitlsconfig_grpc_integration.py b/tests/unit/test_nitlsconfig_grpc_integration.py new file mode 100644 index 000000000..2d9c39c40 --- /dev/null +++ b/tests/unit/test_nitlsconfig_grpc_integration.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from pytest_mock import MockerFixture + +import nidaqmx +from nidaqmx import Task +from tests.unit._grpc_utils import create_grpc_options +from tests.unit._task_utils import expect_create_task, expect_get_task_name + +# _FakeRpcError subclasses grpc.RpcError, so it must be defined here or importing this +# module raises NameError when the grpc extra isn't installed. +try: + import grpc + import nitlsconfig + + from nidaqmx._grpc_interpreter import GrpcStubInterpreter + + class _FakeRpcError(grpc.RpcError): + def __init__(self, code): + self._code = code + + def code(self): + return self._code + + def details(self): + return "original details" + + def trailing_metadata(self): + return [] + +except ImportError: + grpc = None # type: ignore + + +@pytest.fixture +def nitlsconfig_tagged_channel(): + """A gRPC channel tagged the way nitlsconfig.create_grpc_device_channel tags one. + + create_grpc_device_channel needs the nitlsconfig CLI installed on the system, so tagging a + plain channel is the closest we can get to one here. Nothing connects over it. + """ + if grpc is None: + pytest.skip("The grpc module is not available.") + target = "localhost:31763" + with grpc.insecure_channel(target) as channel: + nitlsconfig.channel_tag.tag_channel_target(channel, target) + yield channel + + +def _create_interpreter(mocker: MockerFixture, grpc_options, version_error=None): + mocker.patch("nidaqmx._grpc_interpreter.nidaqmx_grpc.NiDAQmxStub", autospec=True) + mocker.patch.object( + GrpcStubInterpreter, + "get_system_info_attribute_uint32", + side_effect=version_error, + return_value=None if version_error else 1, + ) + return GrpcStubInterpreter(grpc_options) + + +def test___untagged_channel___handle_unavailable___raises_failed_to_connect( + mocker: MockerFixture, +): + interpreter = _create_interpreter(mocker, create_grpc_options(mocker)) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.UNAVAILABLE)) + + assert exc_info.value.rpc_code == grpc.StatusCode.UNAVAILABLE + assert exc_info.value.description == "Failed to connect to server" + + +def test___nitlsconfig_tagged_channel___handle_unavailable___raises_tls_elaboration( + mocker: MockerFixture, nitlsconfig_tagged_channel +): + # Derived from nitlsconfig itself, so this fails if it stops recognizing our channel. + expected_message = nitlsconfig.get_tls_connection_error_elaboration(nitlsconfig_tagged_channel) + assert expected_message is not None + assert expected_message != "Failed to connect to server" + grpc_options = nidaqmx.GrpcSessionOptions(nitlsconfig_tagged_channel, "") + interpreter = _create_interpreter(mocker, grpc_options) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.UNAVAILABLE)) + + assert exc_info.value.rpc_code == grpc.StatusCode.UNAVAILABLE + assert exc_info.value.description == expected_message + + +def test___nitlsconfig_tagged_channel___handle_other_status_code___preserves_original_details( + mocker: MockerFixture, nitlsconfig_tagged_channel +): + # Tagged, so an elaboration is available: this fails if we stop limiting it to UNAVAILABLE. + grpc_options = nidaqmx.GrpcSessionOptions(nitlsconfig_tagged_channel, "") + interpreter = _create_interpreter(mocker, grpc_options) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.INTERNAL)) + + assert exc_info.value.rpc_code == grpc.StatusCode.INTERNAL + assert exc_info.value.description == "original details" + + +def test___server_reachable___create_interpreter___audits_connected(mocker: MockerFixture): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + grpc_options = create_grpc_options(mocker) + + _create_interpreter(mocker, grpc_options) + + patched_audit.assert_called_once_with("NI-DAQmx", grpc_options.grpc_channel, True) + + +def test___server_unreachable___create_interpreter___audits_not_connected(mocker: MockerFixture): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + grpc_options = create_grpc_options(mocker) + + _create_interpreter(mocker, grpc_options, version_error=Exception("unreachable")) + + patched_audit.assert_called_once_with("NI-DAQmx", grpc_options.grpc_channel, False) + + +def test___no_grpc_options___create_task___does_not_audit(interpreter: Mock, mocker: MockerFixture): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + expect_create_task(interpreter) + expect_get_task_name(interpreter, "MyTask") + + with Task("MyTask"): + pass + + patched_audit.assert_not_called()