Skip to main content

omni_dev/daemon/services/
snowflake.rs

1//! The Snowflake daemon service.
2//!
3//! A thin adapter that hosts the account-agnostic [`SnowflakeEngine`] under the
4//! daemon's lifecycle and exposes query/sessions/disconnect over the control
5//! socket, plus a tray submenu.
6//!
7//! All real work (lazy multiplexed auth, per-query `USE …`, heartbeats, the
8//! arbitrary-schema → JSON mapping) lives in [`crate::snowflake`]; this adapter
9//! only routes ops and renders the menu/status. Unlike the bridge it persists no
10//! secret to disk — sessions live only in memory.
11
12use anyhow::{anyhow, bail, Context, Result};
13use async_trait::async_trait;
14use chrono::Utc;
15use serde_json::{json, Value};
16
17use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
18use crate::snowflake::session::SessionInfo;
19use crate::snowflake::{QueryRequest, SnowflakeEngine, SnowflakeEngineConfig};
20
21/// The Snowflake service name (the control-socket routing key).
22pub const SERVICE_NAME: &str = "snowflake";
23
24/// Hosts a [`SnowflakeEngine`] as a [`DaemonService`].
25pub struct SnowflakeService {
26    engine: SnowflakeEngine,
27}
28
29impl SnowflakeService {
30    /// Creates the service. Cheap — no eager auth or I/O; each `(account, user)`
31    /// session is authenticated lazily on its first query.
32    #[must_use]
33    pub fn new(config: SnowflakeEngineConfig) -> Self {
34        Self {
35            engine: SnowflakeEngine::new(config),
36        }
37    }
38}
39
40#[async_trait]
41impl DaemonService for SnowflakeService {
42    fn name(&self) -> &'static str {
43        SERVICE_NAME
44    }
45
46    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
47        match op {
48            "query" => {
49                let req: QueryRequest =
50                    serde_json::from_value(payload).context("invalid `query` payload")?;
51                if req.sql.trim().is_empty() {
52                    bail!("`query` requires a non-empty `sql`");
53                }
54                self.engine.query(req).await
55            }
56            "sessions" => Ok(json!({ "sessions": self.engine.sessions() })),
57            "disconnect" => {
58                let account = payload
59                    .get("account")
60                    .and_then(Value::as_str)
61                    .ok_or_else(|| anyhow!("`disconnect` requires `account`"))?;
62                let user = payload
63                    .get("user")
64                    .and_then(Value::as_str)
65                    .ok_or_else(|| anyhow!("`disconnect` requires `user`"))?;
66                Ok(json!({ "disconnected": self.engine.disconnect(account, user) }))
67            }
68            other => bail!("unknown snowflake op: {other}"),
69        }
70    }
71
72    fn menu(&self) -> MenuSnapshot {
73        let sessions = self.engine.sessions();
74        let items = if sessions.is_empty() {
75            vec![MenuItem::Label("No sessions".to_string())]
76        } else {
77            session_menu_items(&sessions)
78        };
79        MenuSnapshot {
80            title: "Snowflake".to_string(),
81            items,
82        }
83    }
84
85    async fn menu_action(&self, action_id: &str) -> Result<()> {
86        if action_id == "disconnect-all" {
87            self.engine.disconnect_all();
88            return Ok(());
89        }
90        if let Some(id) = action_id.strip_prefix("disconnect:") {
91            let id: u64 = id
92                .parse()
93                .with_context(|| format!("invalid session id in action {action_id}"))?;
94            self.engine.disconnect_by_id(id);
95            return Ok(());
96        }
97        bail!("unknown snowflake menu action: {action_id}")
98    }
99
100    async fn status(&self) -> ServiceStatus {
101        let sessions = self.engine.sessions();
102        let live: usize = sessions.iter().map(|s| s.sessions).sum();
103        ServiceStatus {
104            name: SERVICE_NAME.to_string(),
105            healthy: true,
106            summary: format!("{} pool(s), {live} session(s)", sessions.len()),
107            detail: json!({ "sessions": sessions }),
108        }
109    }
110
111    async fn shutdown(&self) {
112        self.engine.shutdown().await;
113    }
114}
115
116/// Builds the tray items for a non-empty session list: a label per pool, an
117/// indented label per authenticated session (with what it's doing), a separator,
118/// and the per-pool + "Disconnect all" actions.
119fn session_menu_items(sessions: &[SessionInfo]) -> Vec<MenuItem> {
120    let mut items = Vec::new();
121    for session in sessions {
122        items.push(MenuItem::Label(format!(
123            "{} · {} · {}/{} sessions · {} queries",
124            session.account,
125            session.user,
126            session.sessions,
127            session.max_sessions,
128            session.query_count
129        )));
130        // One line per individual authenticated session (auth), with what it's
131        // doing: the running query + elapsed when busy, else idle time.
132        for member in &session.members {
133            let state = if let Some(running) = &member.running {
134                let secs = (Utc::now() - running.started_at).num_seconds().max(0);
135                format!("running {secs}s: {}", running.sql)
136            } else if member.busy {
137                "busy".to_string()
138            } else {
139                let idle = (Utc::now() - member.last_used).num_seconds().max(0);
140                format!("idle {idle}s · {} queries", member.query_count)
141            };
142            items.push(MenuItem::Label(format!(
143                "    #{} {} · {state}",
144                member.id,
145                member.context.summary(),
146            )));
147        }
148    }
149    items.push(MenuItem::Separator);
150    for session in sessions {
151        items.push(MenuItem::Action(MenuAction {
152            id: format!("disconnect:{}", session.id),
153            label: format!("Disconnect {} · {}", session.account, session.user),
154            enabled: true,
155        }));
156    }
157    items.push(MenuItem::Action(MenuAction {
158        id: "disconnect-all".to_string(),
159        label: "Disconnect all".to_string(),
160        enabled: true,
161    }));
162    items
163}
164
165#[cfg(test)]
166#[allow(clippy::unwrap_used, clippy::expect_used)]
167mod tests {
168    use super::*;
169
170    /// A service with no resolvable defaults, so query resolution fails before
171    /// any network/auth — keeping these tests offline and deterministic.
172    fn offline_service() -> SnowflakeService {
173        SnowflakeService::new(SnowflakeEngineConfig::default())
174    }
175
176    #[tokio::test]
177    async fn name_and_unknown_op() {
178        let svc = offline_service();
179        assert_eq!(svc.name(), "snowflake");
180        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
181    }
182
183    #[tokio::test]
184    async fn sessions_op_is_empty_initially() {
185        let svc = offline_service();
186        let payload = svc.handle("sessions", Value::Null).await.unwrap();
187        assert_eq!(payload, json!({ "sessions": [] }));
188    }
189
190    #[tokio::test]
191    async fn empty_sql_is_rejected_before_auth() {
192        let svc = offline_service();
193        assert!(svc.handle("query", json!({ "sql": "   " })).await.is_err());
194    }
195
196    #[tokio::test]
197    async fn query_without_account_errors_not_panics() {
198        let svc = offline_service();
199        // Non-empty SQL but no resolvable account: errors on resolution, no auth.
200        let err = svc
201            .handle("query", json!({ "sql": "SELECT 1" }))
202            .await
203            .unwrap_err();
204        assert!(err.to_string().contains("account"));
205    }
206
207    #[tokio::test]
208    async fn disconnect_requires_account_and_user() {
209        let svc = offline_service();
210        assert!(svc.handle("disconnect", json!({})).await.is_err());
211        assert!(svc
212            .handle("disconnect", json!({ "account": "ACCT" }))
213            .await
214            .is_err());
215        // Both present: evicts nothing on an empty engine, but succeeds.
216        let payload = svc
217            .handle("disconnect", json!({ "account": "ACCT", "user": "me" }))
218            .await
219            .unwrap();
220        assert_eq!(payload, json!({ "disconnected": false }));
221    }
222
223    #[tokio::test]
224    async fn menu_and_status_shape_with_no_sessions() {
225        let svc = offline_service();
226        let menu = svc.menu();
227        assert_eq!(menu.title, "Snowflake");
228        assert!(matches!(
229            menu.items.first(),
230            Some(MenuItem::Label(text)) if text == "No sessions"
231        ));
232        let status = svc.status().await;
233        assert_eq!(status.name, "snowflake");
234        assert!(status.healthy);
235        assert_eq!(status.summary, "0 pool(s), 0 session(s)");
236    }
237
238    #[tokio::test]
239    async fn menu_actions_route_and_reject_unknown() {
240        let svc = offline_service();
241        // Both forms are no-ops on an empty engine but must not error.
242        svc.menu_action("disconnect-all").await.unwrap();
243        svc.menu_action("disconnect:7").await.unwrap();
244        assert!(svc.menu_action("disconnect:not-a-number").await.is_err());
245        assert!(svc.menu_action("bogus").await.is_err());
246        svc.shutdown().await;
247    }
248
249    #[test]
250    fn session_menu_items_render_each_member_state_and_actions() {
251        use crate::snowflake::session::{MemberInfo, QueryContext, RunningQuery};
252
253        let now = Utc::now();
254        let wh_ctx = QueryContext {
255            warehouse: Some("WH".to_string()),
256            role: Some("R".to_string()),
257            ..QueryContext::default()
258        };
259        let sessions = vec![SessionInfo {
260            id: 5,
261            account: "ACME".to_string(),
262            user: "me".to_string(),
263            created_at: now,
264            last_used: now,
265            query_count: 9,
266            sessions: 3,
267            max_sessions: 4,
268            members: vec![
269                MemberInfo {
270                    id: 1,
271                    busy: true,
272                    context: wh_ctx,
273                    last_used: now,
274                    query_count: 3,
275                    running: Some(RunningQuery {
276                        sql: "SELECT 42".to_string(),
277                        started_at: now,
278                    }),
279                },
280                MemberInfo {
281                    id: 2,
282                    busy: true,
283                    context: QueryContext::default(),
284                    last_used: now,
285                    query_count: 1,
286                    running: None,
287                },
288                MemberInfo {
289                    id: 3,
290                    busy: false,
291                    context: QueryContext::default(),
292                    last_used: now,
293                    query_count: 0,
294                    running: None,
295                },
296            ],
297        }];
298
299        let items = session_menu_items(&sessions);
300        let labels: Vec<&str> = items
301            .iter()
302            .filter_map(|i| match i {
303                MenuItem::Label(t) => Some(t.as_str()),
304                _ => None,
305            })
306            .collect();
307        assert!(labels
308            .iter()
309            .any(|l| l.contains("ACME · me · 3/4 sessions · 9 queries")));
310        assert!(labels
311            .iter()
312            .any(|l| l.contains("running") && l.contains("SELECT 42") && l.contains("WH/R")));
313        assert!(labels.iter().any(|l| l.contains("busy")));
314        assert!(labels
315            .iter()
316            .any(|l| l.contains("idle") && l.contains("(default)")));
317
318        assert!(items.iter().any(|i| matches!(i, MenuItem::Separator)));
319        let action_ids: Vec<&str> = items
320            .iter()
321            .filter_map(|i| match i {
322                MenuItem::Action(a) => Some(a.id.as_str()),
323                _ => None,
324            })
325            .collect();
326        assert!(action_ids.contains(&"disconnect:5"));
327        assert!(action_ids.contains(&"disconnect-all"));
328    }
329}