|
| 1 | +# Design: Replace `docker exec gnoi_client` with Native gRPC Calls |
| 2 | + |
| 3 | +## 1. Background |
| 4 | + |
| 5 | +The `gnoi_shutdown_daemon` on SmartSwitch NPU orchestrates graceful DPU shutdown by issuing gNOI `System.Reboot(HALT)` and polling `System.RebootStatus`. Today it does this by shelling out: |
| 6 | + |
| 7 | +``` |
| 8 | +docker exec gnmi gnoi_client -target=<ip>:<port> -notls -module System -rpc Reboot ... |
| 9 | +docker exec gnmi gnoi_client -target=<ip>:<port> -notls -module System -rpc RebootStatus |
| 10 | +``` |
| 11 | + |
| 12 | +This has several problems: |
| 13 | + |
| 14 | +| Problem | Impact | |
| 15 | +|---------|--------| |
| 16 | +| Requires the `gnmi` container to be running and healthy | If gnmi container is restarting or unhealthy, DPU shutdown fails silently | |
| 17 | +| Subprocess overhead per RPC call | Extra process creation, Docker CLI round-trip, stdout parsing | |
| 18 | +| Fragile output parsing | `"reboot complete" in out_s.lower()` breaks on any output format change | |
| 19 | +| No structured error handling | gRPC status codes are lost; only `rc != 0` is checked | |
| 20 | +| Error output is a Go panic stack trace | Extremely painful to diagnose failures (see §1.1) | |
| 21 | +| Security surface | Shell-out through Docker CLI is a wider attack surface than a direct socket | |
| 22 | + |
| 23 | +### 1.1 gnoi_client Output Format Analysis |
| 24 | + |
| 25 | +The `gnoi_client` binary in sonic-gnmi is a Go CLI tool. Understanding its output format reveals why the current approach is fragile: |
| 26 | + |
| 27 | +**Reboot RPC (`-rpc Reboot`):** |
| 28 | +- On **success**: prints `"System Reboot\n"` to stdout, exits 0. No structured output. |
| 29 | +- On **failure**: calls `panic(err.Error())`, which dumps a **Go panic stack trace** to stderr and exits with a non-zero code. The daemon only checks `rc != 0` — the actual gRPC error code, message, and details are buried in a multi-line panic dump that is not parsed. |
| 30 | + |
| 31 | +**RebootStatus RPC (`-rpc RebootStatus`):** |
| 32 | +- On **success**: prints `"System RebootStatus\n"` header followed by JSON-marshaled `RebootStatusResponse`, e.g.: |
| 33 | + ```json |
| 34 | + System RebootStatus |
| 35 | + {"active":false,"status":{"status":"STATUS_SUCCESS","message":"..."}} |
| 36 | + ``` |
| 37 | +- On **failure**: same `panic(err.Error())` — Go stack trace, non-zero exit. |
| 38 | + |
| 39 | +**The parsing bug:** The daemon currently checks: |
| 40 | +```python |
| 41 | +if rc_s == 0 and out_s and ("reboot complete" in out_s.lower()): |
| 42 | + return True |
| 43 | +``` |
| 44 | +But the actual protobuf `RebootStatusResponse` serialized to JSON contains fields like `"active":false` and `"status":"STATUS_SUCCESS"` — the string `"reboot complete"` never appears in the output. This means the poll loop **always times out** regardless of whether the DPU successfully halted, and the daemon proceeds purely on the timeout path. |
| 45 | + |
| 46 | +**Why this matters for error diagnosis:** When a gNOI RPC fails (DPU unreachable, TLS mismatch, auth failure, server-side error), the only signal is a Go panic: |
| 47 | +``` |
| 48 | +panic: rpc error: code = Unavailable desc = connection error: ... |
| 49 | +
|
| 50 | +goroutine 1 [running]: |
| 51 | +main.main() |
| 52 | + /sonic/gnoi_client/gnoi_client.go:42 |
| 53 | +... |
| 54 | +``` |
| 55 | +The daemon captures this in `err` (stderr) but never logs or inspects it — it just logs `"Reboot command failed"` with no context. Diagnosing production failures requires SSHing into the switch, manually running the docker exec command, and reading Go stack traces. |
| 56 | + |
| 57 | +## 2. Goal |
| 58 | + |
| 59 | +Replace the subprocess-based `gnoi_client` invocations with direct Python gRPC calls using generated protobuf stubs for the [OpenConfig gNOI System service](https://github.com/openconfig/gnoi/blob/main/system/system.proto). |
| 60 | + |
| 61 | +## 3. Scope |
| 62 | + |
| 63 | +### In Scope |
| 64 | +- Generate or vendor Python gRPC stubs for `gnoi.system.System` (Reboot, RebootStatus RPCs) |
| 65 | +- Create a lightweight `GnoiClient` wrapper class |
| 66 | +- Refactor `GnoiRebootHandler._send_reboot_command()` and `_poll_reboot_status()` to use native gRPC |
| 67 | +- Remove `execute_command()` helper (becomes unused) |
| 68 | +- Update unit tests to mock at the gRPC stub level |
| 69 | +- Add `grpcio` and `protobuf` to package dependencies |
| 70 | + |
| 71 | +### Out of Scope |
| 72 | +- TLS/mTLS on the midplane channel (future work; midplane is trusted today) |
| 73 | +- Refactoring the daemon's main loop or config DB subscription logic |
| 74 | +- Other gNOI services beyond `System` |
| 75 | +- Changes to how DPU IP/port are discovered from CONFIG_DB |
| 76 | + |
| 77 | +## 4. Design |
| 78 | + |
| 79 | +### 4.1 Phase 1 — Proto Stubs |
| 80 | + |
| 81 | +Vendor pre-generated Python stubs from the gNOI `system.proto` definition. |
| 82 | + |
| 83 | +**Files to add:** |
| 84 | +``` |
| 85 | +host_modules/gnoi/ |
| 86 | +├── __init__.py |
| 87 | +├── system_pb2.py # generated message classes |
| 88 | +└── system_pb2_grpc.py # generated service stubs |
| 89 | +``` |
| 90 | + |
| 91 | +The stubs are generated from: |
| 92 | +- https://github.com/openconfig/gnoi/blob/main/system/system.proto |
| 93 | +- https://github.com/openconfig/gnoi/blob/main/types/types.proto (dependency) |
| 94 | + |
| 95 | +Generation command (for reference / CI reproducibility): |
| 96 | +```bash |
| 97 | +python -m grpc_tools.protoc \ |
| 98 | + -I./proto \ |
| 99 | + --python_out=host_modules/gnoi \ |
| 100 | + --grpc_python_out=host_modules/gnoi \ |
| 101 | + system/system.proto types/types.proto |
| 102 | +``` |
| 103 | + |
| 104 | +**Why vendor instead of build-time generation?** |
| 105 | +- sonic-host-services has no existing proto compilation infrastructure |
| 106 | +- The gNOI System proto is stable (no changes in years) |
| 107 | +- Keeps the build simple; can migrate to build-time generation later if more protos are needed |
| 108 | + |
| 109 | +### 4.2 Phase 2 — GnoiClient Wrapper |
| 110 | + |
| 111 | +A thin wrapper providing the two RPCs we need: |
| 112 | + |
| 113 | +```python |
| 114 | +# host_modules/gnoi/client.py |
| 115 | + |
| 116 | +import grpc |
| 117 | +from . import system_pb2, system_pb2_grpc |
| 118 | + |
| 119 | +class GnoiClient: |
| 120 | + """Lightweight gNOI System service client for DPU communication.""" |
| 121 | + |
| 122 | + def __init__(self, target: str, timeout: int = 30): |
| 123 | + """ |
| 124 | + Args: |
| 125 | + target: gRPC target in "host:port" format |
| 126 | + timeout: Default RPC timeout in seconds |
| 127 | + """ |
| 128 | + self._channel = grpc.insecure_channel(target) |
| 129 | + self._stub = system_pb2_grpc.SystemStub(self._channel) |
| 130 | + self._timeout = timeout |
| 131 | + |
| 132 | + def reboot(self, method: int = 3, message: str = "") -> None: |
| 133 | + """ |
| 134 | + Send System.Reboot RPC. |
| 135 | +
|
| 136 | + Args: |
| 137 | + method: RebootMethod enum value (3 = HALT) |
| 138 | + message: Human-readable reason string |
| 139 | +
|
| 140 | + Raises: |
| 141 | + grpc.RpcError: on any gRPC failure |
| 142 | + """ |
| 143 | + request = system_pb2.RebootRequest( |
| 144 | + method=method, |
| 145 | + message=message, |
| 146 | + ) |
| 147 | + self._stub.Reboot(request, timeout=self._timeout) |
| 148 | + |
| 149 | + def reboot_status(self) -> system_pb2.RebootStatusResponse: |
| 150 | + """ |
| 151 | + Poll System.RebootStatus RPC. |
| 152 | +
|
| 153 | + Returns: |
| 154 | + RebootStatusResponse with .active and .wait fields |
| 155 | +
|
| 156 | + Raises: |
| 157 | + grpc.RpcError: on any gRPC failure |
| 158 | + """ |
| 159 | + request = system_pb2.RebootStatusRequest() |
| 160 | + return self._stub.RebootStatus(request, timeout=self._timeout) |
| 161 | + |
| 162 | + def close(self): |
| 163 | + """Close the underlying gRPC channel.""" |
| 164 | + if self._channel: |
| 165 | + self._channel.close() |
| 166 | + |
| 167 | + def __enter__(self): |
| 168 | + return self |
| 169 | + |
| 170 | + def __exit__(self, *args): |
| 171 | + self.close() |
| 172 | +``` |
| 173 | + |
| 174 | +### 4.3 Phase 3 — Refactor gnoi_shutdown_daemon |
| 175 | + |
| 176 | +Replace the two subprocess call sites in `GnoiRebootHandler`: |
| 177 | + |
| 178 | +#### `_send_reboot_command` (before) |
| 179 | +```python |
| 180 | +def _send_reboot_command(self, dpu_name, dpu_ip, port): |
| 181 | + reboot_cmd = ["docker", "exec", "gnmi", "gnoi_client", ...] |
| 182 | + rc, out, err = execute_command(reboot_cmd, ...) |
| 183 | + return rc == 0 |
| 184 | +``` |
| 185 | + |
| 186 | +#### `_send_reboot_command` (after) |
| 187 | +```python |
| 188 | +def _send_reboot_command(self, dpu_name, dpu_ip, port): |
| 189 | + try: |
| 190 | + with GnoiClient(f"{dpu_ip}:{port}", timeout=REBOOT_RPC_TIMEOUT_SEC) as client: |
| 191 | + client.reboot( |
| 192 | + method=REBOOT_METHOD_HALT, |
| 193 | + message="Triggered by SmartSwitch graceful shutdown" |
| 194 | + ) |
| 195 | + return True |
| 196 | + except grpc.RpcError as e: |
| 197 | + logger.log_error(f"{dpu_name}: gNOI Reboot failed: {e.code()} {e.details()}") |
| 198 | + return False |
| 199 | +``` |
| 200 | + |
| 201 | +#### `_poll_reboot_status` (before) |
| 202 | +```python |
| 203 | +def _poll_reboot_status(self, dpu_name, dpu_ip, port): |
| 204 | + status_cmd = ["docker", "exec", "gnmi", "gnoi_client", ...] |
| 205 | + while time.monotonic() < deadline: |
| 206 | + rc_s, out_s, _ = execute_command(status_cmd, ...) |
| 207 | + if rc_s == 0 and "reboot complete" in out_s.lower(): |
| 208 | + return True |
| 209 | +``` |
| 210 | + |
| 211 | +#### `_poll_reboot_status` (after) |
| 212 | +```python |
| 213 | +def _poll_reboot_status(self, dpu_name, dpu_ip, port): |
| 214 | + deadline = time.monotonic() + _get_halt_timeout() |
| 215 | + with GnoiClient(f"{dpu_ip}:{port}", timeout=STATUS_RPC_TIMEOUT_SEC) as client: |
| 216 | + while time.monotonic() < deadline: |
| 217 | + try: |
| 218 | + resp = client.reboot_status() |
| 219 | + if not resp.active: |
| 220 | + status_str = system_pb2.RebootStatus.Status.Name(resp.status.status) |
| 221 | + logger.log_notice(f"{dpu_name}: RebootStatus complete: {status_str} - {resp.status.message}") |
| 222 | + return resp.status.status == system_pb2.RebootStatus.Status.STATUS_SUCCESS |
| 223 | + except grpc.RpcError as e: |
| 224 | + logger.log_warning( |
| 225 | + f"{dpu_name}: RebootStatus poll error: code={e.code()} details={e.details()}" |
| 226 | + ) |
| 227 | + time.sleep(STATUS_POLL_INTERVAL_SEC) |
| 228 | + return False |
| 229 | +``` |
| 230 | + |
| 231 | +**Key improvements over the subprocess approach:** |
| 232 | +- **Fixes the parsing bug**: checks `resp.active == False` directly instead of the broken `"reboot complete" in stdout` match that never triggers |
| 233 | +- **Distinguishes success from failure**: inspects `resp.status.status` enum (`STATUS_SUCCESS` vs `STATUS_FAILURE` vs `STATUS_RETRIABLE_FAILURE`) |
| 234 | +- **Actionable error logs**: gRPC errors include status code and details (e.g., `code=UNAVAILABLE details=connection refused`) instead of opaque "command failed" |
| 235 | + |
| 236 | +#### Removals |
| 237 | +- `execute_command()` function — no longer needed |
| 238 | +- `import subprocess` — no longer needed |
| 239 | + |
| 240 | +#### Additions |
| 241 | +- `import grpc` |
| 242 | +- `from host_modules.gnoi.client import GnoiClient` |
| 243 | + |
| 244 | +### 4.4 Phase 4 — Update Tests |
| 245 | + |
| 246 | +Current tests mock `execute_command` and check return codes. New tests mock at the gRPC level: |
| 247 | + |
| 248 | +```python |
| 249 | +@mock.patch('gnoi_shutdown_daemon.GnoiClient') |
| 250 | +def test_send_reboot_command_success(self, MockClient): |
| 251 | + mock_client = MockClient.return_value.__enter__.return_value |
| 252 | + # reboot() returns None on success |
| 253 | + mock_client.reboot.return_value = None |
| 254 | + |
| 255 | + result = handler._send_reboot_command("DPU0", "10.0.0.1", "8080") |
| 256 | + assert result is True |
| 257 | + mock_client.reboot.assert_called_once() |
| 258 | + |
| 259 | +@mock.patch('gnoi_shutdown_daemon.GnoiClient') |
| 260 | +def test_send_reboot_command_failure(self, MockClient): |
| 261 | + mock_client = MockClient.return_value.__enter__.return_value |
| 262 | + mock_client.reboot.side_effect = grpc.RpcError() |
| 263 | + |
| 264 | + result = handler._send_reboot_command("DPU0", "10.0.0.1", "8080") |
| 265 | + assert result is False |
| 266 | +``` |
| 267 | + |
| 268 | +### 4.5 Dependencies |
| 269 | + |
| 270 | +| Package | Version | Notes | |
| 271 | +|---------|---------|-------| |
| 272 | +| `grpcio` | >=1.51.0 | Already in SONiC build environment | |
| 273 | +| `protobuf` | >=4.21.0 | Already in SONiC build environment | |
| 274 | + |
| 275 | +Verify these are available in the sonic-host-services build context. If not, add to `setup.py` `install_requires`. |
| 276 | + |
| 277 | +## 5. Implementation Plan |
| 278 | + |
| 279 | +| Phase | Description | PR | |
| 280 | +|-------|-------------|----| |
| 281 | +| 1 | Vendor gNOI System proto stubs | PR #1 | |
| 282 | +| 2 | Add `GnoiClient` wrapper + unit tests | PR #1 (same) | |
| 283 | +| 3 | Refactor `gnoi_shutdown_daemon` to use `GnoiClient` | PR #1 (same) | |
| 284 | +| 4 | Update existing daemon tests | PR #1 (same) | |
| 285 | + |
| 286 | +All phases can ship as a single PR since they form one atomic change — the old subprocess path is fully replaced. |
| 287 | + |
| 288 | +## 6. Testing |
| 289 | + |
| 290 | +- **Unit tests**: Mock gRPC stubs, verify correct protobuf messages are sent, verify error handling for various `grpc.StatusCode` values |
| 291 | +- **Integration test**: On a SmartSwitch testbed, trigger `config chassis modules shutdown DPU0` and verify gNOI HALT is sent and RebootStatus is polled successfully via syslog |
| 292 | +- **Regression**: Existing CI pipeline covers the daemon; updated mocks ensure no regressions |
| 293 | + |
| 294 | +## 7. Risks & Mitigations |
| 295 | + |
| 296 | +| Risk | Mitigation | |
| 297 | +|------|------------| |
| 298 | +| gRPC/protobuf not available in host environment | Verify during build; these are already used by other SONiC components | |
| 299 | +| Proto stub drift from upstream gnoi | Pin to a specific gnoi commit; stubs are stable | |
| 300 | +| Insecure channel on midplane | Same trust model as today's `gnoi_client -notls`; TLS is future work | |
| 301 | + |
| 302 | +## 8. Future Work |
| 303 | + |
| 304 | +- **TLS support**: Add optional mTLS when midplane security is hardened |
| 305 | +- **Build-time proto generation**: If more gNOI/gNMI services are needed, add a proto compilation step |
| 306 | +- **Connection pooling**: Reuse gRPC channels across polls instead of creating per-call (minor optimization) |
0 commit comments