Skip to main content

netget/server/etcd/
actions.rs

1//! etcd protocol action definitions
2
3use 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
14/// etcd protocol handler
15pub struct EtcdProtocol {}
16
17impl EtcdProtocol {
18    pub fn new() -> Self {
19        Self {}
20    }
21}
22
23// Event type IDs
24pub 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
162// Action definitions
163fn 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
307// Implement Protocol trait (common functionality)
308impl Protocol for EtcdProtocol {
309    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
310        // Only parameters the server actually reads are declared. `initial_cluster_state` and
311        // `max_keys` used to be declared here and were never read; `max_keys` in particular
312        // advertised a key store this protocol does not and must not have.
313        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            // Beta, restored September 2026 -- and on different evidence than the rating it
349            // lost. The old Beta rested on `etcd-client` alone, which is tonic-based and
350            // accepts a gRPC status wherever it finds one; pointing etcd's own Go client at
351            // this server showed no grpc-go client could complete a single RPC. That bug is
352            // fixed (the status is in trailing HEADERS now) and BOTH clients are asserted, so
353            // the `mysql`/mysql_async shape -- one lenient client agreeing with one bug -- no
354            // longer applies.
355            .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        // Deterministic: answer every range read with an empty key set, no LLM
430        // call.
431        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            // LLM mode: LLM handles all etcd responses intelligently
442            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            // Script mode: Code-based deterministic responses
449            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            // Static mode: Fixed responses
463            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
484// Implement Server trait (server-specific functionality)
485impl 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        // Every declared action must be listed here. `etcd_put_response` and
512        // `etcd_delete_range_response` were read by handle_put/handle_delete_range in mod.rs
513        // but rejected here as unknown, so the model could never actually produce them: a Put
514        // could not set its revision and a DeleteRange always reported "deleted 0".
515        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}