1use crate::llm::actions::protocol_trait::{ActionResult, Protocol, Server};
13use crate::llm::actions::{ActionDefinition, Parameter};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::state::app_state::AppState;
17use anyhow::Result;
18use serde_json::{json, Value};
19use std::sync::LazyLock;
20
21pub struct SparkProtocol {}
23
24impl SparkProtocol {
25 pub fn new() -> Self {
26 Self {}
27 }
28}
29
30impl Default for SparkProtocol {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36pub static SPARK_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
39 EventType::new(
40 "spark_request",
41 "Apache Spark monitoring REST API request received",
42 json!({
43 "type": "send_spark_applications",
44 "applications": [{
45 "id": "app-20161116163331-0000",
46 "name": "Spark shell",
47 "attempts": [{
48 "startTime": "2016-11-16T22:33:29.916GMT",
49 "completed": false,
50 "sparkUser": "jose",
51 "appSparkVersion": "3.5.1"
52 }]
53 }]
54 }),
55 )
56 .with_parameters(vec![
57 Parameter {
58 name: "method".to_string(),
59 type_hint: "string".to_string(),
60 description: "HTTP method (GET)".to_string(),
61 required: true,
62 },
63 Parameter {
64 name: "path".to_string(),
65 type_hint: "string".to_string(),
66 description: "Request path, e.g. /api/v1/applications/app-1/jobs".to_string(),
67 required: true,
68 },
69 Parameter {
70 name: "operation".to_string(),
71 type_hint: "string".to_string(),
72 description: "Detected operation: applications (the list), application (one, \
73 GET /api/v1/applications/{id}), jobs, stages, executors, rdd \
74 (/storage/rdd), or application_resource (any other per-application \
75 path: answer it or 404 it with send_spark_error)"
76 .to_string(),
77 required: true,
78 },
79 Parameter {
80 name: "app_id".to_string(),
81 type_hint: "string".to_string(),
82 description: "Application id from the path (for per-application operations)"
83 .to_string(),
84 required: false,
85 },
86 ])
87 .with_actions(spark_actions())
88 .with_log_template(
92 LogTemplate::new()
93 .with_info("Spark {method} {path}")
94 .with_debug("Spark {method} {path} op={operation} app={app_id}")
95 .with_trace("Spark: {json_pretty(.)}"),
96 )
97});
98
99fn spark_actions() -> Vec<ActionDefinition> {
100 vec![
101 send_spark_applications_action(),
102 send_spark_jobs_action(),
103 send_spark_stages_action(),
104 send_spark_executors_action(),
105 send_spark_rdds_action(),
106 send_spark_error_action(),
107 ]
108}
109
110fn send_spark_rdds_action() -> ActionDefinition {
111 ActionDefinition {
112 name: "send_spark_rdds".to_string(),
113 description: "Answer GET /api/v1/applications/{id}/storage/rdd (operation rdd): the \
114 application's cached RDDs. Supply the `rdds` array; the response is the \
115 array itself, and an empty array means nothing is cached."
116 .to_string(),
117 parameters: vec![Parameter {
118 name: "rdds".to_string(),
119 type_hint: "array".to_string(),
120 description: "Array of RDD storage objects. Common fields: id (number), name, \
121 numPartitions, numCachedPartitions, storageLevel (e.g. \"Memory \
122 Deserialized 1x Replicated\"), memoryUsed, diskUsed (bytes)."
123 .to_string(),
124 required: true,
125 }],
126 example: json!({
127 "type": "send_spark_rdds",
128 "rdds": [{
129 "id": 3, "name": "ParallelCollectionRDD", "numPartitions": 4,
130 "numCachedPartitions": 4, "storageLevel": "Memory Deserialized 1x Replicated",
131 "memoryUsed": 2048, "diskUsed": 0
132 }]
133 }),
134 log_template: Some(LogTemplate::new().with_info("-> Spark {rdds_len} cached RDDs")),
135 }
136}
137
138fn send_spark_applications_action() -> ActionDefinition {
139 ActionDefinition {
140 name: "send_spark_applications".to_string(),
141 description: "Answer GET /api/v1/applications (list) or GET /api/v1/applications/{id} \
142 (single). Supply the `applications` array; the response is the array \
143 itself (Spark returns a bare JSON array). For the single-app endpoint \
144 (operation application) return exactly one element; it is sent as the \
145 bare object, as Spark sends it."
146 .to_string(),
147 parameters: vec![Parameter {
148 name: "applications".to_string(),
149 type_hint: "array".to_string(),
150 description: "Array of application objects. Each: id (e.g. \
151 app-20161116163331-0000), name, and an `attempts` array whose \
152 elements carry startTime, endTime, lastUpdated, duration, sparkUser, \
153 completed (bool), appSparkVersion."
154 .to_string(),
155 required: true,
156 }],
157 example: json!({
158 "type": "send_spark_applications",
159 "applications": [{
160 "id": "app-20161116163331-0000", "name": "Spark shell",
161 "attempts": [{
162 "startTime": "2016-11-16T22:33:29.916GMT",
163 "endTime": "1969-12-31T23:59:59.999GMT",
164 "duration": 0, "sparkUser": "jose", "completed": false,
165 "appSparkVersion": "3.5.1"
166 }]
167 }]
168 }),
169 log_template: Some(
170 LogTemplate::new().with_info("-> Spark {applications_len} applications"),
171 ),
172 }
173}
174
175fn send_spark_jobs_action() -> ActionDefinition {
176 ActionDefinition {
177 name: "send_spark_jobs".to_string(),
178 description: "Answer GET /api/v1/applications/{id}/jobs. Supply the `jobs` array; the \
179 response is the array itself."
180 .to_string(),
181 parameters: vec![Parameter {
182 name: "jobs".to_string(),
183 type_hint: "array".to_string(),
184 description: "Array of job objects. Common fields: jobId (number), name, \
185 submissionTime, completionTime, stageIds (array of numbers), status \
186 (RUNNING/SUCCEEDED/FAILED/UNKNOWN), numTasks, numActiveTasks, \
187 numCompletedTasks, numSkippedTasks, numFailedTasks, numActiveStages, \
188 numCompletedStages, numFailedStages."
189 .to_string(),
190 required: true,
191 }],
192 example: json!({
193 "type": "send_spark_jobs",
194 "jobs": [{
195 "jobId": 0, "name": "count at <console>:15", "status": "SUCCEEDED",
196 "stageIds": [0], "numTasks": 8, "numActiveTasks": 0, "numCompletedTasks": 8,
197 "numFailedTasks": 0, "numActiveStages": 0, "numCompletedStages": 1,
198 "numFailedStages": 0
199 }]
200 }),
201 log_template: Some(LogTemplate::new().with_info("-> Spark {jobs_len} jobs")),
202 }
203}
204
205fn send_spark_stages_action() -> ActionDefinition {
206 ActionDefinition {
207 name: "send_spark_stages".to_string(),
208 description: "Answer GET /api/v1/applications/{id}/stages. Supply the `stages` array; \
209 the response is the array itself."
210 .to_string(),
211 parameters: vec![Parameter {
212 name: "stages".to_string(),
213 type_hint: "array".to_string(),
214 description: "Array of stage objects. Common fields: status \
215 (ACTIVE/COMPLETE/PENDING/FAILED/SKIPPED), stageId (number), attemptId \
216 (number), name, numTasks, numActiveTasks, numCompleteTasks, \
217 numFailedTasks, inputBytes, outputBytes, shuffleReadBytes, \
218 shuffleWriteBytes."
219 .to_string(),
220 required: true,
221 }],
222 example: json!({
223 "type": "send_spark_stages",
224 "stages": [{
225 "status": "COMPLETE", "stageId": 0, "attemptId": 0, "numTasks": 8,
226 "numActiveTasks": 0, "numCompleteTasks": 8, "numFailedTasks": 0,
227 "name": "count at <console>:15"
228 }]
229 }),
230 log_template: Some(LogTemplate::new().with_info("-> Spark {stages_len} stages")),
231 }
232}
233
234fn send_spark_executors_action() -> ActionDefinition {
235 ActionDefinition {
236 name: "send_spark_executors".to_string(),
237 description: "Answer GET /api/v1/applications/{id}/executors. Supply the `executors` \
238 array; the response is the array itself. There is always at least the \
239 `driver` executor."
240 .to_string(),
241 parameters: vec![Parameter {
242 name: "executors".to_string(),
243 type_hint: "array".to_string(),
244 description: "Array of executor objects. Common fields: id (\"driver\" or a number \
245 as string), hostPort, isActive (bool), rddBlocks, memoryUsed, \
246 diskUsed, totalCores, maxTasks, activeTasks, failedTasks, \
247 completedTasks, totalTasks, totalDuration, maxMemory."
248 .to_string(),
249 required: true,
250 }],
251 example: json!({
252 "type": "send_spark_executors",
253 "executors": [{
254 "id": "driver", "hostPort": "10.0.0.1:57971", "isActive": true, "rddBlocks": 0,
255 "memoryUsed": 0, "diskUsed": 0, "totalCores": 8, "maxTasks": 8, "activeTasks": 0,
256 "failedTasks": 0, "completedTasks": 16, "totalTasks": 16, "maxMemory": 384093388
257 }]
258 }),
259 log_template: Some(LogTemplate::new().with_info("-> Spark {executors_len} executors")),
260 }
261}
262
263fn send_spark_error_action() -> ActionDefinition {
264 ActionDefinition {
265 name: "send_spark_error".to_string(),
266 description: "Return an error with an explicit HTTP status. Spark's REST API sends error \
267 bodies as plain text (e.g. 404 \"unknown app: app-1\"); this action does \
268 the same. status must be 100-599."
269 .to_string(),
270 parameters: vec![
271 Parameter {
272 name: "status".to_string(),
273 type_hint: "number".to_string(),
274 description: "HTTP status code (400, 404, 500, ...)".to_string(),
275 required: true,
276 },
277 Parameter {
278 name: "message".to_string(),
279 type_hint: "string".to_string(),
280 description: "Plain-text error message".to_string(),
281 required: true,
282 },
283 ],
284 example: json!({
285 "type": "send_spark_error",
286 "status": 404,
287 "message": "unknown app: app-does-not-exist"
288 }),
289 log_template: Some(LogTemplate::new().with_info("-> Spark error {status}")),
290 }
291}
292
293pub fn get_spark_event_types() -> Vec<EventType> {
294 vec![SPARK_REQUEST_EVENT.clone()]
295}
296
297fn array_body(action: &Value, key: &str) -> Result<Value> {
298 action
299 .get(key)
300 .filter(|v| v.is_array())
301 .cloned()
302 .ok_or_else(|| anyhow::anyhow!("Missing '{key}' array"))
303}
304
305fn parse_status(action: &Value) -> Result<u16> {
306 let raw = action
307 .get("status")
308 .and_then(|v| v.as_u64())
309 .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'status': expected a number"))?;
310 if !(100..=599).contains(&raw) {
311 return Err(anyhow::anyhow!(
312 "Invalid status {raw}: must be an HTTP status between 100 and 599"
313 ));
314 }
315 Ok(raw as u16)
316}
317
318impl Protocol for SparkProtocol {
319 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
320 use crate::llm::actions::ParameterDefinition;
321 vec![ParameterDefinition {
322 name: "spark_version".to_string(),
323 type_hint: "string".to_string(),
324 description: "Version string reported by the static GET /api/v1/version banner. \
325 Default 3.5.1."
326 .to_string(),
327 required: false,
328 example: json!("3.5.1"),
329 default: None,
330 }]
331 }
332 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
333 vec![]
334 }
335 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
336 spark_actions()
337 }
338 fn protocol_name(&self) -> &'static str {
339 "Spark"
340 }
341 fn get_event_types(&self) -> Vec<EventType> {
342 get_spark_event_types()
343 }
344 fn stack_name(&self) -> &'static str {
345 "ETH>IP>TCP>HTTP>SPARK"
346 }
347 fn keywords(&self) -> Vec<&'static str> {
348 vec!["spark", "apache spark", "spark rest api"]
349 }
350 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
351 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
352 ProtocolMetadataV2::builder()
353 .state(DevelopmentState::Experimental)
354 .well_known_port(4040)
355 .implementation("hyper v1 HTTP/1.1 server, manual Spark monitoring REST arrays")
356 .llm_control("Applications, jobs, stages, executors and cached RDDs invented by the LLM")
357 .e2e_testing(
358 "tests/server/spark/datadog_check_test.rs runs Datadog's Spark integration \
359 (datadog_checks.spark, integrations-core tag spark-7.8.0) once in driver mode \
360 against the server and asserts the metrics, tags, service checks and version it \
361 submitted from the model's answers (applications, jobs, stages, executors, \
362 storage/rdd, a streaming-statistics 404). e2e_test.rs checks the documented \
363 shapes with reqwest. NetGet's own Spark client is tested against real Spark, \
364 not against this server",
365 )
366 .notes(
367 "Virtual application, no storage. GET /api/v1/version is answered \
368 statically; applications/jobs/stages/executors/storage-rdd are LLM-driven, \
369 and GET /api/v1/applications/{id} is sent as the one object Spark sends. \
370 Fail-closed: LLM failure returns 503/500 JSON error, never an empty-but-200 \
371 array. Experimental: one independent consumer (Datadog's check), which reads \
372 the list endpoints only; nothing has yet driven the per-job, per-stage, \
373 environment or SQL endpoints, which reach the model as \
374 application_resource.",
375 )
376 .max_inbound_bytes(crate::server::spark::MAX_REQUEST_BYTES)
377 .request_only("The Spark REST API is HTTP request/response; a server cannot send a peer anything unprompted")
378 .build()
379 }
380 fn description(&self) -> &'static str {
381 "Apache Spark monitoring REST / history API"
382 }
383 fn example_prompt(&self) -> &'static str {
384 "Start an Apache Spark REST API on port 4040 reporting one running application"
385 }
386 fn group_name(&self) -> &'static str {
387 "Application"
388 }
389
390 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
391 use crate::llm::actions::StartupExamples;
392
393 let script = r#"import json, sys
396data = json.load(sys.stdin)
397if data["event_type_id"] == "spark_request":
398 actions = [{"type": "send_spark_applications",
399 "applications": [{"id": "app-netget-0000", "name": "netget-app",
400 "attempts": [{"completed": False, "appSparkVersion": "3.5.1"}]}]}]
401else:
402 actions = []
403print(json.dumps({"actions": actions}))"#;
404
405 StartupExamples::new(
406 json!({
407 "type": "open_server",
408 "port": 4040,
409 "base_stack": "spark",
410 "instruction": "Apache Spark driver monitoring API with one running job"
411 }),
412 json!({
413 "type": "open_server",
414 "port": 4040,
415 "base_stack": "spark",
416 "event_handlers": [{
417 "event_pattern": "spark_request",
418 "handler": { "type": "script", "language": "python", "code": script }
419 }]
420 }),
421 json!({
422 "type": "open_server",
423 "port": 4040,
424 "base_stack": "spark",
425 "event_handlers": [{
426 "event_pattern": "spark_request",
427 "handler": {
428 "type": "static",
429 "actions": [{
430 "type": "send_spark_applications",
431 "applications": [{
432 "id": "app-netget-0000", "name": "netget-app",
433 "attempts": [{ "completed": false, "appSparkVersion": "3.5.1" }]
434 }]
435 }]
436 }
437 }]
438 }),
439 )
440 }
441}
442
443impl Server for SparkProtocol {
444 fn spawn(
445 &self,
446 ctx: crate::protocol::SpawnContext,
447 ) -> std::pin::Pin<
448 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
449 > {
450 Box::pin(async move {
451 let spark_version = ctx
452 .startup_params
453 .as_ref()
454 .and_then(|p| p.get_optional_string("spark_version").ok().flatten())
455 .unwrap_or_else(|| "3.5.1".to_string());
456
457 crate::server::spark::SparkServer::spawn_with_llm_actions(
458 ctx.legacy_listen_addr(),
459 ctx.llm_client,
460 ctx.state,
461 ctx.status_tx,
462 ctx.server_id,
463 spark_version,
464 )
465 .await
466 })
467 }
468
469 fn execute_action(&self, action: Value) -> Result<ActionResult> {
470 let action_type = action
471 .get("type")
472 .and_then(|v| v.as_str())
473 .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
474
475 let json_array = |status: u16, body: Value| {
477 Ok(ActionResult::Custom {
478 name: "spark_response".to_string(),
479 data: json!({
480 "status": status,
481 "body": serde_json::to_string(&body).unwrap_or_else(|_| "[]".to_string()),
482 "content_type": "application/json",
483 }),
484 })
485 };
486
487 match action_type {
488 "send_spark_applications" => json_array(200, array_body(&action, "applications")?),
489 "send_spark_jobs" => json_array(200, array_body(&action, "jobs")?),
490 "send_spark_stages" => json_array(200, array_body(&action, "stages")?),
491 "send_spark_executors" => json_array(200, array_body(&action, "executors")?),
492 "send_spark_rdds" => json_array(200, array_body(&action, "rdds")?),
493 "send_spark_error" => {
494 let status = parse_status(&action)?;
495 let message = action
496 .get("message")
497 .and_then(|v| v.as_str())
498 .ok_or_else(|| anyhow::anyhow!("Missing 'message'"))?;
499 Ok(ActionResult::Custom {
500 name: "spark_response".to_string(),
501 data: json!({
502 "status": status,
503 "body": message,
504 "content_type": "text/plain",
505 }),
506 })
507 }
508 _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
509 }
510 }
511}