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}