Skip to main content

netget/client/gnmi/
actions.rs

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}