1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::Result;
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13
14pub struct EtcdProtocol {}
16
17impl EtcdProtocol {
18 pub fn new() -> Self {
19 Self {}
20 }
21}
22
23pub static ETCD_RANGE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
25 EventType::new(
26 "etcd_range_request",
27 "Triggered when a client sends a Range (get) request to query keys",
28 json!({
29 "type": "etcd_range_response",
30 "kvs": [
31 {"key": "foo", "value": "bar", "create_revision": 1, "mod_revision": 1, "version": 1, "lease": 0}
32 ],
33 "more": false,
34 "count": 1
35 }),
36 )
37 .with_parameters(vec![
38 Parameter {
39 name: "key".to_string(),
40 type_hint: "string".to_string(),
41 description: "Key to query".to_string(),
42 required: true,
43 },
44 Parameter {
45 name: "range_end".to_string(),
46 type_hint: "string".to_string(),
47 description: "End of key range (for prefix/range queries)".to_string(),
48 required: false,
49 },
50 Parameter {
51 name: "limit".to_string(),
52 type_hint: "number".to_string(),
53 description: "Maximum number of keys to return".to_string(),
54 required: false,
55 },
56 ])
57 .with_actions(vec![etcd_range_response_action(), etcd_error_action()])
58});
59
60pub static ETCD_PUT_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
61 EventType::new(
62 "etcd_put_request",
63 "Triggered when a client sends a Put request to store a key-value pair",
64 json!({
65 "type": "etcd_put_response",
66 "revision": 2
67 }),
68 )
69 .with_parameters(vec![
70 Parameter {
71 name: "key".to_string(),
72 type_hint: "string".to_string(),
73 description: "Key being written".to_string(),
74 required: true,
75 },
76 Parameter {
77 name: "value".to_string(),
78 type_hint: "string".to_string(),
79 description: "Value being written".to_string(),
80 required: true,
81 },
82 Parameter {
83 name: "lease".to_string(),
84 type_hint: "number".to_string(),
85 description: "Lease ID attached to the key (0 = no lease)".to_string(),
86 required: false,
87 },
88 ])
89 .with_actions(vec![etcd_put_response_action(), etcd_error_action()])
90});
91
92pub static ETCD_DELETE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
93 EventType::new(
94 "etcd_delete_request",
95 "Triggered when a client sends a DeleteRange request",
96 json!({
97 "type": "etcd_delete_range_response",
98 "deleted": 1
99 }),
100 )
101 .with_parameters(vec![
102 Parameter {
103 name: "key".to_string(),
104 type_hint: "string".to_string(),
105 description: "Key to delete".to_string(),
106 required: true,
107 },
108 Parameter {
109 name: "range_end".to_string(),
110 type_hint: "string".to_string(),
111 description: "End of key range for prefix/range deletes".to_string(),
112 required: false,
113 },
114 ])
115 .with_actions(vec![
116 etcd_delete_range_response_action(),
117 etcd_error_action(),
118 ])
119});
120
121pub static ETCD_TXN_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
122 EventType::new(
123 "etcd_txn_request",
124 "Triggered when a client sends a transaction request",
125 json!({
126 "type": "etcd_txn_response",
127 "succeeded": true
128 }),
129 )
130 .with_parameters(vec![
131 Parameter {
132 name: "compare_count".to_string(),
133 type_hint: "number".to_string(),
134 description: "Number of comparison predicates in the transaction".to_string(),
135 required: true,
136 },
137 Parameter {
138 name: "success_count".to_string(),
139 type_hint: "number".to_string(),
140 description: "Number of operations to run if the comparisons hold".to_string(),
141 required: true,
142 },
143 Parameter {
144 name: "failure_count".to_string(),
145 type_hint: "number".to_string(),
146 description: "Number of operations to run if they do not".to_string(),
147 required: true,
148 },
149 Parameter {
150 name: "compares".to_string(),
151 type_hint: "array".to_string(),
152 description: "Comparison predicates as objects with 'key', 'target' \
153 (VERSION/CREATE/MOD/VALUE), 'result' (EQUAL/GREATER/LESS/NOT_EQUAL) \
154 and 'value' fields"
155 .to_string(),
156 required: true,
157 },
158 ])
159 .with_actions(vec![etcd_txn_response_action(), etcd_error_action()])
160});
161
162fn etcd_range_response_action() -> ActionDefinition {
164 ActionDefinition {
165 name: "etcd_range_response".to_string(),
166 description: "Return key-value pairs for a Range request".to_string(),
167 parameters: vec![
168 Parameter {
169 name: "kvs".to_string(),
170 type_hint: "array".to_string(),
171 description: "Array of key-value objects with 'key', 'value', 'create_revision', 'mod_revision', 'version', 'lease' fields".to_string(),
172 required: true,
173 },
174 Parameter {
175 name: "more".to_string(),
176 type_hint: "boolean".to_string(),
177 description: "Whether there are more keys to return".to_string(),
178 required: false,
179 },
180 Parameter {
181 name: "count".to_string(),
182 type_hint: "number".to_string(),
183 description: "Total count of keys matching the range".to_string(),
184 required: false,
185 },
186 ],
187 example: json!({
188 "type": "etcd_range_response",
189 "kvs": [
190 {"key": "foo", "value": "bar", "create_revision": 1, "mod_revision": 1, "version": 1, "lease": 0}
191 ],
192 "more": false,
193 "count": 1
194 }),
195 log_template: Some(
196 LogTemplate::new()
197 .with_info("-> etcd range response ({kvs_len} keys)")
198 .with_debug("etcd etcd_range_response: keys={kvs_len} more={more}"),
199 ),
200 }
201}
202
203fn etcd_put_response_action() -> ActionDefinition {
204 ActionDefinition {
205 name: "etcd_put_response".to_string(),
206 description: "Acknowledge a Put. The server increments its own revision counter if you \
207 omit one."
208 .to_string(),
209 parameters: vec![Parameter {
210 name: "revision".to_string(),
211 type_hint: "number".to_string(),
212 description: "Revision this write is recorded at. Omit to let the server increment."
213 .to_string(),
214 required: false,
215 }],
216 example: json!({
217 "type": "etcd_put_response",
218 "revision": 2
219 }),
220 log_template: Some(
221 LogTemplate::new()
222 .with_info("-> etcd put ok (rev {revision})")
223 .with_debug("etcd etcd_put_response: revision={revision}"),
224 ),
225 }
226}
227
228fn etcd_delete_range_response_action() -> ActionDefinition {
229 ActionDefinition {
230 name: "etcd_delete_range_response".to_string(),
231 description: "Acknowledge a DeleteRange, reporting how many keys were removed".to_string(),
232 parameters: vec![Parameter {
233 name: "deleted".to_string(),
234 type_hint: "number".to_string(),
235 description: "Number of keys deleted. etcdctl prints this as 'deleted N'.".to_string(),
236 required: true,
237 }],
238 example: json!({
239 "type": "etcd_delete_range_response",
240 "deleted": 1
241 }),
242 log_template: Some(
243 LogTemplate::new()
244 .with_info("-> etcd deleted {deleted} key(s)")
245 .with_debug("etcd etcd_delete_range_response: deleted={deleted}"),
246 ),
247 }
248}
249
250fn etcd_txn_response_action() -> ActionDefinition {
251 ActionDefinition {
252 name: "etcd_txn_response".to_string(),
253 description: "Report whether a transaction's comparisons held. The per-operation results \
254 are returned empty: nested Range/Put/Delete results inside a Txn are not \
255 implemented."
256 .to_string(),
257 parameters: vec![Parameter {
258 name: "succeeded".to_string(),
259 type_hint: "boolean".to_string(),
260 description: "True if the comparisons held and the success branch was taken"
261 .to_string(),
262 required: true,
263 }],
264 example: json!({
265 "type": "etcd_txn_response",
266 "succeeded": true
267 }),
268 log_template: Some(
269 LogTemplate::new()
270 .with_info("-> etcd txn succeeded={succeeded}")
271 .with_debug("etcd etcd_txn_response: succeeded={succeeded}"),
272 ),
273 }
274}
275
276fn etcd_error_action() -> ActionDefinition {
277 ActionDefinition {
278 name: "etcd_error".to_string(),
279 description: "Return an error response".to_string(),
280 parameters: vec![
281 Parameter {
282 name: "code".to_string(),
283 type_hint: "string".to_string(),
284 description: "Error code (e.g., 'KEY_NOT_FOUND', 'INVALID_ARGUMENT')".to_string(),
285 required: true,
286 },
287 Parameter {
288 name: "message".to_string(),
289 type_hint: "string".to_string(),
290 description: "Error message".to_string(),
291 required: true,
292 },
293 ],
294 example: json!({
295 "type": "etcd_error",
296 "code": "KEY_NOT_FOUND",
297 "message": "etcdserver: key not found"
298 }),
299 log_template: Some(
300 LogTemplate::new()
301 .with_info("-> etcd error {code}: {message}")
302 .with_debug("etcd etcd_error: code={code} message={message}"),
303 ),
304 }
305}
306
307impl Protocol for EtcdProtocol {
309 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
310 vec![ParameterDefinition {
314 name: "cluster_name".to_string(),
315 type_hint: "string".to_string(),
316 description: "Cluster identifier name, used in log lines (default: netget-cluster)"
317 .to_string(),
318 required: false,
319 example: json!("my-cluster"),
320 default: None,
321 }]
322 }
323 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
324 vec![]
325 }
326 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
327 vec![
328 etcd_range_response_action(),
329 etcd_put_response_action(),
330 etcd_delete_range_response_action(),
331 etcd_txn_response_action(),
332 etcd_error_action(),
333 ]
334 }
335 fn protocol_name(&self) -> &'static str {
336 "etcd"
337 }
338 fn stack_name(&self) -> &'static str {
339 "ETH>IP>TCP>GRPC>ETCD"
340 }
341 fn keywords(&self) -> Vec<&'static str> {
342 vec!["etcd", "etcd3", "etcdv3", "etcd server"]
343 }
344 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
345 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
346
347 ProtocolMetadataV2::builder()
348 .state(DevelopmentState::Beta)
356 .well_known_port(2379)
357 .implementation(
358 "hyper HTTP/2 + prost, hand-routed gRPC; etcd protobuf schemas compiled by \
359 build.rs. tonic is a dependency but this server does not use it.",
360 )
361 .llm_control(
362 "Range, Put, DeleteRange and Txn. Compact is answered without consulting the \
363 handler; a Txn's nested operations are not executed, only its outcome.",
364 )
365 .e2e_testing(
366 "TWO independent clients, neither #[ignore]d and neither skipping when \
367 anything is missing. etcd-client (the official Rust client, tonic) in \
368 tests/server/etcd/e2e_test.rs completes put, get, prefix-get and delete and \
369 asserts on the decoded key/value pairs. The real etcdctl binary (grpc-go, the \
370 reference implementation) in tests/server/etcd/real_client_test.rs does the \
371 same four verbs through the CLI and asserts on what it printed; it hard-fails \
372 when etcdctl is absent rather than skipping. \
373 The second client is there because the first was not enough: until September \
374 2026 a successful reply put `grpc-status` in the INITIAL HEADERS and then sent \
375 a DATA frame, so the stream ended with no trailing HEADERS. tonic tolerated \
376 that; grpc-go answered `server closed the stream without sending trailers` to \
377 every RPC carrying a body -- the first Put included. Error replies were \
378 unaffected because they are Trailers-Only by construction, which is precisely \
379 why the whole suite passed. The success path now emits real HTTP/2 trailers \
380 and the error path stays Trailers-Only, and removing either is a red test. \
381 UNPROVEN: Watch, Lease, Auth, Cluster and Maintenance (none are routed -- they \
382 return UNIMPLEMENTED), Txn's nested operations, and binary keys or values, \
383 which cross the action boundary as lossy UTF-8.",
384 )
385 .notes(
386 "KV service only: no Watch, Lease, Auth, Cluster or Maintenance. No storage - \
387 the handler answers every request; the server keeps only a revision counter. \
388 Keys and values cross the action boundary as UTF-8 strings, so binary keys \
389 are lossy.",
390 )
391 .max_inbound_bytes(crate::server::etcd::MAX_REQUEST_BYTES)
392 .build()
393 }
394 fn description(&self) -> &'static str {
395 "etcd v3 distributed key-value store server"
396 }
397 fn get_event_types(&self) -> Vec<EventType> {
398 vec![
399 (*ETCD_RANGE_REQUEST_EVENT).clone(),
400 (*ETCD_PUT_REQUEST_EVENT).clone(),
401 (*ETCD_DELETE_REQUEST_EVENT).clone(),
402 (*ETCD_TXN_REQUEST_EVENT).clone(),
403 ]
404 }
405 fn example_prompt(&self) -> &'static str {
406 r#"listen on port 2379 via etcd
407
408 Store configuration under /config/ prefix.
409 When clients PUT /config/database with value "localhost:5432", store it (revision 1).
410 When clients GET /config/database, return "localhost:5432" with revision metadata.
411 For unknown keys, return empty kvs array.
412
413 Examples:
414 - PUT /config/timeout = "30" → Success, increment revision
415 - GET /config/timeout → Return "30" with create_revision, mod_revision, version
416 - DELETE /config/timeout → Remove key, return deleted count
417 - Range query /config/ → Return all keys with /config/ prefix
418
419 Track revisions for MVCC."#
420 }
421 fn group_name(&self) -> &'static str {
422 "Database"
423 }
424
425 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
426 use crate::llm::actions::StartupExamples;
427 use serde_json::json;
428
429 let script = r#"import json, sys
432data = json.load(sys.stdin)
433event = data["event"]
434if data["event_type_id"] == "etcd_range_request":
435 actions = [{"type": "etcd_range_response", "kvs": [], "count": 0}]
436else:
437 actions = []
438print(json.dumps({"actions": actions}))"#;
439
440 StartupExamples::new(
441 json!({
443 "type": "open_server",
444 "port": 2379,
445 "base_stack": "etcd",
446 "instruction": "etcd v3 key-value store handling Range, Put, Delete operations"
447 }),
448 json!({
450 "type": "open_server",
451 "port": 2379,
452 "base_stack": "etcd",
453 "event_handlers": [{
454 "event_pattern": "etcd_range_request",
455 "handler": {
456 "type": "script",
457 "language": "python",
458 "code": script
459 }
460 }]
461 }),
462 json!({
464 "type": "open_server",
465 "port": 2379,
466 "base_stack": "etcd",
467 "event_handlers": [{
468 "event_pattern": "etcd_range_request",
469 "handler": {
470 "type": "static",
471 "actions": [{
472 "type": "etcd_range_response",
473 "kvs": [],
474 "more": false,
475 "count": 0
476 }]
477 }
478 }]
479 }),
480 )
481 }
482}
483
484impl Server for EtcdProtocol {
486 fn spawn(
487 &self,
488 ctx: crate::protocol::SpawnContext,
489 ) -> std::pin::Pin<
490 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
491 > {
492 Box::pin(async move {
493 use crate::server::etcd::EtcdServer;
494 EtcdServer::spawn_with_llm_actions(
495 ctx.legacy_listen_addr(),
496 ctx.llm_client,
497 ctx.state,
498 ctx.status_tx,
499 ctx.server_id,
500 ctx.startup_params,
501 )
502 .await
503 })
504 }
505 fn execute_action(&self, action: Value) -> Result<ActionResult> {
506 let action_type = action
507 .get("type")
508 .and_then(|v| v.as_str())
509 .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
510
511 match action_type {
516 "etcd_range_response"
517 | "etcd_put_response"
518 | "etcd_delete_range_response"
519 | "etcd_txn_response"
520 | "etcd_error" => Ok(ActionResult::Custom {
521 name: action_type.to_string(),
522 data: action,
523 }),
524 _ => anyhow::bail!("Unknown etcd action type: {}", action_type),
525 }
526 }
527}