1use crate::server::gnmi::actions::{definition, field};
2use crate::{
3 llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, ParameterDefinition, StartupExamples,
7 },
8 protocol::{ConnectContext, EventType, LogTemplate},
9 state::AppState,
10};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13#[derive(Default)]
14pub struct GnmiClientProtocol;
15impl GnmiClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20fn event(
21 name: &str,
22 description: &str,
23 parameters: Vec<crate::llm::actions::Parameter>,
24) -> EventType {
25 EventType::new(name, description, json!({"type":"wait_for_more"}))
26 .with_parameters(parameters)
27 .with_actions(GnmiClientProtocol.get_sync_actions())
28}
29pub static CONNECTED: LazyLock<EventType> = LazyLock::new(|| {
30 event(
31 "gnmi_client_connected",
32 "HTTP/2 gNMI connection ready",
33 vec![
34 field("remote_addr", "string", "Connected endpoint", true),
35 field(
36 "tls_verified",
37 "boolean",
38 "Certificate and server name verified when true",
39 true,
40 ),
41 ],
42 )
43});
44pub static RESPONSE: LazyLock<EventType> = LazyLock::new(|| {
45 event(
46 "gnmi_client_response",
47 "Validated typed Capabilities/Get/Set response",
48 vec![
49 field("call_id", "integer", "Originating call", true),
50 field(
51 "method",
52 "string",
53 "Completed Capabilities, Get or Set RPC name",
54 true,
55 ),
56 field("response", "object", "Decoded typed result", true),
57 ],
58 )
59});
60pub static UPDATE: LazyLock<EventType> = LazyLock::new(|| {
61 event(
62 "gnmi_client_update",
63 "Validated subscription notification",
64 vec![
65 field("call_id", "integer", "Originating subscription", true),
66 field(
67 "sequence",
68 "integer",
69 "Response sequence from 1, including sync",
70 true,
71 ),
72 field("notification", "object", "Typed paths and values", true),
73 ],
74 )
75});
76pub static SYNC: LazyLock<EventType> = LazyLock::new(|| {
77 event(
78 "gnmi_client_sync",
79 "Initial/POLL snapshot complete",
80 vec![
81 field("call_id", "integer", "Originating subscription", true),
82 field("sequence", "integer", "Response sequence from 1", true),
83 ],
84 )
85});
86pub static ENDED: LazyLock<EventType> = LazyLock::new(|| {
87 event(
88 "gnmi_client_ended",
89 "Final gRPC status or local bounded failure",
90 vec![
91 field("call_id", "integer", "Originating call", true),
92 field("code", "integer", "gRPC status 0..16", true),
93 field("message", "string", "Diagnostic, at most 512 bytes", true),
94 field(
95 "response_count",
96 "integer",
97 "Validated response messages",
98 true,
99 ),
100 ],
101 )
102});
103impl Protocol for GnmiClientProtocol {
104 fn protocol_name(&self) -> &'static str {
105 "gNMI"
106 }
107 fn stack_name(&self) -> &'static str {
108 "ETH>IP>TCP>HTTP2>GRPC>GNMI"
109 }
110 fn group_name(&self) -> &'static str {
111 "Network Services"
112 }
113 fn description(&self) -> &'static str {
114 "Typed OpenConfig gNMI RPC and subscription client"
115 }
116 fn keywords(&self) -> Vec<&'static str> {
117 vec!["gnmi", "openconfig"]
118 }
119 fn example_prompt(&self) -> &'static str {
120 "Read typed gNMI state and cancel a live subscription"
121 }
122 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
123 let mut m = crate::llm::actions::protocol_trait::Protocol::metadata(
124 &crate::server::gnmi::actions::GnmiProtocol,
125 );
126 m.notes=Some("Experimental typed gNMI client; Capabilities/Get/Set and ONCE/POLL/STREAM, TLS certificate/name verification with optional bounded CA, explicit cleartext local-fixture mode. No automatic reconnect/retry; no opaque values, extensions, SAMPLE, YANG execution, storage, authentication, reflection, fuzz or pcap evidence.".into());
127 m
128 }
129 fn get_event_types(&self) -> Vec<EventType> {
130 vec![
131 CONNECTED.clone(),
132 RESPONSE.clone(),
133 UPDATE.clone(),
134 SYNC.clone(),
135 ENDED.clone(),
136 ]
137 }
138 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
139 vec![
140 definition(
141 "use_tls",
142 "boolean",
143 "Verify certificate and server name; false explicitly selects cleartext fixtures",
144 Some(json!(super::DEFAULT_TLS)),
145 ),
146 definition(
147 "server_name",
148 "string",
149 "TLS certificate name override, at most 253 bytes; requires use_tls",
150 None,
151 ),
152 definition(
153 "ca_file",
154 "string",
155 "Additional trusted CA PEM regular file, at most 1 MiB; requires use_tls",
156 None,
157 ),
158 definition(
159 "connect_timeout_secs",
160 "integer",
161 "Whole file/TCP/TLS/HTTP2 startup deadline, 1..60 seconds",
162 Some(json!(super::CONNECT_TIMEOUT_SECS)),
163 ),
164 definition(
165 "rpc_timeout_secs",
166 "integer",
167 "Whole RPC including stream/event backpressure, 1..3600 seconds",
168 Some(json!(crate::server::gnmi::DEFAULT_RPC_TIMEOUT_SECS)),
169 ),
170 definition(
171 "idle_timeout_secs",
172 "integer",
173 "Idle connection closes after 1..3600 seconds, only with no RPC/handler",
174 Some(json!(super::DEFAULT_IDLE_TIMEOUT_SECS)),
175 ),
176 ]
177 }
178 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
179 self.get_sync_actions()
180 }
181 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
182 let action = |name: &str, description: &str, request: Option<&str>, example: Value| {
183 let mut parameters = vec![];
184 if name != "wait_for_more" && name != "disconnect" {
185 parameters.push(field(
186 "call_id",
187 "integer",
188 "Fresh positive u32 for RPCs, active ID for controls; at most 256 total calls",
189 true,
190 ));
191 }
192 if let Some(request) = request {
193 parameters.push(field("request", "object", request, true));
194 }
195 if matches!(
196 name,
197 "gnmi_capabilities" | "gnmi_get" | "gnmi_set" | "gnmi_subscribe"
198 ) {
199 parameters.push(field(
200 "gzip",
201 "boolean",
202 "Compress request and accept bounded gzip responses",
203 false,
204 ));
205 }
206 ActionDefinition {
207 name: name.into(),
208 description: description.into(),
209 parameters,
210 example,
211 log_template: Some(LogTemplate::new().with_info(match name {
212 "gnmi_capabilities" => "gNMI Capabilities call {call_id} queued",
213 "gnmi_get" => "gNMI Get call {call_id} queued",
214 "gnmi_set" => "gNMI Set call {call_id} queued",
215 "gnmi_subscribe" => "gNMI Subscribe call {call_id} queued",
216 "gnmi_poll" => "gNMI Poll for call {call_id} queued",
217 "gnmi_cancel" => "gNMI call {call_id} cancellation requested",
218 "disconnect" => "gNMI client disconnect requested",
219 _ => "gNMI client waiting for another event",
220 })),
221 }
222 };
223 vec![
224 action("gnmi_capabilities","Read capabilities",None,json!({"type":"gnmi_capabilities","call_id":1})),
225 action("gnmi_get","Read typed state snapshot",Some("{prefix,path:[{origin,target,elem:[{name,key}]}],data_type:ALL|CONFIG|STATE|OPERATIONAL,encoding:PROTO|JSON|JSON_IETF|ASCII,use_models}"),json!({"type":"gnmi_get","call_id":1,"request":{"path":[{"elem":[{"name":"system"}]}],"encoding":"PROTO"}})),
226 action("gnmi_set","Send one atomic delete/replace/update request",Some("{prefix,delete:[path],replace:[{path,value:{kind,value},duplicates}],update:[...]} ; signed/unsigned integers are decimal strings"),json!({"type":"gnmi_set","call_id":1,"request":{"delete":[{"elem":[{"name":"old"}]}]}})),
227 action("gnmi_subscribe","Start a bounded ONCE/POLL/STREAM subscription",Some("{prefix,mode:ONCE|POLL|STREAM,subscription:[{path,mode:TARGET_DEFINED|ON_CHANGE}],encoding,updates_only,use_models}"),json!({"type":"gnmi_subscribe","call_id":1,"request":{"mode":"ONCE","subscription":[{"path":{"elem":[{"name":"system"}]}}],"encoding":"PROTO"}})),
228 action("gnmi_poll","Request next POLL snapshot after prior sync",None,json!({"type":"gnmi_poll","call_id":1})),
229 action("gnmi_cancel","Cancel an active RPC responsively",None,json!({"type":"gnmi_cancel","call_id":1})),
230 action("wait_for_more","Leave the owned connection running",None,json!({"type":"wait_for_more"})),
231 action("disconnect","Drop the owned socket and all calls/handlers",None,json!({"type":"disconnect"})),
232 ]
233 }
234 fn get_startup_examples(&self) -> StartupExamples {
235 let answer = json!({"type":"gnmi_capabilities","call_id":1});
236 let base = |handler: Option<Value>, instruction: &str| {
237 let mut v = json!({"type":"open_client","base_stack":"gnmi","remote_addr":"127.0.0.1:9339","instruction":instruction});
238 if let Some(handler) = handler {
239 v["event_handlers"] =
240 json!([{"event_pattern":"gnmi_client_connected","handler":handler}]);
241 }
242 v
243 };
244 StartupExamples::new(
245 base(
246 None,
247 "Send gnmi_capabilities with call_id1, then wait for the typed response.",
248 ),
249 base(
250 Some(
251 json!({"type":"script","language":"python","code":"import json,sys\njson.load(sys.stdin)\nprint(json.dumps({'actions':[{'type':'gnmi_capabilities','call_id':1}]}))"}),
252 ),
253 "",
254 ),
255 base(Some(json!({"type":"static","actions":[answer]})), ""),
256 )
257 }
258}
259impl Client for GnmiClientProtocol {
260 fn connect(
261 &self,
262 ctx: ConnectContext,
263 ) -> std::pin::Pin<
264 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
265 > {
266 Box::pin(super::connect(ctx))
267 }
268 fn execute_action(&self, action: Value) -> anyhow::Result<ClientActionResult> {
269 super::request::parse(&action)?;
270 Ok(match action["type"].as_str() {
271 Some("disconnect") => ClientActionResult::Disconnect,
272 Some("wait_for_more") => ClientActionResult::WaitForMore,
273 _ => ClientActionResult::Custom {
274 name: "gnmi_command".into(),
275 data: action,
276 },
277 })
278 }
279}