Skip to main content

netget/client/redfish/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::redfish::actions::{action, parameter};
8use crate::server::redfish::model;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct RedfishClientProtocol;
15impl RedfishClientProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21fn path_param() -> crate::llm::actions::Parameter {
22    parameter(
23        "path",
24        "string",
25        "Resource path under /redfish/v1, e.g. /redfish/v1/Systems/1",
26        true,
27    )
28}
29fn get_action() -> ActionDefinition {
30    action(
31        "redfish_get",
32        "Read a resource or collection (GET)",
33        vec![path_param()],
34        json!({"type":"redfish_get","path":"/redfish/v1/Systems"}),
35    )
36}
37fn patch_action() -> ActionDefinition {
38    action(
39        "redfish_patch",
40        "Change writable properties of a resource (PATCH); a 202 task is followed to its end",
41        vec![
42            path_param(),
43            parameter(
44                "body",
45                "object",
46                "Properties to change, e.g. {\"AssetTag\": \"rack-7\"}",
47                true,
48            ),
49            parameter(
50                "if_match",
51                "string",
52                "Optional ETag to send as If-Match",
53                false,
54            ),
55        ],
56        json!({"type":"redfish_patch","path":"/redfish/v1/Systems/1","body":{"AssetTag":"rack-7"}}),
57    )
58}
59fn post_action() -> ActionDefinition {
60    action(
61        "redfish_post",
62        "POST a body to a collection (create) or an action target; a 202 task is followed to its end",
63        vec![
64            path_param(),
65            parameter("body", "object", "Request body, e.g. {\"UserName\": \"ops\", \"Password\": \"...\", \"RoleId\": \"Operator\"}", true),
66        ],
67        json!({"type":"redfish_post","path":"/redfish/v1/AccountService/Accounts","body":{"UserName":"ops","Password":"changeme","RoleId":"Operator"}}),
68    )
69}
70fn delete_action() -> ActionDefinition {
71    action(
72        "redfish_delete",
73        "Delete a resource (DELETE)",
74        vec![path_param()],
75        json!({"type":"redfish_delete","path":"/redfish/v1/AccountService/Accounts/3"}),
76    )
77}
78fn invoke_action() -> ActionDefinition {
79    action(
80        "redfish_action",
81        "Invoke an action a resource advertises: Rust reads the resource, finds the action's target in its Actions and POSTs the parameters there",
82        vec![
83            parameter("resource_path", "string", "The resource owning the action, e.g. /redfish/v1/Systems/1", true),
84            parameter("action", "string", "Action name without the #, e.g. ComputerSystem.Reset", true),
85            parameter("parameters", "object", "Action parameters, e.g. {\"ResetType\": \"ForceRestart\"}", false),
86        ],
87        json!({"type":"redfish_action","resource_path":"/redfish/v1/Systems/1","action":"ComputerSystem.Reset","parameters":{"ResetType":"GracefulRestart"}}),
88    )
89}
90fn disconnect_action() -> ActionDefinition {
91    action(
92        "disconnect",
93        "Log out (deleting the session) and stop this Redfish client",
94        vec![],
95        json!({"type":"disconnect"}),
96    )
97}
98fn actions() -> Vec<ActionDefinition> {
99    vec![
100        get_action(),
101        patch_action(),
102        post_action(),
103        delete_action(),
104        invoke_action(),
105        disconnect_action(),
106    ]
107}
108
109pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
110    EventType::new(
111        "redfish_connected",
112        "The service root was read (and the client logged in when credentials were given)",
113        get_action().example.clone(),
114    )
115    .with_parameters(vec![
116        parameter(
117            "redfish_version",
118            "string",
119            "RedfishVersion from the service root",
120            false,
121        ),
122        parameter("product", "string", "Product from the service root", false),
123        parameter("vendor", "string", "Vendor from the service root", false),
124        parameter(
125            "links",
126            "object",
127            "Top-level resources the service root links to, name → path",
128            true,
129        ),
130        parameter("authenticated", "string", "session, basic or none", true),
131    ])
132    .with_actions(actions())
133});
134pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
135    EventType::new(
136        "redfish_response",
137        "The service's answer to the last request",
138        disconnect_action().example.clone(),
139    )
140    .with_parameters(vec![
141        parameter("method", "string", "HTTP method sent", true),
142        parameter(
143            "path",
144            "string",
145            "Path the request went to (the action target for redfish_action)",
146            true,
147        ),
148        parameter(
149            "status",
150            "number",
151            "HTTP status (the final one when a task was followed)",
152            true,
153        ),
154        parameter("body", "object", "Response body, when there was one", false),
155        parameter(
156            "location",
157            "string",
158            "Location header (created resource or task monitor)",
159            false,
160        ),
161        parameter(
162            "error",
163            "object",
164            "Redfish error: {message_id, message} from the error body",
165            false,
166        ),
167        parameter(
168            "task",
169            "object",
170            "The followed task: {state, messages, monitor}",
171            false,
172        ),
173    ])
174    .with_actions(actions())
175});
176
177impl Protocol for RedfishClientProtocol {
178    fn protocol_name(&self) -> &'static str {
179        "Redfish"
180    }
181    fn stack_name(&self) -> &'static str {
182        "ETH>IP>TCP>HTTP>Redfish"
183    }
184    fn keywords(&self) -> Vec<&'static str> {
185        vec!["redfish", "bmc", "dmtf", "redfish client"]
186    }
187    fn description(&self) -> &'static str {
188        "Redfish client: reads the service root, logs in, reads and changes resources, invokes actions and follows tasks"
189    }
190    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
191        actions()
192    }
193    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
194        vec![]
195    }
196    fn get_event_types(&self) -> Vec<EventType> {
197        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
198    }
199    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
200        let p =
201            |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
202                ParameterDefinition {
203                    name: name.into(),
204                    type_hint: kind.into(),
205                    description: description.into(),
206                    required: false,
207                    example,
208                    default,
209                }
210            };
211        vec![
212            p(
213                "scheme",
214                "string",
215                "https (BMCs) or http",
216                json!("http"),
217                Some(json!(super::DEFAULT_SCHEME)),
218            ),
219            p(
220                "username",
221                "string",
222                "Account to log in with; without it the client stays anonymous",
223                json!("admin"),
224                None,
225            ),
226            p(
227                "password",
228                "string",
229                "Password for username",
230                json!("secret"),
231                None,
232            ),
233            p(
234                "auth_method",
235                "string",
236                "session (POST SessionService/Sessions, X-Auth-Token) or basic",
237                json!("basic"),
238                Some(json!(super::DEFAULT_AUTH_METHOD)),
239            ),
240            p(
241                "insecure",
242                "boolean",
243                "Accept a self-signed BMC certificate over https",
244                json!(true),
245                Some(json!(super::DEFAULT_INSECURE)),
246            ),
247        ]
248    }
249    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
250        use crate::protocol::metadata::*;
251        ProtocolMetadataV2::builder()
252            .state(DevelopmentState::Experimental)
253            .privilege_requirement(PrivilegeRequirement::None)
254            .implementation("Shared http_fetch client (reqwest natively, no redirects); service root, session or Basic auth, OData-Version 4.0, Redfish error bodies parsed, action targets read from the resource, 202 task monitors polled to completion")
255            .llm_control("Which resources to read, what to change, which actions to invoke and what to do with each answer")
256            .e2e_testing("tests/client/redfish: DMTF Redfish-Mockup-Server 1.3.0 (independent) with its public-rackmount1 mockup: login, systems, a PATCH, a reset action, a 404")
257            .notes("No $expand/$select, ETag caching or event subscriptions. disconnect deletes the session; a client stopped from outside leaves it to expire. 1 MiB answers; task monitors are followed for up to 60 s.")
258            .max_inbound_bytes(model::MAX_BODY_BYTES)
259            .build()
260    }
261    fn example_prompt(&self) -> &'static str {
262        "Log in to the BMC at 10.0.0.5 as admin and power-cycle Systems/1"
263    }
264    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
265        let llm = json!({"type":"open_client","protocol":"redfish","remote_addr":"10.0.0.5:443","instruction":"List the systems and their power state","startup_params":{"username":"admin","password":"secret","insecure":true}});
266        let mut static_example = llm.clone();
267        static_example["event_handlers"] = json!([
268            {"event_pattern":"redfish_connected","handler":{"type":"static","actions":[get_action().example]}},
269            {"event_pattern":"redfish_response","handler":{"type":"static","actions":[]}}
270        ]);
271        let mut scripted = static_example.clone();
272        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
273        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
274    }
275    fn group_name(&self) -> &'static str {
276        "Network Management"
277    }
278}
279
280impl Client for RedfishClientProtocol {
281    fn connect(
282        &self,
283        ctx: ConnectContext,
284    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
285    {
286        Box::pin(super::connect(ctx))
287    }
288    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
289        let path = |k: &str| -> Result<()> {
290            let p = v[k].as_str().with_context(|| format!("{k} is required"))?;
291            ensure!(
292                model::normalize(p).is_some(),
293                "{k} must be a path under /redfish/v1"
294            );
295            Ok(())
296        };
297        match v["type"].as_str() {
298            Some("redfish_get" | "redfish_delete") => path("path")?,
299            Some("redfish_patch" | "redfish_post") => {
300                path("path")?;
301                ensure!(
302                    v["body"].is_object() && model::budget_ok(&v["body"]),
303                    "body must be a JSON object within bounds"
304                );
305                if let Some(e) = v.get("if_match").filter(|e| !e.is_null()) {
306                    ensure!(
307                        e.as_str().is_some_and(
308                            |e| e.len() <= 256 && !crate::utils::sanitize::has_controls(&e)
309                        ),
310                        "if_match must be text"
311                    );
312                }
313            }
314            Some("redfish_action") => {
315                path("resource_path")?;
316                let a = v["action"].as_str().context("action is required")?;
317                ensure!(
318                    a.split('.').count() == 2
319                        && a.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.'),
320                    "action is Namespace.Action, e.g. ComputerSystem.Reset"
321                );
322                if let Some(p) = v.get("parameters").filter(|p| !p.is_null()) {
323                    ensure!(
324                        p.is_object() && model::budget_ok(p),
325                        "parameters must be a JSON object"
326                    );
327                }
328            }
329            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
330            _ => bail!("Unknown Redfish client action"),
331        }
332        Ok(ClientActionResult::Custom {
333            name: v["type"].as_str().unwrap_or_default().into(),
334            data: v,
335        })
336    }
337}