Skip to main content

netget/server/snowflake/
actions.rs

1//! Snowflake protocol actions implementation
2//!
3//! Snowflake's client/driver protocol is HTTPS + JSON: the driver POSTs to a small
4//! set of REST endpoints (`/session/v1/login-request`, `/queries/v1/query-request`,
5//! `/session/logout-request`, `/session/token-request`) and reads a JSON envelope
6//! back. NetGet serves those endpoints and lets the LLM play the warehouse: it
7//! decides the session token minted at login and the rowset returned for a query.
8//!
9//! **There is no storage.** No tables, no rows, no session table in Rust. The model
10//! answers every request. Because there is no session store, the query endpoint
11//! cannot *validate* a token against issued ones — it surfaces whether an
12//! `Authorization` token was presented and leaves the decision to the model.
13
14use crate::llm::actions::{
15    protocol_trait::{ActionResult, Protocol, Server},
16    ActionDefinition, Parameter,
17};
18use crate::protocol::log_template::LogTemplate;
19use crate::protocol::EventType;
20use crate::server::connection::ConnectionId;
21use crate::state::app_state::AppState;
22use anyhow::{Context, Result};
23use serde_json::json;
24use std::sync::{Arc, LazyLock};
25use tokio::sync::mpsc;
26use tracing::debug;
27
28/// Snowflake protocol action handler.
29///
30/// Like the other database protocols this is a thin, stateless description: it
31/// carries no session table (there is no storage), only the channel used for dual
32/// logging when an action is executed.
33pub struct SnowflakeProtocol {
34    _connection_id: ConnectionId,
35    _app_state: Arc<AppState>,
36    status_tx: mpsc::UnboundedSender<String>,
37}
38
39impl SnowflakeProtocol {
40    pub fn new(
41        connection_id: ConnectionId,
42        app_state: Arc<AppState>,
43        status_tx: mpsc::UnboundedSender<String>,
44    ) -> Self {
45        Self {
46            _connection_id: connection_id,
47            _app_state: app_state,
48            status_tx,
49        }
50    }
51}
52
53impl Protocol for SnowflakeProtocol {
54    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
55        // No startup parameters: every request is answered by the model, and there
56        // is nothing to configure at spawn time. Declaring a parameter that is never
57        // read is a documented trap (the model tries to use it), so declare none.
58        vec![]
59    }
60
61    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
62        // Snowflake is purely reactive: the model answers login/query/session events.
63        vec![]
64    }
65
66    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
67        vec![
68            snowflake_login_success_action(),
69            snowflake_query_response_action(),
70            snowflake_session_response_action(),
71            snowflake_error_action(),
72        ]
73    }
74
75    fn protocol_name(&self) -> &'static str {
76        "Snowflake"
77    }
78
79    fn get_event_types(&self) -> Vec<EventType> {
80        get_snowflake_event_types()
81    }
82
83    fn stack_name(&self) -> &'static str {
84        "ETH>IP>TCP>HTTP>Snowflake"
85    }
86
87    fn keywords(&self) -> Vec<&'static str> {
88        // Distinctive, multi-word keywords — no bare "db"/"sql"/"database".
89        vec!["snowflake", "snowflake warehouse", "snowflake data cloud"]
90    }
91
92    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
93        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
94
95        ProtocolMetadataV2::builder()
96            .state(DevelopmentState::Experimental)
97            .implementation("Hand-written Snowflake REST/JSON endpoints over hyper (login, query, session)")
98            .llm_control("Session tokens at login, rowset/rowtype for queries, error envelopes")
99            .e2e_testing("tests/server/snowflake/real_client_test.rs drives the server with snowflake-connector-python 4.8.0, Snowflake's official Python driver (gzipped request bodies): a login the model grants and one it refuses (the driver raises 390100), a six-column rowset typed by the driver's own JSON converters (FIXED with a scale, BOOLEAN, DATE, TIMESTAMP_NTZ, NULL), a query error raised with the model's errno and SQLSTATE, and the driver's POST /session?delete=true logout. e2e_test.rs and llm_failure_test.rs drive the envelopes with reqwest")
100            .notes(
101                "HTTP/JSON only; TLS termination is out of scope (real drivers use HTTPS — front with a \
102                 TLS proxy if needed). One real driver so far (snowflake-connector-python over plain \
103                 HTTP); JDBC, ODBC and gosnowflake are untested, and results are JSON only (no Arrow, \
104                 no chunks). No session store, so tokens are not validated against issued ones. \
105                 Fail-closed: an LLM outage on login is a refusal (no token issued), never a \
106                 success-shaped empty result.",
107            )
108            .max_inbound_bytes(crate::server::snowflake::MAX_REQUEST_BYTES)
109            .request_only("The Snowflake API is HTTP request/response; a server cannot send a peer anything unprompted")
110            .build()
111    }
112
113    fn description(&self) -> &'static str {
114        "Snowflake data-warehouse server (REST/JSON login + query protocol)"
115    }
116
117    fn example_prompt(&self) -> &'static str {
118        "Start a Snowflake server on port 8085 that logs clients in and answers SELECT queries"
119    }
120
121    fn group_name(&self) -> &'static str {
122        "Database"
123    }
124
125    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
126        use crate::llm::actions::StartupExamples;
127
128        // Deterministic: answer every SQL query with a single-column "OK"
129        // rowset, no LLM call.
130        let script = r#"import json, sys
131data = json.load(sys.stdin)
132if data["event_type_id"] == "snowflake_query":
133    actions = [{"type": "snowflake_query_response",
134                "rowtype": [{"name": "STATUS", "type": "text"}],
135                "rowset": [["OK"]]}]
136else:
137    actions = []
138print(json.dumps({"actions": actions}))"#;
139
140        StartupExamples::new(
141            // LLM mode
142            json!({
143                "type": "open_server",
144                "port": 8085,
145                "base_stack": "snowflake",
146                "instruction": "Snowflake warehouse: accept logins and answer SQL queries with plausible rowsets"
147            }),
148            // Script mode
149            json!({
150                "type": "open_server",
151                "port": 8085,
152                "base_stack": "snowflake",
153                "event_handlers": [{
154                    "event_pattern": "snowflake_query",
155                    "handler": {
156                        "type": "script",
157                        "language": "python",
158                        "code": script
159                    }
160                }]
161            }),
162            // Static mode
163            json!({
164                "type": "open_server",
165                "port": 8085,
166                "base_stack": "snowflake",
167                "event_handlers": [{
168                    "event_pattern": "snowflake_login",
169                    "handler": {
170                        "type": "static",
171                        "actions": [{
172                            "type": "snowflake_login_success",
173                            "token": "SESSION_TOKEN_ABC",
174                            "master_token": "MASTER_TOKEN_ABC",
175                            "session_id": 100
176                        }]
177                    }
178                }]
179            }),
180        )
181    }
182}
183
184impl Server for SnowflakeProtocol {
185    fn spawn(
186        &self,
187        ctx: crate::protocol::SpawnContext,
188    ) -> std::pin::Pin<
189        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
190    > {
191        Box::pin(async move {
192            use crate::server::snowflake::SnowflakeServer;
193            SnowflakeServer::spawn_with_llm_actions(
194                ctx.legacy_listen_addr(),
195                ctx.llm_client,
196                ctx.state,
197                ctx.status_tx,
198                ctx.server_id,
199            )
200            .await
201        })
202    }
203
204    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
205        let action_type = action
206            .get("type")
207            .and_then(|v| v.as_str())
208            .context("Missing 'type' field in action")?;
209
210        match action_type {
211            "snowflake_login_success" => self.execute_login_success(action),
212            "snowflake_query_response" => self.execute_query_response(action),
213            "snowflake_session_response" => self.execute_session_response(action),
214            "snowflake_error" => self.execute_error(action),
215            _ => Err(anyhow::anyhow!("Unknown Snowflake action: {}", action_type)),
216        }
217    }
218}
219
220impl SnowflakeProtocol {
221    fn execute_login_success(&self, action: serde_json::Value) -> Result<ActionResult> {
222        let token = action
223            .get("token")
224            .and_then(|v| v.as_str())
225            .context("Missing 'token' in snowflake_login_success")?
226            .to_string();
227        let master_token = action
228            .get("master_token")
229            .and_then(|v| v.as_str())
230            .map(|s| s.to_string());
231        let session_id = action.get("session_id").cloned();
232        let validity_seconds = action.get("validity_seconds").and_then(|v| v.as_u64());
233
234        debug!("Snowflake login success: session token minted");
235        let _ = self
236            .status_tx
237            .send("[DEBUG] Snowflake → login success (token issued)".to_string());
238
239        Ok(ActionResult::Custom {
240            name: "snowflake_login_success".to_string(),
241            data: json!({
242                "token": token,
243                "master_token": master_token,
244                "session_id": session_id,
245                "validity_seconds": validity_seconds,
246            }),
247        })
248    }
249
250    fn execute_query_response(&self, action: serde_json::Value) -> Result<ActionResult> {
251        let rowtype = action
252            .get("rowtype")
253            .and_then(|v| v.as_array())
254            .context("Missing 'rowtype' array in snowflake_query_response")?;
255        let rowset = action
256            .get("rowset")
257            .and_then(|v| v.as_array())
258            .context("Missing 'rowset' array in snowflake_query_response")?;
259        let query_id = action
260            .get("query_id")
261            .and_then(|v| v.as_str())
262            .map(|s| s.to_string());
263
264        debug!(
265            "Snowflake query response: {} columns, {} rows",
266            rowtype.len(),
267            rowset.len()
268        );
269        let _ = self.status_tx.send(format!(
270            "[DEBUG] Snowflake → rowset: {} columns, {} rows",
271            rowtype.len(),
272            rowset.len()
273        ));
274
275        Ok(ActionResult::Custom {
276            name: "snowflake_query_response".to_string(),
277            data: json!({
278                "rowtype": rowtype,
279                "rowset": rowset,
280                "query_id": query_id,
281            }),
282        })
283    }
284
285    fn execute_session_response(&self, action: serde_json::Value) -> Result<ActionResult> {
286        let data = action.get("data").cloned().unwrap_or(json!({}));
287        debug!("Snowflake session response");
288        let _ = self
289            .status_tx
290            .send("[DEBUG] Snowflake → session response".to_string());
291        Ok(ActionResult::Custom {
292            name: "snowflake_session_response".to_string(),
293            data: json!({ "data": data }),
294        })
295    }
296
297    fn execute_error(&self, action: serde_json::Value) -> Result<ActionResult> {
298        let code = action
299            .get("code")
300            .and_then(|v| v.as_str())
301            .unwrap_or("000603")
302            .to_string();
303        let message = action
304            .get("message")
305            .and_then(|v| v.as_str())
306            .unwrap_or("Unknown error")
307            .to_string();
308        let sql_state = action
309            .get("sql_state")
310            .and_then(|v| v.as_str())
311            .filter(|s| !s.is_empty());
312        debug!("Snowflake error response: {} - {}", code, message);
313        let _ = self
314            .status_tx
315            .send(format!("[DEBUG] Snowflake ✗ error {}: {}", code, message));
316        let mut data = json!({ "code": code, "message": message });
317        if let Some(state) = sql_state {
318            data["sql_state"] = json!(state);
319        }
320        Ok(ActionResult::Custom {
321            name: "snowflake_error".to_string(),
322            data,
323        })
324    }
325}
326
327// ============================================================================
328// Action definitions
329// ============================================================================
330
331/// `snowflake_login_success` — issue a session token in response to a login.
332pub fn snowflake_login_success_action() -> ActionDefinition {
333    ActionDefinition {
334        name: "snowflake_login_success".to_string(),
335        description: "Authenticate the client and issue a Snowflake session token. Only emit this \
336            when the login should succeed; to refuse a login use snowflake_error instead (never \
337            leave the event unanswered — an unanswered login is treated as a refusal)."
338            .to_string(),
339        parameters: vec![
340            Parameter {
341                name: "token".to_string(),
342                type_hint: "string".to_string(),
343                description: "The session token the driver will send back as \
344                    Authorization: Snowflake Token=\"...\" on subsequent query requests."
345                    .to_string(),
346                required: true,
347            },
348            Parameter {
349                name: "master_token".to_string(),
350                type_hint: "string".to_string(),
351                description: "Master token used by the driver to renew the session (optional)."
352                    .to_string(),
353                required: false,
354            },
355            Parameter {
356                name: "session_id".to_string(),
357                type_hint: "number".to_string(),
358                description: "Numeric session id (optional)".to_string(),
359                required: false,
360            },
361            Parameter {
362                name: "validity_seconds".to_string(),
363                type_hint: "number".to_string(),
364                description: "Session token validity in seconds (optional, default 3600)"
365                    .to_string(),
366                required: false,
367            },
368        ],
369        example: json!({
370            "type": "snowflake_login_success",
371            "token": "ver:1-hint:abc-ETMsDgAAA...",
372            "master_token": "ver:1-hint:def-ETMsDgAAA...",
373            "session_id": 123456789,
374            "validity_seconds": 3600
375        }),
376        log_template: Some(
377            LogTemplate::new()
378                .with_info("-> Snowflake login OK (session {session_id})")
379                .with_debug("Snowflake snowflake_login_success: session_id={session_id}"),
380        ),
381    }
382}
383
384/// `snowflake_query_response` — return a result set for a SQL query.
385pub fn snowflake_query_response_action() -> ActionDefinition {
386    ActionDefinition {
387        name: "snowflake_query_response".to_string(),
388        description: "Return a result set for a SQL query. `rowtype` describes the columns and \
389            `rowset` is the rows. Snowflake's JSON result format transmits every value as a \
390            string, so all cell values are stringified on the wire."
391            .to_string(),
392        parameters: vec![
393            Parameter {
394                name: "rowtype".to_string(),
395                type_hint: "array".to_string(),
396                description: "Array of column descriptors. Each is an object with at least \
397                    'name' and 'type' (Snowflake logical types: text, fixed, real, boolean, date, \
398                    time, timestamp_ntz, variant, ...). Optional fields (nullable, length, \
399                    precision, scale) are passed through; sensible defaults are filled in."
400                    .to_string(),
401                required: true,
402            },
403            Parameter {
404                name: "rowset".to_string(),
405                type_hint: "array".to_string(),
406                description: "Array of rows; each row is an array of cell values matching the \
407                    column order. Values are sent as strings (Snowflake JSON result format)."
408                    .to_string(),
409                required: true,
410            },
411            Parameter {
412                name: "query_id".to_string(),
413                type_hint: "string".to_string(),
414                description: "Optional query id (UUID) echoed back to the driver".to_string(),
415                required: false,
416            },
417        ],
418        example: json!({
419            "type": "snowflake_query_response",
420            "rowtype": [
421                {"name": "ID", "type": "fixed"},
422                {"name": "NAME", "type": "text"}
423            ],
424            "rowset": [["1", "Alice"], ["2", "Bob"]],
425            "query_id": "01b2c3d4-0000-0000-0000-000000000001"
426        }),
427        log_template: Some(
428            LogTemplate::new()
429                .with_info("-> Snowflake {rowtype_len} cols, {rowset_len} rows")
430                .with_debug(
431                    "Snowflake snowflake_query_response: {rowtype_len} cols, {rowset_len} rows",
432                ),
433        ),
434    }
435}
436
437/// `snowflake_session_response` — answer a logout / token-renew request.
438pub fn snowflake_session_response_action() -> ActionDefinition {
439    ActionDefinition {
440        name: "snowflake_session_response".to_string(),
441        description: "Acknowledge a session-management request (logout or token renewal) with a \
442            success envelope. For logout, omit `data`. For token renewal, put the renewed \
443            {sessionToken, masterToken, validityInSeconds} in `data`."
444            .to_string(),
445        parameters: vec![Parameter {
446            name: "data".to_string(),
447            type_hint: "object".to_string(),
448            description: "Optional data object placed in the response 'data' field (e.g. renewed \
449                tokens for a token-request). Omit for a plain logout acknowledgement."
450                .to_string(),
451            required: false,
452        }],
453        example: json!({
454            "type": "snowflake_session_response",
455            "data": {"sessionToken": "ver:1-...", "validityInSeconds": 3600}
456        }),
457        log_template: Some(
458            LogTemplate::new()
459                .with_info("-> Snowflake session OK")
460                .with_debug("Snowflake snowflake_session_response"),
461        ),
462    }
463}
464
465/// `snowflake_error` — refuse a login/query/session with a Snowflake error envelope.
466pub fn snowflake_error_action() -> ActionDefinition {
467    ActionDefinition {
468        name: "snowflake_error".to_string(),
469        description: "Refuse the current request with a Snowflake error envelope (success:false). \
470            This is the only way to deny a login or reject a query. The client receives HTTP 200 \
471            with success:false and the code/message you provide."
472            .to_string(),
473        parameters: vec![
474            Parameter {
475                name: "code".to_string(),
476                type_hint: "string".to_string(),
477                description: "Snowflake error code string, e.g. \"390100\" (incorrect username or \
478                    password), \"390114\" (authentication token expired), \"002003\" (object does \
479                    not exist), \"001003\" (SQL compilation error)."
480                    .to_string(),
481                required: true,
482            },
483            Parameter {
484                name: "message".to_string(),
485                type_hint: "string".to_string(),
486                description: "Human-readable error message shown to the client".to_string(),
487                required: true,
488            },
489            Parameter {
490                name: "sql_state".to_string(),
491                type_hint: "string".to_string(),
492                description: "Five-character SQLSTATE for a refused query, e.g. \"42S02\" \
493                    (table not found) or \"42000\" (syntax error); drivers raise it with the \
494                    error. Ignored for a refused login."
495                    .to_string(),
496                required: false,
497            },
498        ],
499        example: json!({
500            "type": "snowflake_error",
501            "code": "390100",
502            "message": "Incorrect username or password was specified."
503        }),
504        log_template: Some(
505            LogTemplate::new()
506                .with_info("-> Snowflake ERR {code}: {message}")
507                .with_debug("Snowflake snowflake_error: code={code}, message={message}"),
508        ),
509    }
510}
511
512// ============================================================================
513// Action constants (attached to event types)
514// ============================================================================
515
516pub static SNOWFLAKE_LOGIN_SUCCESS_ACTION: LazyLock<ActionDefinition> =
517    LazyLock::new(snowflake_login_success_action);
518pub static SNOWFLAKE_QUERY_RESPONSE_ACTION: LazyLock<ActionDefinition> =
519    LazyLock::new(snowflake_query_response_action);
520pub static SNOWFLAKE_SESSION_RESPONSE_ACTION: LazyLock<ActionDefinition> =
521    LazyLock::new(snowflake_session_response_action);
522pub static SNOWFLAKE_ERROR_ACTION: LazyLock<ActionDefinition> =
523    LazyLock::new(snowflake_error_action);
524
525// ============================================================================
526// Event types
527// ============================================================================
528
529/// Emitted on `POST /session/v1/login-request` — the client is authenticating.
530pub static SNOWFLAKE_LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
531    EventType::new(
532        "snowflake_login",
533        "Snowflake client login request",
534        json!({
535            "type": "snowflake_login_success",
536            "token": "SESSION_TOKEN",
537            "session_id": 100
538        }),
539    )
540    .with_parameters(vec![
541        Parameter {
542            name: "login_name".to_string(),
543            type_hint: "string".to_string(),
544            description: "The username the client is logging in as".to_string(),
545            required: false,
546        },
547        Parameter {
548            name: "account".to_string(),
549            type_hint: "string".to_string(),
550            description: "The Snowflake account name the client is targeting".to_string(),
551            required: false,
552        },
553        Parameter {
554            name: "client_app_id".to_string(),
555            type_hint: "string".to_string(),
556            description: "The driver/app id (e.g. PythonConnector, JDBC)".to_string(),
557            required: false,
558        },
559        Parameter {
560            name: "client_app_version".to_string(),
561            type_hint: "string".to_string(),
562            description: "The driver/app version".to_string(),
563            required: false,
564        },
565        Parameter {
566            name: "has_password".to_string(),
567            type_hint: "boolean".to_string(),
568            description: "True if a password was supplied in the login request".to_string(),
569            required: false,
570        },
571    ])
572    .with_actions(vec![
573        SNOWFLAKE_LOGIN_SUCCESS_ACTION.clone(),
574        SNOWFLAKE_ERROR_ACTION.clone(),
575    ])
576    .with_log_template(
577        LogTemplate::new()
578            .with_info("Snowflake login: {login_name}@{account}")
579            .with_debug("Snowflake login request: {json_pretty(.)}"),
580    )
581});
582
583/// Emitted on `POST /queries/v1/query-request` — the client runs SQL.
584pub static SNOWFLAKE_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
585    EventType::new(
586        "snowflake_query",
587        "Snowflake SQL query request",
588        json!({
589            "type": "snowflake_query_response",
590            "rowtype": [{"name": "COL", "type": "text"}],
591            "rowset": [["value"]]
592        }),
593    )
594    .with_parameters(vec![
595        Parameter {
596            name: "sql_text".to_string(),
597            type_hint: "string".to_string(),
598            description: "The SQL statement the client wants to run".to_string(),
599            required: true,
600        },
601        Parameter {
602            name: "has_auth_token".to_string(),
603            type_hint: "boolean".to_string(),
604            description: "True if the client presented an Authorization session token. There is \
605                no session store, so the token is not validated — decide based on this signal."
606                .to_string(),
607            required: false,
608        },
609        Parameter {
610            name: "request_id".to_string(),
611            type_hint: "string".to_string(),
612            description: "The requestId query parameter, if present".to_string(),
613            required: false,
614        },
615    ])
616    .with_actions(vec![
617        SNOWFLAKE_QUERY_RESPONSE_ACTION.clone(),
618        SNOWFLAKE_ERROR_ACTION.clone(),
619    ])
620    .with_log_template(
621        LogTemplate::new()
622            .with_info("Snowflake query: {preview(sql_text,80)}")
623            .with_debug("Snowflake query: {sql_text}"),
624    )
625});
626
627/// Emitted on `POST /session/logout-request` and `POST /session/token-request`.
628pub static SNOWFLAKE_SESSION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
629    EventType::new(
630        "snowflake_session",
631        "Snowflake session management request (logout or token renewal)",
632        json!({
633            "type": "snowflake_session_response"
634        }),
635    )
636    .with_parameters(vec![
637        Parameter {
638            name: "operation".to_string(),
639            type_hint: "string".to_string(),
640            description: "Either \"logout\" or \"token_renew\"".to_string(),
641            required: true,
642        },
643        Parameter {
644            name: "has_auth_token".to_string(),
645            type_hint: "boolean".to_string(),
646            description: "True if the client presented an Authorization session token".to_string(),
647            required: false,
648        },
649    ])
650    .with_actions(vec![
651        SNOWFLAKE_SESSION_RESPONSE_ACTION.clone(),
652        SNOWFLAKE_ERROR_ACTION.clone(),
653    ])
654    .with_log_template(
655        LogTemplate::new()
656            .with_info("Snowflake session: {operation}")
657            .with_debug("Snowflake session request: {operation}"),
658    )
659});
660
661/// All Snowflake event types.
662pub fn get_snowflake_event_types() -> Vec<EventType> {
663    vec![
664        SNOWFLAKE_LOGIN_EVENT.clone(),
665        SNOWFLAKE_QUERY_EVENT.clone(),
666        SNOWFLAKE_SESSION_EVENT.clone(),
667    ]
668}