diff --git a/files/build_templates/docker_image_ctl.j2 b/files/build_templates/docker_image_ctl.j2 index 499419388a1..e5531024ca7 100644 --- a/files/build_templates/docker_image_ctl.j2 +++ b/files/build_templates/docker_image_ctl.j2 @@ -738,6 +738,10 @@ start() { {%- endif %} docker create {{docker_image_run_opt}} \ -v /host/warmboot$DEV:/var/warmboot \ +{#- gNMI/gNOI UDS for in-box clients. gnmi container owns it :rw. #} +{%- if docker_container_name in ("swss", "syncd", "pmon") %} + -v /var/run/gnmi:/var/run/gnmi:ro \ +{%- endif %} {%- if docker_container_name != "dhcp_server" %} --net=$NET \ {%- endif %} diff --git a/src/sonic-py-common/setup.py b/src/sonic-py-common/setup.py index d4ae6853641..c4b0aaef8de 100644 --- a/src/sonic-py-common/setup.py +++ b/src/sonic-py-common/setup.py @@ -26,6 +26,34 @@ print(package + " version not match!", file=sys.stderr) exit(1) +# The sonic_py_common.grpc.gnoi package depends on grpcio/protobuf, which +# no longer publish Python 2 wheels, and uses Python-3-only syntax +# (f-strings, type hints). Gate both the dependencies and the package +# entry on Python 3 so the legacy ENABLE_PY2_MODULES=y wheel build +# continues to succeed. +PY3 = sys.version_info[0] >= 3 + +extra_packages = [] +extra_install_requires = [] +if PY3: + extra_packages = [ + 'sonic_py_common.grpc', + 'sonic_py_common.grpc.gnoi', + ] + # protobuf 4.21 introduced the runtime-version check embedded in + # generated *_pb2.py stubs (see Protobuf Python Version header). + # Stubs in sonic_py_common/grpc/gnoi were generated with protoc-gen + # >= 4.25 and refuse to import under protobuf < 4.21, so pin the + # lower bound explicitly rather than silently importing on bullseye + # images that still ship the locally-built protobuf 3.21.12 + # (rules/protobuf.mk). Modern SONiC base images (bookworm, trixie) + # already satisfy this; older ones get a clear ImportError instead + # of a low-level descriptor mismatch. + extra_install_requires = [ + 'grpcio', + 'protobuf>=4.21', + ] + setup( name='sonic-py-common', version='1.0', @@ -36,10 +64,10 @@ url='https://github.com/Azure/SONiC', maintainer='Joe LeVeque', maintainer_email='jolevequ@microsoft.com', - install_requires=dependencies, + install_requires=dependencies + extra_install_requires, packages=[ 'sonic_py_common', - ], + ] + extra_packages, setup_requires= [ 'pytest-runner', 'wheel' @@ -48,6 +76,15 @@ 'pytest', 'mock==3.0.5' # For python 2. Version >=4.0.0 drops support for py2 ], + extras_require={ + # The bookworm/trixie wheel build pipeline does + # `pip install ".[testing]"` before running pytest + # (slave.mk). Provide the extra explicitly so the build + # doesn't fail with "has no extra 'testing'". + 'testing': [ + 'pytest', + ], + }, entry_points={ 'console_scripts': [ 'sonic-db-load = sonic_py_common.sonic_db_dump_load:sonic_db_dump_load', diff --git a/src/sonic-py-common/sonic_py_common/grpc/__init__.py b/src/sonic-py-common/sonic_py_common/grpc/__init__.py new file mode 100644 index 00000000000..9b39e0b0318 --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/__init__.py @@ -0,0 +1,10 @@ +""" +sonic_py_common.grpc - gRPC framework for SONiC. + +Sub-packages: + - gnoi: gNOI service stubs and client (System, Healthz, Cert, etc.) + +Future: + - gnmi: gNMI service stubs and client + - gribi: gRIBI service stubs and client +""" diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/__init__.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/__init__.py new file mode 100644 index 00000000000..f1c599ef44b --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/__init__.py @@ -0,0 +1,19 @@ +""" +sonic_py_common.grpc.gnoi - gNOI Python framework for SONiC. + +Provides: + - Vendored gNOI proto stubs (System, types, common) + - GnoiClient: gRPC channel manager with service stub access + +Usage: + from sonic_py_common.grpc.gnoi import GnoiClient + from sonic_py_common.grpc.gnoi import system_pb2 + + with GnoiClient("10.0.0.1:8080") as client: + request = system_pb2.RebootRequest(method=system_pb2.HALT) + client.system.Reboot(request, timeout=60) +""" + +from sonic_py_common.grpc.gnoi.client import GnoiClient + +__all__ = ['GnoiClient'] diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/client.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/client.py new file mode 100644 index 00000000000..e2723d1e200 --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/client.py @@ -0,0 +1,144 @@ +""" +gNOI gRPC client for SONiC. + +Manages a gRPC channel and provides access to gNOI service stubs. +All RPCs are accessed through service properties: + + with GnoiClient("10.0.0.1:8080") as client: + client.system.Reboot(request, timeout=60) + client.system.RebootStatus(request, timeout=10) + +For Unix-domain-socket targets (e.g. the local gNMI/gNOI server's +``/var/run/gnmi/gnmi.sock``), use the ``unix://`` scheme; no TLS is +needed for loopback IPC: + + with GnoiClient("unix:///var/run/gnmi/gnmi.sock") as client: + client.system.Time(system_pb2.TimeRequest(), timeout=5) + +For mTLS targets (e.g. the SONiC telemetry/gNOI server), pass a +grpc.ChannelCredentials built from the relevant cert files: + + import grpc + creds = grpc.ssl_channel_credentials( + root_certificates=open("/etc/sonic/telemetry/streamingtelemetryserver.cer", "rb").read(), + private_key=open("/etc/sonic/telemetry/dsmsroot.key", "rb").read(), + certificate_chain=open("/etc/sonic/telemetry/dsmsroot.cer", "rb").read(), + ) + options = (("grpc.ssl_target_name_override", "ndastreamingservertest"),) + with GnoiClient("127.0.0.1:50052", credentials=creds, options=options) as client: + client.system.Time(system_pb2.TimeRequest(), timeout=5) + +The client is service-agnostic — new gNOI services (Healthz, Cert, +File, OS, etc.) can be added as properties without modifying existing +code. +""" + +import grpc +from sonic_py_common.grpc.gnoi import system_pb2_grpc + + +class GnoiClient: + """gNOI gRPC client for SONiC components. + + Manages a single gRPC channel and exposes gNOI service stubs as + properties. Use as a context manager for automatic cleanup. + + Args: + target: gRPC target address, e.g. "10.0.0.1:8080". + options: Optional list of gRPC channel options, e.g. + ``(("grpc.ssl_target_name_override", "server-cn"),)``. + credentials: Optional ``grpc.ChannelCredentials``. When ``None`` + (default), an insecure channel is opened — suitable for + localhost loopback or test fakes. When supplied, a secure + channel is opened; build credentials with + ``grpc.ssl_channel_credentials(...)``. + + Example (insecure, for FakeGnoiServer or a localhost helper): + with GnoiClient("localhost:50051") as client: + client.system.Reboot(req, timeout=60) + + Example (Unix domain socket, no TLS): + with GnoiClient("unix:///var/run/gnmi/gnmi.sock") as client: + client.system.Time(system_pb2.TimeRequest(), timeout=5) + + Example (mTLS): + creds = grpc.ssl_channel_credentials( + root_certificates=server_ca_pem, + private_key=client_key_pem, + certificate_chain=client_cert_pem, + ) + with GnoiClient("dut:50052", credentials=creds) as client: + client.system.Time(system_pb2.TimeRequest(), timeout=5) + """ + + def __init__(self, target, options=None, credentials=None): + self._target = target + self._options = options + self._credentials = credentials + self._channel = None + + def __enter__(self): + if self._credentials is None: + self._channel = grpc.insecure_channel( + self._target, options=self._options + ) + else: + self._channel = grpc.secure_channel( + self._target, self._credentials, options=self._options + ) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + + def close(self): + """Close the gRPC channel.""" + if self._channel is not None: + self._channel.close() + self._channel = None + + @property + def channel(self): + """The underlying gRPC channel for constructing custom stubs.""" + return self._channel + + def _require_channel(self): + if self._channel is None: + raise RuntimeError( + "GnoiClient channel is not open. Use it as a context manager " + "(`with GnoiClient(target) as client:`) or call __enter__() " + "before accessing service stubs." + ) + return self._channel + + # ---- gNOI service stubs ---- + + @property + def system(self): + """gNOI System service stub (gnoi.system.System). + + Provides: Reboot, RebootStatus, CancelReboot, Time, Ping, + Traceroute, SwitchControlProcessor, etc. + """ + return system_pb2_grpc.SystemStub(self._require_channel()) + + # Future services can be added here: + # + # @property + # def healthz(self): + # """gNOI Healthz service stub.""" + # from sonic_py_common.grpc.gnoi import healthz_pb2_grpc + # return healthz_pb2_grpc.HealthzStub(self._channel) + # + # @property + # def cert(self): + # """gNOI CertificateManagement service stub.""" + # from sonic_py_common.grpc.gnoi import cert_pb2_grpc + # return cert_pb2_grpc.CertificateManagementStub(self._channel) + # + # @property + # def file(self): + # """gNOI File service stub.""" + # from sonic_py_common.grpc.gnoi import file_pb2_grpc + # return file_pb2_grpc.FileStub(self._channel) diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2.py new file mode 100644 index 00000000000..f03813c2dba --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: github.com/openconfig/gnoi/common/common.proto +# Protobuf Python Version: 4.25.0 +"""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() + + +from sonic_py_common.grpc.gnoi import types_pb2 as github_dot_com_dot_openconfig_dot_gnoi_dot_types_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n.github.com/openconfig/gnoi/common/common.proto\x12\x0bgnoi.common\x1a,github.com/openconfig/gnoi/types/types.proto\"\xf1\x01\n\x0eRemoteDownload\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x36\n\x08protocol\x18\x02 \x01(\x0e\x32$.gnoi.common.RemoteDownload.Protocol\x12,\n\x0b\x63redentials\x18\x03 \x01(\x0b\x32\x17.gnoi.types.Credentials\x12\x16\n\x0esource_address\x18\x04 \x01(\t\x12\x12\n\nsource_vrf\x18\x05 \x01(\t\"?\n\x08Protocol\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x08\n\x04SFTP\x10\x01\x12\x08\n\x04HTTP\x10\x02\x12\t\n\x05HTTPS\x10\x03\x12\x07\n\x03SCP\x10\x04\x42#Z!github.com/openconfig/gnoi/commonb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.openconfig.gnoi.common.common_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z!github.com/openconfig/gnoi/common' + _globals['_REMOTEDOWNLOAD']._serialized_start=110 + _globals['_REMOTEDOWNLOAD']._serialized_end=351 + _globals['_REMOTEDOWNLOAD_PROTOCOL']._serialized_start=288 + _globals['_REMOTEDOWNLOAD_PROTOCOL']._serialized_end=351 +# @@protoc_insertion_point(module_scope) diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2_grpc.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2_grpc.py new file mode 100644 index 00000000000..2daafffebfc --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/common_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2.py new file mode 100644 index 00000000000..6dcad656b2c --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: github.com/openconfig/gnoi/system/system.proto +# Protobuf Python Version: 4.25.0 +"""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() + + +from sonic_py_common.grpc.gnoi import common_pb2 as github_dot_com_dot_openconfig_dot_gnoi_dot_common_dot_common__pb2 +from sonic_py_common.grpc.gnoi import types_pb2 as github_dot_com_dot_openconfig_dot_gnoi_dot_types_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n.github.com/openconfig/gnoi/system/system.proto\x12\x0bgnoi.system\x1a.github.com/openconfig/gnoi/common/common.proto\x1a,github.com/openconfig/gnoi/types/types.proto\"L\n\x1dSwitchControlProcessorRequest\x12+\n\x11\x63ontrol_processor\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\"n\n\x1eSwitchControlProcessorResponse\x12+\n\x11\x63ontrol_processor\x18\x01 \x01(\x0b\x32\x10.gnoi.types.Path\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0e\n\x06uptime\x18\x03 \x01(\x03\"\x92\x01\n\rRebootRequest\x12)\n\x06method\x18\x01 \x01(\x0e\x32\x19.gnoi.system.RebootMethod\x12\r\n\x05\x64\x65lay\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\'\n\rsubcomponents\x18\x04 \x03(\x0b\x32\x10.gnoi.types.Path\x12\r\n\x05\x66orce\x18\x05 \x01(\x08\"\x10\n\x0eRebootResponse\"O\n\x13\x43\x61ncelRebootRequest\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\'\n\rsubcomponents\x18\x02 \x03(\x0b\x32\x10.gnoi.types.Path\"\x16\n\x14\x43\x61ncelRebootResponse\">\n\x13RebootStatusRequest\x12\'\n\rsubcomponents\x18\x01 \x03(\x0b\x32\x10.gnoi.types.Path\"\xb7\x01\n\x14RebootStatusResponse\x12\x0e\n\x06\x61\x63tive\x18\x01 \x01(\x08\x12\x0c\n\x04wait\x18\x02 \x01(\x04\x12\x0c\n\x04when\x18\x03 \x01(\x04\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\r\n\x05\x63ount\x18\x05 \x01(\r\x12)\n\x06method\x18\x06 \x01(\x0e\x32\x19.gnoi.system.RebootMethod\x12)\n\x06status\x18\x07 \x01(\x0b\x32\x19.gnoi.system.RebootStatus\"\xb5\x01\n\x0cRebootStatus\x12\x30\n\x06status\x18\x01 \x01(\x0e\x32 .gnoi.system.RebootStatus.Status\x12\x0f\n\x07message\x18\x02 \x01(\t\"b\n\x06Status\x12\x12\n\x0eSTATUS_UNKNOWN\x10\x00\x12\x12\n\x0eSTATUS_SUCCESS\x10\x01\x12\x1c\n\x18STATUS_RETRIABLE_FAILURE\x10\x02\x12\x12\n\x0eSTATUS_FAILURE\x10\x03\"\r\n\x0bTimeRequest\"\x1c\n\x0cTimeResponse\x12\x0c\n\x04time\x18\x01 \x01(\x04\"\xe6\x01\n\x0bPingRequest\x12\x13\n\x0b\x64\x65stination\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\x05\x12\x10\n\x08interval\x18\x04 \x01(\x03\x12\x0c\n\x04wait\x18\x05 \x01(\x03\x12\x0c\n\x04size\x18\x06 \x01(\x05\x12\x17\n\x0f\x64o_not_fragment\x18\x07 \x01(\x08\x12\x16\n\x0e\x64o_not_resolve\x18\x08 \x01(\x08\x12*\n\nl3protocol\x18\t \x01(\x0e\x32\x16.gnoi.types.L3Protocol\x12\x18\n\x10network_instance\x18\n \x01(\t\"\xc1\x01\n\x0cPingResponse\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x03\x12\x0c\n\x04sent\x18\x03 \x01(\x05\x12\x10\n\x08received\x18\x04 \x01(\x05\x12\x10\n\x08min_time\x18\x05 \x01(\x03\x12\x10\n\x08\x61vg_time\x18\x06 \x01(\x03\x12\x10\n\x08max_time\x18\x07 \x01(\x03\x12\x0f\n\x07std_dev\x18\x08 \x01(\x03\x12\r\n\x05\x62ytes\x18\x0b \x01(\x05\x12\x10\n\x08sequence\x18\x0c \x01(\x05\x12\x0b\n\x03ttl\x18\r \x01(\x05\"\xe7\x02\n\x11TracerouteRequest\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x13\n\x0binitial_ttl\x18\x03 \x01(\r\x12\x0f\n\x07max_ttl\x18\x04 \x01(\x05\x12\x0c\n\x04wait\x18\x05 \x01(\x03\x12\x17\n\x0f\x64o_not_fragment\x18\x06 \x01(\x08\x12\x16\n\x0e\x64o_not_resolve\x18\x07 \x01(\x08\x12*\n\nl3protocol\x18\x08 \x01(\x0e\x32\x16.gnoi.types.L3Protocol\x12=\n\nl4protocol\x18\t \x01(\x0e\x32).gnoi.system.TracerouteRequest.L4Protocol\x12\x19\n\x11\x64o_not_lookup_asn\x18\n \x01(\x08\x12\x18\n\x10network_instance\x18\x0b \x01(\t\"(\n\nL4Protocol\x12\x08\n\x04ICMP\x10\x00\x12\x07\n\x03TCP\x10\x01\x12\x07\n\x03UDP\x10\x02\"\xda\x05\n\x12TracerouteResponse\x12\x18\n\x10\x64\x65stination_name\x18\x01 \x01(\t\x12\x1b\n\x13\x64\x65stination_address\x18\x02 \x01(\t\x12\x0c\n\x04hops\x18\x03 \x01(\x05\x12\x13\n\x0bpacket_size\x18\x04 \x01(\x05\x12\x0b\n\x03hop\x18\x05 \x01(\x05\x12\x0f\n\x07\x61\x64\x64ress\x18\x06 \x01(\t\x12\x0c\n\x04name\x18\x07 \x01(\t\x12\x0b\n\x03rtt\x18\x08 \x01(\x03\x12\x34\n\x05state\x18\t \x01(\x0e\x32%.gnoi.system.TracerouteResponse.State\x12\x11\n\ticmp_code\x18\n \x01(\x05\x12\x37\n\x04mpls\x18\x0b \x03(\x0b\x32).gnoi.system.TracerouteResponse.MplsEntry\x12\x0f\n\x07\x61s_path\x18\x0c \x03(\x05\x12\x42\n\ricmp_ext_data\x18\r \x03(\x0b\x32+.gnoi.system.TracerouteResponse.IcmpExtData\x1a\x38\n\x0bIcmpExtData\x12\r\n\x05\x63lass\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x03 \x03(\r\x1a+\n\tMplsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xf2\x01\n\x05State\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x08\n\x04NONE\x10\x01\x12\x0b\n\x07UNKNOWN\x10\x02\x12\x08\n\x04ICMP\x10\x03\x12\x14\n\x10HOST_UNREACHABLE\x10\x04\x12\x17\n\x13NETWORK_UNREACHABLE\x10\x05\x12\x18\n\x14PROTOCOL_UNREACHABLE\x10\x06\x12\x17\n\x13SOURCE_ROUTE_FAILED\x10\x07\x12\x18\n\x14\x46RAGMENTATION_NEEDED\x10\x08\x12\x0e\n\nPROHIBITED\x10\t\x12\x18\n\x14PRECEDENCE_VIOLATION\x10\n\x12\x15\n\x11PRECEDENCE_CUTOFF\x10\x0b\"t\n\x07Package\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x10\n\x08\x61\x63tivate\x18\x05 \x01(\x08\x12\x34\n\x0fremote_download\x18\x06 \x01(\x0b\x32\x1b.gnoi.common.RemoteDownload\"\x81\x01\n\x11SetPackageRequest\x12\'\n\x07package\x18\x01 \x01(\x0b\x32\x14.gnoi.system.PackageH\x00\x12\x12\n\x08\x63ontents\x18\x02 \x01(\x0cH\x00\x12$\n\x04hash\x18\x03 \x01(\x0b\x32\x14.gnoi.types.HashTypeH\x00\x42\t\n\x07request\"\x14\n\x12SetPackageResponse\"\xdd\x01\n\x12KillProcessRequest\x12\x0b\n\x03pid\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x36\n\x06signal\x18\x03 \x01(\x0e\x32&.gnoi.system.KillProcessRequest.Signal\x12\x0f\n\x07restart\x18\x04 \x01(\x08\"c\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x0f\n\x0bSIGNAL_TERM\x10\x01\x12\x0f\n\x0bSIGNAL_KILL\x10\x02\x12\x0e\n\nSIGNAL_HUP\x10\x03\x12\x0f\n\x0bSIGNAL_ABRT\x10\x04\"\x15\n\x13KillProcessResponse*d\n\x0cRebootMethod\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x08\n\x04\x43OLD\x10\x01\x12\r\n\tPOWERDOWN\x10\x02\x12\x08\n\x04HALT\x10\x03\x12\x08\n\x04WARM\x10\x04\x12\x07\n\x03NSF\x10\x05\x12\x0b\n\x07POWERUP\x10\x07\"\x04\x08\x06\x10\x06\x32\xea\x05\n\x06System\x12?\n\x04Ping\x12\x18.gnoi.system.PingRequest\x1a\x19.gnoi.system.PingResponse\"\x00\x30\x01\x12Q\n\nTraceroute\x12\x1e.gnoi.system.TracerouteRequest\x1a\x1f.gnoi.system.TracerouteResponse\"\x00\x30\x01\x12=\n\x04Time\x12\x18.gnoi.system.TimeRequest\x1a\x19.gnoi.system.TimeResponse\"\x00\x12Q\n\nSetPackage\x12\x1e.gnoi.system.SetPackageRequest\x1a\x1f.gnoi.system.SetPackageResponse\"\x00(\x01\x12s\n\x16SwitchControlProcessor\x12*.gnoi.system.SwitchControlProcessorRequest\x1a+.gnoi.system.SwitchControlProcessorResponse\"\x00\x12\x43\n\x06Reboot\x12\x1a.gnoi.system.RebootRequest\x1a\x1b.gnoi.system.RebootResponse\"\x00\x12U\n\x0cRebootStatus\x12 .gnoi.system.RebootStatusRequest\x1a!.gnoi.system.RebootStatusResponse\"\x00\x12U\n\x0c\x43\x61ncelReboot\x12 .gnoi.system.CancelRebootRequest\x1a!.gnoi.system.CancelRebootResponse\"\x00\x12R\n\x0bKillProcess\x12\x1f.gnoi.system.KillProcessRequest\x1a .gnoi.system.KillProcessResponse\"\x00\x42+Z!github.com/openconfig/gnoi/system\xd2>\x05\x31.4.0b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.openconfig.gnoi.system.system_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z!github.com/openconfig/gnoi/system\322>\0051.4.0' + _globals['_TRACEROUTERESPONSE_MPLSENTRY']._options = None + _globals['_TRACEROUTERESPONSE_MPLSENTRY']._serialized_options = b'8\001' + _globals['_REBOOTMETHOD']._serialized_start=3141 + _globals['_REBOOTMETHOD']._serialized_end=3241 + _globals['_SWITCHCONTROLPROCESSORREQUEST']._serialized_start=157 + _globals['_SWITCHCONTROLPROCESSORREQUEST']._serialized_end=233 + _globals['_SWITCHCONTROLPROCESSORRESPONSE']._serialized_start=235 + _globals['_SWITCHCONTROLPROCESSORRESPONSE']._serialized_end=345 + _globals['_REBOOTREQUEST']._serialized_start=348 + _globals['_REBOOTREQUEST']._serialized_end=494 + _globals['_REBOOTRESPONSE']._serialized_start=496 + _globals['_REBOOTRESPONSE']._serialized_end=512 + _globals['_CANCELREBOOTREQUEST']._serialized_start=514 + _globals['_CANCELREBOOTREQUEST']._serialized_end=593 + _globals['_CANCELREBOOTRESPONSE']._serialized_start=595 + _globals['_CANCELREBOOTRESPONSE']._serialized_end=617 + _globals['_REBOOTSTATUSREQUEST']._serialized_start=619 + _globals['_REBOOTSTATUSREQUEST']._serialized_end=681 + _globals['_REBOOTSTATUSRESPONSE']._serialized_start=684 + _globals['_REBOOTSTATUSRESPONSE']._serialized_end=867 + _globals['_REBOOTSTATUS']._serialized_start=870 + _globals['_REBOOTSTATUS']._serialized_end=1051 + _globals['_REBOOTSTATUS_STATUS']._serialized_start=953 + _globals['_REBOOTSTATUS_STATUS']._serialized_end=1051 + _globals['_TIMEREQUEST']._serialized_start=1053 + _globals['_TIMEREQUEST']._serialized_end=1066 + _globals['_TIMERESPONSE']._serialized_start=1068 + _globals['_TIMERESPONSE']._serialized_end=1096 + _globals['_PINGREQUEST']._serialized_start=1099 + _globals['_PINGREQUEST']._serialized_end=1329 + _globals['_PINGRESPONSE']._serialized_start=1332 + _globals['_PINGRESPONSE']._serialized_end=1525 + _globals['_TRACEROUTEREQUEST']._serialized_start=1528 + _globals['_TRACEROUTEREQUEST']._serialized_end=1887 + _globals['_TRACEROUTEREQUEST_L4PROTOCOL']._serialized_start=1847 + _globals['_TRACEROUTEREQUEST_L4PROTOCOL']._serialized_end=1887 + _globals['_TRACEROUTERESPONSE']._serialized_start=1890 + _globals['_TRACEROUTERESPONSE']._serialized_end=2620 + _globals['_TRACEROUTERESPONSE_ICMPEXTDATA']._serialized_start=2274 + _globals['_TRACEROUTERESPONSE_ICMPEXTDATA']._serialized_end=2330 + _globals['_TRACEROUTERESPONSE_MPLSENTRY']._serialized_start=2332 + _globals['_TRACEROUTERESPONSE_MPLSENTRY']._serialized_end=2375 + _globals['_TRACEROUTERESPONSE_STATE']._serialized_start=2378 + _globals['_TRACEROUTERESPONSE_STATE']._serialized_end=2620 + _globals['_PACKAGE']._serialized_start=2622 + _globals['_PACKAGE']._serialized_end=2738 + _globals['_SETPACKAGEREQUEST']._serialized_start=2741 + _globals['_SETPACKAGEREQUEST']._serialized_end=2870 + _globals['_SETPACKAGERESPONSE']._serialized_start=2872 + _globals['_SETPACKAGERESPONSE']._serialized_end=2892 + _globals['_KILLPROCESSREQUEST']._serialized_start=2895 + _globals['_KILLPROCESSREQUEST']._serialized_end=3116 + _globals['_KILLPROCESSREQUEST_SIGNAL']._serialized_start=3017 + _globals['_KILLPROCESSREQUEST_SIGNAL']._serialized_end=3116 + _globals['_KILLPROCESSRESPONSE']._serialized_start=3118 + _globals['_KILLPROCESSRESPONSE']._serialized_end=3139 + _globals['_SYSTEM']._serialized_start=3244 + _globals['_SYSTEM']._serialized_end=3990 +# @@protoc_insertion_point(module_scope) diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2_grpc.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2_grpc.py new file mode 100644 index 00000000000..84514f9956f --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/system_pb2_grpc.py @@ -0,0 +1,369 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from sonic_py_common.grpc.gnoi import system_pb2 as github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2 + + +class SystemStub(object): + """The gNOI service is a collection of operational RPC's that allow for the + management of a target outside of the configuration and telemetry pipeline. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Ping = channel.unary_stream( + '/gnoi.system.System/Ping', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingResponse.FromString, + ) + self.Traceroute = channel.unary_stream( + '/gnoi.system.System/Traceroute', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteResponse.FromString, + ) + self.Time = channel.unary_unary( + '/gnoi.system.System/Time', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeResponse.FromString, + ) + self.SetPackage = channel.stream_unary( + '/gnoi.system.System/SetPackage', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageResponse.FromString, + ) + self.SwitchControlProcessor = channel.unary_unary( + '/gnoi.system.System/SwitchControlProcessor', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorResponse.FromString, + ) + self.Reboot = channel.unary_unary( + '/gnoi.system.System/Reboot', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootResponse.FromString, + ) + self.RebootStatus = channel.unary_unary( + '/gnoi.system.System/RebootStatus', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusResponse.FromString, + ) + self.CancelReboot = channel.unary_unary( + '/gnoi.system.System/CancelReboot', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootResponse.FromString, + ) + self.KillProcess = channel.unary_unary( + '/gnoi.system.System/KillProcess', + request_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessRequest.SerializeToString, + response_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessResponse.FromString, + ) + + +class SystemServicer(object): + """The gNOI service is a collection of operational RPC's that allow for the + management of a target outside of the configuration and telemetry pipeline. + """ + + def Ping(self, request, context): + """Ping executes the ping command on the target and streams back + the results. Some targets may not stream any results until all + results are in. The stream should provide single ping packet responses + and must provide summary statistics. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Traceroute(self, request, context): + """Traceroute executes the traceroute command on the target and streams back + the results. Some targets may not stream any results until all + results are in. If a hop count is not explicitly provided, + 30 is used. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Time(self, request, context): + """Time returns the current time on the target. Time is typically used to + test if a target is actually responding. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SetPackage(self, request_iterator, context): + """SetPackage places a software package (possibly including bootable images) + on the target. The file is sent in sequential messages, each message + up to 64KB of data. A final message must be sent that includes the hash + of the data sent. An error is returned if the location does not exist or + there is an error writing the data. If no checksum is received, the target + must assume the operation is incomplete and remove the partially + transmitted file. The target should initially write the file to a temporary + location so a failure does not destroy the original file. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SwitchControlProcessor(self, request, context): + """SwitchControlProcessor will switch from the current route processor to the + provided route processor. If the current route processor is the same as the + one provided it is a NOOP. If the target does not exist an error is + returned. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Reboot(self, request, context): + """Reboot causes the target to reboot, possibly at some point in the future. + If the method of reboot is not supported then the Reboot RPC will fail. + If the reboot is immediate the command will block until the subcomponents + have restarted. + If a reboot on the active control processor is pending the service must + reject all other reboot requests. + If a reboot request for active control processor is initiated with other + pending reboot requests it must be rejected. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def RebootStatus(self, request, context): + """RebootStatus returns the status of reboot for the target. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def CancelReboot(self, request, context): + """CancelReboot cancels any pending reboot request. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def KillProcess(self, request, context): + """KillProcess kills an OS process and optionally restarts it. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SystemServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Ping': grpc.unary_stream_rpc_method_handler( + servicer.Ping, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingResponse.SerializeToString, + ), + 'Traceroute': grpc.unary_stream_rpc_method_handler( + servicer.Traceroute, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteResponse.SerializeToString, + ), + 'Time': grpc.unary_unary_rpc_method_handler( + servicer.Time, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeResponse.SerializeToString, + ), + 'SetPackage': grpc.stream_unary_rpc_method_handler( + servicer.SetPackage, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageResponse.SerializeToString, + ), + 'SwitchControlProcessor': grpc.unary_unary_rpc_method_handler( + servicer.SwitchControlProcessor, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorResponse.SerializeToString, + ), + 'Reboot': grpc.unary_unary_rpc_method_handler( + servicer.Reboot, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootResponse.SerializeToString, + ), + 'RebootStatus': grpc.unary_unary_rpc_method_handler( + servicer.RebootStatus, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusResponse.SerializeToString, + ), + 'CancelReboot': grpc.unary_unary_rpc_method_handler( + servicer.CancelReboot, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootResponse.SerializeToString, + ), + 'KillProcess': grpc.unary_unary_rpc_method_handler( + servicer.KillProcess, + request_deserializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessRequest.FromString, + response_serializer=github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gnoi.system.System', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class System(object): + """The gNOI service is a collection of operational RPC's that allow for the + management of a target outside of the configuration and telemetry pipeline. + """ + + @staticmethod + def Ping(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_stream(request, target, '/gnoi.system.System/Ping', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.PingResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Traceroute(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_stream(request, target, '/gnoi.system.System/Traceroute', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TracerouteResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Time(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, '/gnoi.system.System/Time', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.TimeResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def SetPackage(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_unary(request_iterator, target, '/gnoi.system.System/SetPackage', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SetPackageResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def SwitchControlProcessor(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, '/gnoi.system.System/SwitchControlProcessor', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.SwitchControlProcessorResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Reboot(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, '/gnoi.system.System/Reboot', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def RebootStatus(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, '/gnoi.system.System/RebootStatus', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.RebootStatusResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def CancelReboot(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, '/gnoi.system.System/CancelReboot', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.CancelRebootResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def KillProcess(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, '/gnoi.system.System/KillProcess', + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessRequest.SerializeToString, + github_dot_com_dot_openconfig_dot_gnoi_dot_system_dot_system__pb2.KillProcessResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/testing.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/testing.py new file mode 100644 index 00000000000..16fd8c88e24 --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/testing.py @@ -0,0 +1,258 @@ +""" +Fake gNOI gRPC server for testing. + +Spins up a real gRPC server on localhost with controllable service +implementations. Tests use the real GnoiClient against this server — +no mocking of gRPC internals. + +Usage: + from sonic_py_common.grpc.gnoi.testing import FakeGnoiServer + from sonic_py_common.grpc.gnoi import GnoiClient, system_pb2 + + server = FakeGnoiServer() + server.start() + + # Configure responses + server.system.set_reboot_response() # default success + server.system.set_reboot_status(active=False, status=system_pb2.RebootStatus.Status.STATUS_SUCCESS) + + # Test with real client + with GnoiClient(server.target) as client: + client.system.Reboot(system_pb2.RebootRequest(method=system_pb2.HALT), timeout=5) + resp = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + assert not resp.active + + server.stop() +""" + +from concurrent import futures +import grpc + +from sonic_py_common.grpc.gnoi import system_pb2 +from sonic_py_common.grpc.gnoi import system_pb2_grpc + + +class FakeSystemServicer(system_pb2_grpc.SystemServicer): + """Fake gNOI System servicer with configurable responses. + + Each RPC returns a configured response or raises a configured error. + Supports response sequences for polling tests. + Call history is recorded for assertions. + """ + + def __init__(self): + self.reset() + + def reset(self): + """Reset all configured responses and call history.""" + self._reboot_response = system_pb2.RebootResponse() + self._reboot_error = None + + self._reboot_status_responses = None # None = use single response + self._reboot_status_response = self._default_status_response() + self._reboot_status_error = None + + self._cancel_reboot_response = system_pb2.CancelRebootResponse() + self._cancel_reboot_error = None + + self.reboot_calls = [] + self.reboot_status_calls = [] + self.cancel_reboot_calls = [] + + @staticmethod + def _default_status_response(): + resp = system_pb2.RebootStatusResponse() + resp.active = False + resp.status.status = system_pb2.RebootStatus.Status.STATUS_SUCCESS + return resp + + # ---- Configuration ---- + + def set_reboot_response(self, response=None, error_code=None, error_message=""): + """Set Reboot RPC response or error. + + Args: + response: RebootResponse proto (default: empty success). + error_code: grpc.StatusCode to return as error (e.g. grpc.StatusCode.UNAVAILABLE). + error_message: Error detail string. + """ + if response is not None: + self._reboot_response = response + self._reboot_error = (error_code, error_message) if error_code else None + + def set_reboot_status(self, active=None, status=None, message=None, + response=None, error_code=None, error_message=""): + """Set RebootStatus single response. + + Args: + active: Whether reboot is in progress. + status: RebootStatus.Status enum value. + message: Status message. + response: Full RebootStatusResponse (overrides field args). + error_code: grpc.StatusCode for error response. + error_message: Error detail string. + """ + self._reboot_status_responses = None + if response is not None: + self._reboot_status_response = response + else: + if active is not None: + self._reboot_status_response.active = active + if status is not None: + self._reboot_status_response.status.status = status + if message is not None: + self._reboot_status_response.status.message = message + self._reboot_status_error = (error_code, error_message) if error_code else None + + def set_reboot_status_sequence(self, responses): + """Set a sequence of RebootStatus responses for polling tests. + + Each call pops the next item. Items can be: + - RebootStatusResponse proto → returned as success + - (grpc.StatusCode, message) tuple → returned as error + + After exhaustion, falls back to the single response. + + Args: + responses: List of RebootStatusResponse or (StatusCode, msg) tuples. + """ + self._reboot_status_responses = list(responses) + + def set_cancel_reboot_response(self, response=None, error_code=None, error_message=""): + """Set CancelReboot RPC response or error.""" + if response is not None: + self._cancel_reboot_response = response + self._cancel_reboot_error = (error_code, error_message) if error_code else None + + # ---- RPC implementations ---- + + def Reboot(self, request, context): + self.reboot_calls.append(request) + if self._reboot_error: + context.abort(self._reboot_error[0], self._reboot_error[1]) + return self._reboot_response + + def RebootStatus(self, request, context): + self.reboot_status_calls.append(request) + if self._reboot_status_responses is not None and self._reboot_status_responses: + item = self._reboot_status_responses.pop(0) + if isinstance(item, tuple): + context.abort(item[0], item[1]) + return item + # No sequence configured, or sequence has been exhausted — fall + # through to the single configured response (or error). + if self._reboot_status_error: + context.abort(self._reboot_status_error[0], self._reboot_status_error[1]) + return self._reboot_status_response + + def CancelReboot(self, request, context): + self.cancel_reboot_calls.append(request) + if self._cancel_reboot_error: + context.abort(self._cancel_reboot_error[0], self._cancel_reboot_error[1]) + return self._cancel_reboot_response + + +class FakeGnoiServer: + """Fake gNOI gRPC server for integration-style tests. + + Starts a real gRPC server on a random localhost port. + Tests use GnoiClient(server.target) for real channel communication. + + Example: + server = FakeGnoiServer() + server.start() + try: + with GnoiClient(server.target) as client: + client.system.Reboot(req, timeout=5) + assert len(server.system.reboot_calls) == 1 + finally: + server.stop() + + Or as context manager: + with FakeGnoiServer() as server: + with GnoiClient(server.target) as client: + ... + """ + + def __init__(self, max_workers=2): + self._max_workers = max_workers + self._server = None + self._port = None + self.system = FakeSystemServicer() + + @property + def target(self): + """gRPC target string, e.g. 'localhost:50051'. + + Raises: + RuntimeError: If accessed before ``start()`` has bound a port. + """ + if self._port is None: + raise RuntimeError( + "FakeGnoiServer.target accessed before start() — call " + "start() (or use as a context manager) first." + ) + return f"localhost:{self._port}" + + def start(self): + """Start the fake gRPC server on a random port. + + Raises: + RuntimeError: If the server is already started (use ``stop()`` + first or just rely on the context manager), or if the + listening port could not be bound. + """ + if self._server is not None: + raise RuntimeError( + "FakeGnoiServer.start() called on an already-started server; " + "call stop() first or use a fresh instance." + ) + self._server = grpc.server(futures.ThreadPoolExecutor(max_workers=self._max_workers)) + system_pb2_grpc.add_SystemServicer_to_server(self.system, self._server) + port = self._server.add_insecure_port("localhost:0") + if port == 0: + # add_insecure_port returns 0 on bind failure. + self._server = None + raise RuntimeError( + "FakeGnoiServer.start(): add_insecure_port('localhost:0') failed" + ) + self._port = port + self._server.start() + return self + + def stop(self, grace=0): + """Stop the server and wait for graceful termination. + + gRPC's ``server.stop(grace)`` returns a ``threading.Event`` that is + set once all RPCs have completed. Wait on it so callers (e.g. unit + tests) don't leak server threads / sockets across iterations. + + Raises: + RuntimeError: If the server fails to terminate within the + bounded wait (5s), indicating leaked threads / sockets. + """ + if self._server: + stopped = self._server.stop(grace) + # Bounded wait: grace=0 means "abort in-flight RPCs immediately", + # so the Event fires almost instantly. Cap the wait so a broken + # gRPC build can't hang a test run indefinitely. + if not stopped.wait(timeout=5): + # Don't silently clear state — the server is leaking. Surface + # it so the caller (typically a test) can fail loudly. + raise RuntimeError( + "FakeGnoiServer.stop(): gRPC server did not terminate " + "within 5s; the server is leaking threads / sockets." + ) + self._server = None + self._port = None + + def reset(self): + """Reset all service state (responses + call history).""" + self.system.reset() + + def __enter__(self): + return self.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + return False diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2.py new file mode 100644 index 00000000000..5f22d2aca45 --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: github.com/openconfig/gnoi/types/types.proto +# Protobuf Python Version: 4.25.0 +"""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() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n,github.com/openconfig/gnoi/types/types.proto\x12\ngnoi.types\x1a google/protobuf/descriptor.proto\"\x89\x01\n\x08HashType\x12/\n\x06method\x18\x01 \x01(\x0e\x32\x1f.gnoi.types.HashType.HashMethod\x12\x0c\n\x04hash\x18\x02 \x01(\x0c\">\n\nHashMethod\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\n\n\x06SHA256\x10\x01\x12\n\n\x06SHA512\x10\x02\x12\x07\n\x03MD5\x10\x03\":\n\x04Path\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\"\n\x04\x65lem\x18\x03 \x03(\x0b\x32\x14.gnoi.types.PathElem\"p\n\x08PathElem\x12\x0c\n\x04name\x18\x01 \x01(\t\x12*\n\x03key\x18\x02 \x03(\x0b\x32\x1d.gnoi.types.PathElem.KeyEntry\x1a*\n\x08KeyEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"h\n\x0b\x43redentials\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x13\n\tcleartext\x18\x02 \x01(\tH\x00\x12&\n\x06hashed\x18\x03 \x01(\x0b\x32\x14.gnoi.types.HashTypeH\x00\x42\n\n\x08password*1\n\nL3Protocol\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x08\n\x04IPV4\x10\x01\x12\x08\n\x04IPV6\x10\x02:3\n\x0cgnoi_version\x12\x1c.google.protobuf.FileOptions\x18\xea\x07 \x01(\tB\"Z github.com/openconfig/gnoi/typesb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.openconfig.gnoi.types.types_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z github.com/openconfig/gnoi/types' + _globals['_PATHELEM_KEYENTRY']._options = None + _globals['_PATHELEM_KEYENTRY']._serialized_options = b'8\001' + _globals['_L3PROTOCOL']._serialized_start=514 + _globals['_L3PROTOCOL']._serialized_end=563 + _globals['_HASHTYPE']._serialized_start=95 + _globals['_HASHTYPE']._serialized_end=232 + _globals['_HASHTYPE_HASHMETHOD']._serialized_start=170 + _globals['_HASHTYPE_HASHMETHOD']._serialized_end=232 + _globals['_PATH']._serialized_start=234 + _globals['_PATH']._serialized_end=292 + _globals['_PATHELEM']._serialized_start=294 + _globals['_PATHELEM']._serialized_end=406 + _globals['_PATHELEM_KEYENTRY']._serialized_start=364 + _globals['_PATHELEM_KEYENTRY']._serialized_end=406 + _globals['_CREDENTIALS']._serialized_start=408 + _globals['_CREDENTIALS']._serialized_end=512 +# @@protoc_insertion_point(module_scope) diff --git a/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2_grpc.py b/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2_grpc.py new file mode 100644 index 00000000000..2daafffebfc --- /dev/null +++ b/src/sonic-py-common/sonic_py_common/grpc/gnoi/types_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/src/sonic-py-common/tests/test_gnoi_client.py b/src/sonic-py-common/tests/test_gnoi_client.py new file mode 100644 index 00000000000..8195856290c --- /dev/null +++ b/src/sonic-py-common/tests/test_gnoi_client.py @@ -0,0 +1,175 @@ +"""Tests for sonic_py_common.grpc.gnoi.client.GnoiClient. + +Uses FakeGnoiServer for real gRPC — no mocking. +""" + +import sys +import unittest + +if sys.version_info[0] < 3: + # sonic_py_common.grpc.gnoi is only packaged for Py3 (see setup.py), + # and grpcio dropped Py2 support years ago. Skip the whole module on + # Py2 so the legacy ENABLE_PY2_MODULES=y wheel build doesn't fail on + # an unsatisfiable import. ``raise SkipTest`` at module level is + # recognized by both ``unittest`` and ``pytest`` collection. + raise unittest.SkipTest("sonic_py_common.grpc.gnoi requires Python 3") + +from unittest import mock # noqa: E402 +import grpc # noqa: E402 + +from sonic_py_common.grpc.gnoi.testing import FakeGnoiServer # noqa: E402 +from sonic_py_common.grpc.gnoi.client import GnoiClient # noqa: E402 +from sonic_py_common.grpc.gnoi import system_pb2 # noqa: E402 + + +class TestGnoiClient(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.server = FakeGnoiServer() + cls.server.start() + + @classmethod + def tearDownClass(cls): + cls.server.stop() + + def setUp(self): + self.server.reset() + + def test_context_manager_creates_and_closes_channel(self): + with GnoiClient(self.server.target) as client: + self.assertIsNotNone(client.channel) + # After exit, channel is None + self.assertIsNone(client.channel) + + def test_close_idempotent(self): + client = GnoiClient(self.server.target) + client.__enter__() + client.close() + client.close() # Should not raise + + def test_channel_not_available_before_enter(self): + client = GnoiClient(self.server.target) + self.assertIsNone(client.channel) + + def test_system_reboot_rpc(self): + with GnoiClient(self.server.target) as client: + resp = client.system.Reboot( + system_pb2.RebootRequest(method=system_pb2.HALT), + timeout=5, + ) + self.assertIsNotNone(resp) + self.assertEqual(len(self.server.system.reboot_calls), 1) + + def test_system_reboot_status_rpc(self): + with GnoiClient(self.server.target) as client: + resp = client.system.RebootStatus( + system_pb2.RebootStatusRequest(), timeout=5 + ) + self.assertFalse(resp.active) + + def test_grpc_error_propagation(self): + self.server.system.set_reboot_response( + error_code=grpc.StatusCode.UNAVAILABLE, + error_message="connection refused", + ) + with GnoiClient(self.server.target) as client: + with self.assertRaises(grpc.RpcError) as ctx: + client.system.Reboot(system_pb2.RebootRequest(), timeout=5) + self.assertEqual(ctx.exception.code(), grpc.StatusCode.UNAVAILABLE) + + def test_channel_options_accepted(self): + """GnoiClient accepts gRPC channel options.""" + opts = [('grpc.keepalive_time_ms', 10000)] + with GnoiClient(self.server.target, options=opts) as client: + resp = client.system.RebootStatus( + system_pb2.RebootStatusRequest(), timeout=5 + ) + self.assertIsNotNone(resp) + + def test_multiple_rpcs_on_same_channel(self): + with GnoiClient(self.server.target) as client: + client.system.Reboot(system_pb2.RebootRequest(), timeout=5) + client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + client.system.CancelReboot(system_pb2.CancelRebootRequest(), timeout=5) + self.assertEqual(len(self.server.system.reboot_calls), 1) + self.assertEqual(len(self.server.system.reboot_status_calls), 1) + self.assertEqual(len(self.server.system.cancel_reboot_calls), 1) + + def test_credentials_none_uses_insecure_channel(self): + """When credentials is None, GnoiClient opens an insecure channel.""" + with mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.insecure_channel" + ) as m_insecure, mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.secure_channel" + ) as m_secure: + m_insecure.return_value = mock.MagicMock() + client = GnoiClient(self.server.target) + client.__enter__() + client.close() + m_insecure.assert_called_once() + m_secure.assert_not_called() + + def test_credentials_provided_uses_secure_channel(self): + """When credentials is provided, GnoiClient opens a secure channel.""" + creds = grpc.ssl_channel_credentials() + with mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.insecure_channel" + ) as m_insecure, mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.secure_channel" + ) as m_secure: + m_secure.return_value = mock.MagicMock() + client = GnoiClient("dut:50052", credentials=creds) + client.__enter__() + client.close() + m_secure.assert_called_once() + # First positional arg is the target, second is the credentials + args, kwargs = m_secure.call_args + self.assertEqual(args[0], "dut:50052") + self.assertIs(args[1], creds) + m_insecure.assert_not_called() + + def test_unix_socket_target_uses_insecure_channel(self): + """unix:// targets are passed through to insecure_channel as-is. + + gRPC supports the ``unix://`` scheme natively for local IPC, so no + special handling is needed in GnoiClient — but pin the behavior so + a future refactor doesn't accidentally break it. + """ + target = "unix:///var/run/gnmi/gnmi.sock" + with mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.insecure_channel" + ) as m_insecure, mock.patch( + "sonic_py_common.grpc.gnoi.client.grpc.secure_channel" + ) as m_secure: + m_insecure.return_value = mock.MagicMock() + client = GnoiClient(target) + client.__enter__() + client.close() + m_insecure.assert_called_once() + args, kwargs = m_insecure.call_args + self.assertEqual(args[0], target) + m_secure.assert_not_called() + + def test_service_stub_before_open_raises(self): + """Accessing a service stub before __enter__ raises a clear error. + + Without the explicit guard, callers got a confusing AttributeError + from inside the generated stub code when self._channel was None. + """ + client = GnoiClient(self.server.target) + with self.assertRaises(RuntimeError) as ctx: + client.system # noqa: B018 — accessing the property is the test + self.assertIn("context manager", str(ctx.exception)) + + def test_service_stub_after_close_raises(self): + """Accessing a service stub after close() raises a clear error.""" + client = GnoiClient(self.server.target) + client.__enter__() + client.close() + with self.assertRaises(RuntimeError): + client.system # noqa: B018 + + +if __name__ == '__main__': + unittest.main() diff --git a/src/sonic-py-common/tests/test_gnoi_testing.py b/src/sonic-py-common/tests/test_gnoi_testing.py new file mode 100644 index 00000000000..838e8ed81f8 --- /dev/null +++ b/src/sonic-py-common/tests/test_gnoi_testing.py @@ -0,0 +1,311 @@ +"""Tests for sonic_py_common.grpc.gnoi.testing (FakeGnoiServer). + +These tests use a real gRPC server and real GnoiClient — no mocking. +""" + +import sys +import unittest + +if sys.version_info[0] < 3: + # See sibling test_gnoi_client.py — module is Py3-only. + raise unittest.SkipTest("sonic_py_common.grpc.gnoi requires Python 3") + +import grpc # noqa: E402 + +from sonic_py_common.grpc.gnoi.testing import FakeGnoiServer # noqa: E402 +from sonic_py_common.grpc.gnoi.client import GnoiClient # noqa: E402 +from sonic_py_common.grpc.gnoi import system_pb2 # noqa: E402 + + +class TestFakeSystemServicer(unittest.TestCase): + """Unit tests for FakeSystemServicer behavior.""" + + @classmethod + def setUpClass(cls): + cls.server = FakeGnoiServer() + cls.server.start() + + @classmethod + def tearDownClass(cls): + cls.server.stop() + + def setUp(self): + self.server.reset() + + def test_reboot_default_success(self): + with GnoiClient(self.server.target) as client: + resp = client.system.Reboot( + system_pb2.RebootRequest(method=system_pb2.HALT, message="test"), + timeout=5, + ) + self.assertIsNotNone(resp) + self.assertEqual(len(self.server.system.reboot_calls), 1) + self.assertEqual(self.server.system.reboot_calls[0].method, system_pb2.HALT) + self.assertEqual(self.server.system.reboot_calls[0].message, "test") + + def test_reboot_error(self): + self.server.system.set_reboot_response( + error_code=grpc.StatusCode.UNAVAILABLE, + error_message="DPU unreachable", + ) + with GnoiClient(self.server.target) as client: + with self.assertRaises(grpc.RpcError) as ctx: + client.system.Reboot(system_pb2.RebootRequest(), timeout=5) + self.assertEqual(ctx.exception.code(), grpc.StatusCode.UNAVAILABLE) + self.assertIn("DPU unreachable", ctx.exception.details()) + + def test_reboot_status_default_complete(self): + with GnoiClient(self.server.target) as client: + resp = client.system.RebootStatus( + system_pb2.RebootStatusRequest(), timeout=5 + ) + self.assertFalse(resp.active) + self.assertEqual( + resp.status.status, + system_pb2.RebootStatus.Status.STATUS_SUCCESS, + ) + + def test_reboot_status_active(self): + self.server.system.set_reboot_status(active=True) + with GnoiClient(self.server.target) as client: + resp = client.system.RebootStatus( + system_pb2.RebootStatusRequest(), timeout=5 + ) + self.assertTrue(resp.active) + + def test_reboot_status_sequence(self): + """Polling scenario: active → active → done.""" + active_resp = system_pb2.RebootStatusResponse() + active_resp.active = True + + done_resp = system_pb2.RebootStatusResponse() + done_resp.active = False + done_resp.status.status = system_pb2.RebootStatus.Status.STATUS_SUCCESS + + self.server.system.set_reboot_status_sequence([active_resp, active_resp, done_resp]) + + with GnoiClient(self.server.target) as client: + r1 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertTrue(r1.active) + r2 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertTrue(r2.active) + r3 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertFalse(r3.active) + + self.assertEqual(len(self.server.system.reboot_status_calls), 3) + + def test_reboot_status_sequence_with_error(self): + """Polling with transient error then success.""" + done_resp = system_pb2.RebootStatusResponse() + done_resp.active = False + done_resp.status.status = system_pb2.RebootStatus.Status.STATUS_SUCCESS + + self.server.system.set_reboot_status_sequence([ + (grpc.StatusCode.UNAVAILABLE, "transient"), + done_resp, + ]) + + with GnoiClient(self.server.target) as client: + with self.assertRaises(grpc.RpcError): + client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + r2 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertFalse(r2.active) + + def test_reboot_status_error(self): + self.server.system.set_reboot_status( + error_code=grpc.StatusCode.INTERNAL, + error_message="internal failure", + ) + with GnoiClient(self.server.target) as client: + with self.assertRaises(grpc.RpcError) as ctx: + client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertEqual(ctx.exception.code(), grpc.StatusCode.INTERNAL) + + def test_cancel_reboot(self): + with GnoiClient(self.server.target) as client: + resp = client.system.CancelReboot( + system_pb2.CancelRebootRequest(message="abort"), timeout=5 + ) + self.assertIsNotNone(resp) + self.assertEqual(len(self.server.system.cancel_reboot_calls), 1) + + def test_reset_clears_history(self): + with GnoiClient(self.server.target) as client: + client.system.Reboot(system_pb2.RebootRequest(), timeout=5) + self.assertEqual(len(self.server.system.reboot_calls), 1) + self.server.reset() + self.assertEqual(len(self.server.system.reboot_calls), 0) + + +class TestFakeGnoiServer(unittest.TestCase): + """Test server lifecycle.""" + + def test_context_manager(self): + with FakeGnoiServer() as server: + self.assertIn("localhost:", server.target) + with GnoiClient(server.target) as client: + resp = client.system.RebootStatus( + system_pb2.RebootStatusRequest(), timeout=5 + ) + self.assertFalse(resp.active) + + def test_end_to_end_reboot_flow(self): + """Full reboot + poll flow like gnoi_shutdown_daemon.""" + with FakeGnoiServer() as server: + active = system_pb2.RebootStatusResponse() + active.active = True + done = system_pb2.RebootStatusResponse() + done.active = False + done.status.status = system_pb2.RebootStatus.Status.STATUS_SUCCESS + server.system.set_reboot_status_sequence([active, done]) + + with GnoiClient(server.target) as client: + # Send reboot + client.system.Reboot( + system_pb2.RebootRequest(method=system_pb2.HALT, message="shutdown"), + timeout=5, + ) + # Poll + r1 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertTrue(r1.active) + r2 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertFalse(r2.active) + + self.assertEqual(len(server.system.reboot_calls), 1) + self.assertEqual(server.system.reboot_calls[0].method, system_pb2.HALT) + self.assertEqual(len(server.system.reboot_status_calls), 2) + + +class TestFakeGnoiServerGuards(unittest.TestCase): + """Tests for FakeGnoiServer lifecycle / misuse guards.""" + + def test_target_before_start_raises(self): + """target accessed before start() raises a clear error. + + Previously returned the misleading string 'localhost:None', which + manifested as a confusing connection error rather than a misuse + diagnostic. + """ + server = FakeGnoiServer() + with self.assertRaises(RuntimeError) as ctx: + _ = server.target + self.assertIn("start()", str(ctx.exception)) + + def test_target_after_stop_raises(self): + """target accessed after stop() raises a clear error. + + Without resetting _port in stop(), target would keep pointing at a + defunct server, causing tests to fail with connection errors + instead of an obvious misuse signal. + """ + server = FakeGnoiServer() + server.start() + server.stop() + with self.assertRaises(RuntimeError): + _ = server.target + + def test_double_start_raises(self): + """start() on an already-started server raises rather than leaking + the first server's thread / listening socket.""" + server = FakeGnoiServer() + server.start() + try: + with self.assertRaises(RuntimeError) as ctx: + server.start() + self.assertIn("already-started", str(ctx.exception)) + finally: + server.stop() + + def test_stop_waits_for_termination(self): + """stop() returns only after the underlying gRPC server signals done. + + grpc.Server.stop(grace) returns a threading.Event; pinning the wait + prevents leaking server threads across many start/stop iterations. + """ + for _ in range(5): + server = FakeGnoiServer() + server.start() + target = server.target + server.stop() + # After stop returns, the port should no longer accept new + # connections — i.e., a new client should fail fast rather than + # hang. We don't strictly need to assert that here (would race + # with port reuse); the key contract is that stop() doesn't + # return mid-shutdown. Just exercising the loop without + # blowing the file-descriptor budget is the smoke test. + self.assertTrue(target.startswith("localhost:")) + + +class TestFakeSystemServicerEdgeCases(unittest.TestCase): + """Behavioral guarantees for FakeSystemServicer that aren't covered + elsewhere — sequence exhaustion fallback, explicit-empty-message + handling. + """ + + @classmethod + def setUpClass(cls): + cls.server = FakeGnoiServer() + cls.server.start() + + @classmethod + def tearDownClass(cls): + cls.server.stop() + + def setUp(self): + self.server.reset() + + def test_status_sequence_falls_back_to_single_response_after_exhaustion(self): + """Docstring promises: after sequence exhaustion, fall back to the + single configured response. Verify the contract.""" + single = system_pb2.RebootStatusResponse() + single.active = False + single.status.message = "fallback" + self.server.system.set_reboot_status(response=single) + + seq_item = system_pb2.RebootStatusResponse() + seq_item.active = True + seq_item.status.message = "from sequence" + self.server.system.set_reboot_status_sequence([seq_item]) + + with GnoiClient(self.server.target) as client: + r1 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + r2 = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + + self.assertEqual(r1.status.message, "from sequence") + self.assertTrue(r1.active) + # Second call: sequence exhausted, should return the single response + self.assertEqual(r2.status.message, "fallback") + self.assertFalse(r2.active) + + def test_set_reboot_status_message_can_be_cleared_to_empty(self): + """message='' should clear the previous value; previously the + falsy guard silently kept it.""" + self.server.system.set_reboot_status(message="hello") + self.server.system.set_reboot_status(message="") + + with GnoiClient(self.server.target) as client: + resp = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertEqual(resp.status.message, "") + + + def test_set_reboot_status_message_default_does_not_clobber(self): + """Default message=None must NOT overwrite a previously set message. + + Previously the default was ``message=""`` which the falsy guard + translated into a silent "keep previous"; changing the default to + ``None`` made the empty-string case meaningful but risks clobbering + on every other call if not handled correctly. Pin both halves of + the contract. + """ + self.server.system.set_reboot_status(message="orig") + # Call without message kwarg: message should be preserved. + self.server.system.set_reboot_status(active=True) + + with GnoiClient(self.server.target) as client: + resp = client.system.RebootStatus(system_pb2.RebootStatusRequest(), timeout=5) + self.assertEqual(resp.status.message, "orig") + self.assertTrue(resp.active) + + +if __name__ == '__main__': + unittest.main()