Skip to main content

omni_dev/cli/
snowflake.rs

1//! `omni-dev snowflake` — a thin client that runs SQL through the daemon's
2//! multiplexed, authenticate-once Snowflake sessions.
3//!
4//! Lifecycle stays on `omni-dev daemon` (`start`/`stop`/`status`/`restart`);
5//! these subcommands only send `query`/`sessions`/`disconnect` ops to the
6//! `snowflake` service over the daemon's Unix control socket.
7
8use std::io::Read as _;
9use std::path::{Path, PathBuf};
10
11use anyhow::{bail, Context, Result};
12use chrono::Utc;
13use clap::{Parser, Subcommand, ValueEnum};
14use serde_json::{json, Value};
15
16use crate::cli::format::TableOrJson;
17use crate::daemon::client::DaemonClient;
18use crate::daemon::protocol::DaemonEnvelope;
19use crate::daemon::server;
20use crate::snowflake::QueryRequest;
21use crate::utils::env::EnvSource;
22use crate::utils::settings::SettingsEnv;
23
24/// The `snowflake` service routing key on the daemon control socket.
25const SERVICE: &str = "snowflake";
26
27/// Snowflake: authenticate once via external-browser SSO and run arbitrary SQL
28/// across any account, multiplexed by the daemon.
29#[derive(Parser)]
30pub struct SnowflakeCommand {
31    /// The Snowflake subcommand to execute.
32    #[command(subcommand)]
33    pub command: SnowflakeSubcommands,
34}
35
36/// Snowflake subcommands.
37#[derive(Subcommand)]
38pub enum SnowflakeSubcommands {
39    /// Run SQL (from an argument or stdin) through a multiplexed session.
40    Query(QueryCommand),
41    /// List active multiplexed sessions.
42    Sessions(SessionsCommand),
43    /// Disconnect (evict) one session.
44    Disconnect(DisconnectCommand),
45}
46
47impl SnowflakeCommand {
48    /// Executes the Snowflake command.
49    pub async fn execute(self) -> Result<()> {
50        match self.command {
51            SnowflakeSubcommands::Query(cmd) => cmd.execute().await,
52            SnowflakeSubcommands::Sessions(cmd) => cmd.execute().await,
53            SnowflakeSubcommands::Disconnect(cmd) => cmd.execute().await,
54        }
55    }
56}
57
58/// Output format for query results.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
60#[value(rename_all = "kebab-case")]
61pub enum OutputFormat {
62    /// Pretty-printed JSON (default).
63    #[default]
64    Json,
65    /// YAML.
66    Yaml,
67}
68
69/// Runs arbitrary SQL through the `(account, user)` session, authenticating it
70/// on first use (a browser may open for sign-in).
71#[derive(Parser)]
72pub struct QueryCommand {
73    /// Target account. Falls back to `SNOWFLAKE_ACCOUNT` / settings.json.
74    #[arg(long)]
75    pub account: Option<String>,
76    /// Authenticating user. Falls back to `SNOWFLAKE_USER` / settings.json.
77    #[arg(long)]
78    pub user: Option<String>,
79    /// Per-query warehouse (`USE WAREHOUSE`).
80    #[arg(long)]
81    pub warehouse: Option<String>,
82    /// Per-query role (`USE ROLE`).
83    #[arg(long)]
84    pub role: Option<String>,
85    /// Per-query database (`USE DATABASE`).
86    #[arg(long)]
87    pub database: Option<String>,
88    /// Per-query schema (`USE SCHEMA`).
89    #[arg(long)]
90    pub schema: Option<String>,
91    /// Control-socket path. Defaults to the per-user runtime location.
92    #[arg(long, value_name = "PATH")]
93    pub socket: Option<PathBuf>,
94    /// Output format.
95    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Json)]
96    pub output: OutputFormat,
97    /// Deprecated: use `-o`/`--output` instead.
98    #[arg(long = "format", hide = true)]
99    pub format: Option<OutputFormat>,
100    /// SQL to run. Read from stdin when omitted.
101    pub sql: Option<String>,
102}
103
104impl QueryCommand {
105    /// Executes the query command.
106    pub async fn execute(mut self) -> Result<()> {
107        if let Some(format) = self.format.take() {
108            eprintln!("warning: --format is deprecated; use -o/--output instead");
109            self.output = format;
110        }
111        let sql = match self.sql.take() {
112            Some(sql) => sql,
113            None => read_stdin()?,
114        };
115        if sql.trim().is_empty() {
116            bail!("no SQL provided (pass it as an argument or on stdin)");
117        }
118
119        let payload = serde_json::to_value(self.request(sql, &SettingsEnv::load()))?;
120
121        // First-time auth for an (account, user) opens a browser; warn on stderr
122        // so it doesn't pollute the JSON/YAML on stdout.
123        eprintln!("snowflake: a browser may open for first-time sign-in…");
124
125        let socket = server::resolve_socket(self.socket)?;
126        let result = call(&socket, "query", payload).await?;
127        print_value(&result, self.output)
128    }
129
130    /// Builds the query request, filling each unset identity/context field from
131    /// `env` so `SNOWFLAKE_*` defaults — including profile-scoped ones selected
132    /// via `--profile` / `OMNI_DEV_PROFILE` — resolve in this client process
133    /// rather than against the daemon's startup environment (#1110).
134    fn request(&self, sql: String, env: &impl EnvSource) -> QueryRequest {
135        let mut req = QueryRequest {
136            account: self.account.clone(),
137            user: self.user.clone(),
138            warehouse: self.warehouse.clone(),
139            role: self.role.clone(),
140            database: self.database.clone(),
141            schema: self.schema.clone(),
142            sql,
143        };
144        req.fill_defaults_from(env);
145        req
146    }
147}
148
149/// Lists the daemon's active multiplexed sessions.
150#[derive(Parser)]
151pub struct SessionsCommand {
152    /// Control-socket path. Defaults to the per-user runtime location.
153    #[arg(long, value_name = "PATH")]
154    pub socket: Option<PathBuf>,
155    /// Output format.
156    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
157    pub output: TableOrJson,
158    /// Deprecated: use `-o`/`--output json` instead.
159    #[arg(long, hide = true)]
160    pub json: bool,
161}
162
163impl SessionsCommand {
164    /// Executes the sessions command.
165    pub async fn execute(mut self) -> Result<()> {
166        if self.json {
167            eprintln!("warning: --json is deprecated; use -o/--output json instead");
168            self.output = TableOrJson::Json;
169        }
170        let socket = server::resolve_socket(self.socket)?;
171        let result = call(&socket, "sessions", Value::Null).await?;
172        match self.output {
173            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
174            TableOrJson::Table => println!("{}", render_sessions(&result)),
175        }
176        Ok(())
177    }
178}
179
180/// Renders a `sessions` reply as a human-readable table: a header, one row per
181/// pool, and an indented line per authenticated session (what each is doing).
182/// Returns a placeholder line when there are no active sessions.
183fn render_sessions(result: &Value) -> String {
184    let sessions = result
185        .get("sessions")
186        .and_then(Value::as_array)
187        .map(Vec::as_slice)
188        .unwrap_or_default();
189    if sessions.is_empty() {
190        return "No active sessions.".to_string();
191    }
192    let mut out = format!(
193        "{:<4} {:<28} {:<28} {:>8} {:>7}",
194        "ID", "ACCOUNT", "USER", "SESSIONS", "QUERIES"
195    );
196    for session in sessions {
197        let id = session.get("id").and_then(Value::as_u64).unwrap_or(0);
198        let account = session.get("account").and_then(Value::as_str).unwrap_or("");
199        let user = session.get("user").and_then(Value::as_str).unwrap_or("");
200        let live = session.get("sessions").and_then(Value::as_u64).unwrap_or(0);
201        let max = session
202            .get("max_sessions")
203            .and_then(Value::as_u64)
204            .unwrap_or(0);
205        let count = session
206            .get("query_count")
207            .and_then(Value::as_u64)
208            .unwrap_or(0);
209        let pool = format!("{live}/{max}");
210        out.push_str(&format!(
211            "\n{id:<4} {account:<28} {user:<28} {pool:>8} {count:>7}"
212        ));
213        // One indented line per individual authenticated session (auth), with
214        // what it's doing.
215        if let Some(members) = session.get("members").and_then(Value::as_array) {
216            for member in members {
217                out.push_str(&format!("\n       {}", render_member(member)));
218            }
219        }
220    }
221    out
222}
223
224/// Renders one authenticated-session line: id, context, current state (running
225/// query + elapsed, busy, or idle time), and lifetime query count.
226fn render_member(member: &Value) -> String {
227    let mid = member.get("id").and_then(Value::as_u64).unwrap_or(0);
228    let qc = member
229        .get("query_count")
230        .and_then(Value::as_u64)
231        .unwrap_or(0);
232    let context = member
233        .get("context")
234        .map_or_else(|| "(default)".to_string(), context_summary);
235    let state = if let Some(running) = member.get("running").filter(|r| !r.is_null()) {
236        let sql = running.get("sql").and_then(Value::as_str).unwrap_or("");
237        let secs = age_secs(running.get("started_at").and_then(Value::as_str));
238        format!("running {secs}s: {sql}")
239    } else if member.get("busy").and_then(Value::as_bool).unwrap_or(false) {
240        "busy".to_string()
241    } else {
242        let secs = age_secs(member.get("last_used").and_then(Value::as_str));
243        format!("idle {secs}s")
244    };
245    format!("#{mid} {context} · {state} · {qc} queries")
246}
247
248/// Evicts a single multiplexed session.
249#[derive(Parser)]
250pub struct DisconnectCommand {
251    /// Account of the session to evict.
252    #[arg(long)]
253    pub account: String,
254    /// User of the session to evict.
255    #[arg(long)]
256    pub user: String,
257    /// Control-socket path. Defaults to the per-user runtime location.
258    #[arg(long, value_name = "PATH")]
259    pub socket: Option<PathBuf>,
260}
261
262impl DisconnectCommand {
263    /// Executes the disconnect command.
264    pub async fn execute(self) -> Result<()> {
265        let socket = server::resolve_socket(self.socket)?;
266        let payload = json!({ "account": self.account, "user": self.user });
267        let result = call(&socket, "disconnect", payload).await?;
268        let disconnected = result
269            .get("disconnected")
270            .and_then(Value::as_bool)
271            .unwrap_or(false);
272        println!(
273            "{}",
274            disconnect_message(disconnected, &self.account, &self.user)
275        );
276        Ok(())
277    }
278}
279
280/// The message printed after a `disconnect`, depending on whether a session was
281/// actually evicted.
282fn disconnect_message(disconnected: bool, account: &str, user: &str) -> String {
283    if disconnected {
284        format!("Disconnected {account} / {user}.")
285    } else {
286        format!("No active session for {account} / {user}.")
287    }
288}
289
290/// Sends one `snowflake` service op over the control socket, returning its
291/// payload or turning an `ok: false` reply into an error.
292async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
293    let reply = DaemonClient::new(socket)
294        .request(DaemonEnvelope::service(SERVICE, op, payload))
295        .await?;
296    if reply.ok {
297        Ok(reply.payload)
298    } else {
299        bail!(
300            "daemon returned an error: {}",
301            reply.error.as_deref().unwrap_or("unknown error")
302        )
303    }
304}
305
306/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
307fn age_secs(ts: Option<&str>) -> i64 {
308    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
309        .map_or(0, |t| {
310            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
311        })
312}
313
314/// A compact `wh/role/db/schema` label from a serialized session context
315/// (`(default)` when none are set).
316fn context_summary(context: &Value) -> String {
317    let parts: Vec<&str> = ["warehouse", "role", "database", "schema"]
318        .iter()
319        .filter_map(|key| context.get(*key).and_then(Value::as_str))
320        .collect();
321    if parts.is_empty() {
322        "(default)".to_string()
323    } else {
324        parts.join("/")
325    }
326}
327
328/// Reads SQL from stdin (the lumon pipe path).
329fn read_stdin() -> Result<String> {
330    let mut buf = String::new();
331    std::io::stdin()
332        .read_to_string(&mut buf)
333        .context("failed to read SQL from stdin")?;
334    Ok(buf)
335}
336
337/// Formats a JSON value in the requested output format.
338fn format_value(value: &Value, format: OutputFormat) -> Result<String> {
339    Ok(match format {
340        OutputFormat::Json => serde_json::to_string_pretty(value)?,
341        OutputFormat::Yaml => serde_yaml::to_string(value)?,
342    })
343}
344
345/// Prints a JSON value in the requested format (JSON gets a trailing newline;
346/// `serde_yaml` already emits one).
347fn print_value(value: &Value, format: OutputFormat) -> Result<()> {
348    let text = format_value(value, format)?;
349    match format {
350        OutputFormat::Json => println!("{text}"),
351        OutputFormat::Yaml => print!("{text}"),
352    }
353    Ok(())
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used, clippy::expect_used)]
358mod tests {
359    use super::*;
360    use crate::test_support::env::MapEnv;
361
362    /// Mirrors the `omni-dev snowflake` argv surface for parse tests.
363    #[derive(Parser)]
364    struct Wrapper {
365        #[command(subcommand)]
366        cmd: SnowflakeSubcommands,
367    }
368
369    fn parse(args: &[&str]) -> SnowflakeSubcommands {
370        let mut full = vec!["omni-dev"];
371        full.extend_from_slice(args);
372        Wrapper::try_parse_from(full).unwrap().cmd
373    }
374
375    #[test]
376    fn query_parses_sql_and_flags() {
377        let SnowflakeSubcommands::Query(cmd) = parse(&[
378            "query",
379            "--account",
380            "ACCT",
381            "--user",
382            "me",
383            "--warehouse",
384            "WH",
385            "-o",
386            "yaml",
387            "SELECT 1",
388        ]) else {
389            panic!("expected query");
390        };
391        assert_eq!(cmd.account.as_deref(), Some("ACCT"));
392        assert_eq!(cmd.user.as_deref(), Some("me"));
393        assert_eq!(cmd.warehouse.as_deref(), Some("WH"));
394        assert_eq!(cmd.sql.as_deref(), Some("SELECT 1"));
395        assert_eq!(cmd.output, OutputFormat::Yaml);
396    }
397
398    #[test]
399    fn query_deprecated_format_alias_still_parses() {
400        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "--format", "yaml", "SELECT 1"])
401        else {
402            panic!("expected query");
403        };
404        // The deprecated `--format` is captured separately; `execute` folds it
405        // into `output` with a stderr warning.
406        assert_eq!(cmd.format, Some(OutputFormat::Yaml));
407        assert_eq!(cmd.output, OutputFormat::Json);
408    }
409
410    #[test]
411    fn query_sql_optional_and_format_defaults_to_json() {
412        let SnowflakeSubcommands::Query(cmd) = parse(&["query"]) else {
413            panic!("expected query");
414        };
415        assert!(cmd.sql.is_none());
416        assert_eq!(cmd.output, OutputFormat::Json);
417        assert!(cmd.format.is_none());
418        assert!(cmd.socket.is_none());
419    }
420
421    #[test]
422    fn query_request_resolves_defaults_from_env_source() {
423        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
424            panic!("expected query");
425        };
426        let env = MapEnv::new()
427            .with("SNOWFLAKE_ACCOUNT", "PROFILE_ACCT")
428            .with("SNOWFLAKE_USER", "profile_user")
429            .with("SNOWFLAKE_ROLE", "PROFILE_ROLE");
430        let payload = serde_json::to_value(cmd.request("SELECT 1".to_string(), &env)).unwrap();
431        assert_eq!(
432            payload,
433            json!({
434                "account": "PROFILE_ACCT",
435                "user": "profile_user",
436                "role": "PROFILE_ROLE",
437                "sql": "SELECT 1",
438            })
439        );
440    }
441
442    #[test]
443    fn query_request_explicit_flags_beat_env_source() {
444        let SnowflakeSubcommands::Query(cmd) =
445            parse(&["query", "--account", "FLAG_ACCT", "SELECT 1"])
446        else {
447            panic!("expected query");
448        };
449        let env = MapEnv::new()
450            .with("SNOWFLAKE_ACCOUNT", "ENV_ACCT")
451            .with("SNOWFLAKE_USER", "env_user");
452        let req = cmd.request("SELECT 1".to_string(), &env);
453        assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
454        assert_eq!(req.user.as_deref(), Some("env_user"));
455    }
456
457    #[test]
458    fn query_request_omits_unresolved_fields_from_payload() {
459        let SnowflakeSubcommands::Query(cmd) = parse(&["query", "SELECT 1"]) else {
460            panic!("expected query");
461        };
462        let payload =
463            serde_json::to_value(cmd.request("SELECT 1".to_string(), &MapEnv::new())).unwrap();
464        assert_eq!(payload, json!({ "sql": "SELECT 1" }));
465    }
466
467    #[test]
468    fn sessions_output_flag_parses() {
469        let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "-o", "json"]) else {
470            panic!("expected sessions");
471        };
472        assert_eq!(cmd.output, TableOrJson::Json);
473        assert!(!cmd.json);
474    }
475
476    #[test]
477    fn sessions_deprecated_json_flag_still_parses() {
478        let SnowflakeSubcommands::Sessions(cmd) = parse(&["sessions", "--json"]) else {
479            panic!("expected sessions");
480        };
481        // Captured separately; `execute` folds it into `output` with a warning.
482        assert!(cmd.json);
483        assert_eq!(cmd.output, TableOrJson::Table);
484    }
485
486    #[tokio::test]
487    async fn query_execute_folds_deprecated_format_flag() {
488        let dir = tempfile::tempdir().unwrap();
489        // Deprecated `--format` folds into `output` before the (absent-daemon)
490        // socket call fails.
491        let cmd = QueryCommand {
492            account: None,
493            user: None,
494            warehouse: None,
495            role: None,
496            database: None,
497            schema: None,
498            socket: Some(dir.path().join("absent.sock")),
499            output: OutputFormat::Json,
500            format: Some(OutputFormat::Yaml),
501            sql: Some("SELECT 1".to_string()),
502        };
503        assert!(cmd.execute().await.is_err());
504    }
505
506    #[tokio::test]
507    async fn sessions_execute_folds_deprecated_json_flag() {
508        let dir = tempfile::tempdir().unwrap();
509        // Deprecated `--json` folds into `output` before the (absent-daemon)
510        // socket call fails.
511        let cmd = SessionsCommand {
512            socket: Some(dir.path().join("absent.sock")),
513            output: TableOrJson::Table,
514            json: true,
515        };
516        assert!(cmd.execute().await.is_err());
517    }
518
519    #[test]
520    fn disconnect_requires_account_and_user() {
521        let SnowflakeSubcommands::Disconnect(cmd) =
522            parse(&["disconnect", "--account", "ACCT", "--user", "me"])
523        else {
524            panic!("expected disconnect");
525        };
526        assert_eq!(cmd.account, "ACCT");
527        assert_eq!(cmd.user, "me");
528
529        // Missing required flags is a parse error.
530        let mut full = vec!["omni-dev", "disconnect", "--account", "ACCT"];
531        assert!(Wrapper::try_parse_from(std::mem::take(&mut full)).is_err());
532    }
533
534    #[test]
535    fn age_secs_handles_absent_and_unparseable_and_past() {
536        assert_eq!(age_secs(None), 0);
537        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
538        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
539    }
540
541    #[test]
542    fn context_summary_joins_set_dimensions_or_default() {
543        assert_eq!(context_summary(&json!({})), "(default)");
544        assert_eq!(
545            context_summary(&json!({ "warehouse": "WH", "role": "R" })),
546            "WH/R"
547        );
548        assert_eq!(
549            context_summary(&json!({ "warehouse": "WH", "database": "DB", "schema": "S" })),
550            "WH/DB/S"
551        );
552    }
553
554    #[test]
555    fn render_sessions_handles_empty_replies() {
556        assert_eq!(
557            render_sessions(&json!({ "sessions": [] })),
558            "No active sessions."
559        );
560        assert_eq!(render_sessions(&json!({})), "No active sessions.");
561    }
562
563    #[test]
564    fn render_sessions_renders_running_busy_and_idle_members() {
565        let result = json!({ "sessions": [{
566            "id": 1, "account": "ACME", "user": "me",
567            "sessions": 2, "max_sessions": 4, "query_count": 9,
568            "members": [
569                { "id": 1, "query_count": 3,
570                  "context": { "warehouse": "WH", "role": "R" },
571                  "running": { "sql": "SELECT 42", "started_at": "2000-01-01T00:00:00Z" } },
572                { "id": 2, "query_count": 1, "context": {}, "busy": true },
573                { "id": 3, "query_count": 0, "context": {}, "last_used": "2000-01-01T00:00:00Z" },
574            ],
575        }]});
576        let table = render_sessions(&result);
577        assert!(table.contains("ACME"), "{table}");
578        assert!(table.contains("2/4"), "{table}");
579        assert!(
580            table.contains("running") && table.contains("SELECT 42"),
581            "{table}"
582        );
583        assert!(table.contains("WH/R"), "{table}");
584        assert!(table.contains("busy"), "{table}");
585        assert!(table.contains("idle"), "{table}");
586        assert!(table.contains("(default)"), "{table}");
587    }
588
589    #[test]
590    fn format_value_renders_json_and_yaml() {
591        let value = json!({ "a": 1 });
592        assert!(format_value(&value, OutputFormat::Json)
593            .unwrap()
594            .contains("\"a\": 1"));
595        assert!(format_value(&value, OutputFormat::Yaml)
596            .unwrap()
597            .contains("a: 1"));
598    }
599
600    #[test]
601    fn disconnect_message_varies_on_outcome() {
602        assert_eq!(
603            disconnect_message(true, "ACME", "me"),
604            "Disconnected ACME / me."
605        );
606        assert_eq!(
607            disconnect_message(false, "ACME", "me"),
608            "No active session for ACME / me."
609        );
610    }
611
612    #[test]
613    fn print_value_emits_both_formats() {
614        // Exercises both arms; output goes to the test harness's captured stdout.
615        print_value(&json!({ "a": 1 }), OutputFormat::Json).unwrap();
616        print_value(&json!({ "a": 1 }), OutputFormat::Yaml).unwrap();
617    }
618}