Skip to content

Commit 22d0937

Browse files
committed
Add design doc: replace docker exec gnoi_client with native gRPC calls
Proposes replacing subprocess-based gnoi_client invocations in gnoi_shutdown_daemon with direct Python gRPC calls. Documents the gnoi_client output format, a parsing bug in RebootStatus polling, and the difficulty of diagnosing RPC failures through Go panic stack traces. Ref: #360 Signed-off-by: Dawei Huang <daweihuang@microsoft.com>
1 parent 2c5bf36 commit 22d0937

1 file changed

Lines changed: 306 additions & 0 deletions

File tree

doc/gnoi-native-grpc-design.md

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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

Comments
 (0)