From 88b02acdd14394b3575aca25494fd935fd91aae3 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Fri, 20 Mar 2026 15:17:57 +0000 Subject: [PATCH] feat: kubectl-style contexts with per-domain auth tokens Config now supports named contexts (like kubectl), each bundling domain, kube-context, ssh-host, infra-dir, and acme-email. Legacy flat config auto-migrates to a "production" context on load. - sunbeam config set --domain sunbeam.pt --host user@server - sunbeam config use-context production - sunbeam config get (shows all contexts) Auth tokens stored per-domain (~/.local/share/sunbeam/auth/{domain}.json) so local and production don't clobber each other. pm and auth commands read domain from active context instead of K8s cluster discovery. --- src/auth.rs | 56 ++++++---- src/cli.rs | 132 +++++++++++++++-------- src/config.rs | 284 ++++++++++++++++++++++++++++++++++++++++++++------ src/pm.rs | 18 ++-- 4 files changed, 384 insertions(+), 106 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 523b3a5..947e190 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -29,15 +29,28 @@ const DEFAULT_CLIENT_ID: &str = "sunbeam-cli"; // Cache file helpers // --------------------------------------------------------------------------- -fn cache_path() -> PathBuf { - dirs::data_dir() +/// Cache path for auth tokens — per-domain so multiple environments work. +fn cache_path_for_domain(domain: &str) -> PathBuf { + let dir = dirs::data_dir() .unwrap_or_else(|| { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".local/share") }) .join("sunbeam") - .join("auth.json") + .join("auth"); + if domain.is_empty() { + dir.join("default.json") + } else { + // Sanitize domain for filename + let safe = domain.replace(['/', '\\', ':'], "_"); + dir.join(format!("{safe}.json")) + } +} + +fn cache_path() -> PathBuf { + let domain = crate::config::domain(); + cache_path_for_domain(domain) } fn read_cache() -> Result { @@ -112,7 +125,13 @@ async fn resolve_domain(explicit: Option<&str>) -> Result { } } - // 2. Cached token domain (already logged in to a domain) + // 2. Active context domain (set by cli::dispatch from config) + let ctx_domain = crate::config::domain(); + if !ctx_domain.is_empty() { + return Ok(ctx_domain.to_string()); + } + + // 3. Cached token domain (already logged in) if let Ok(tokens) = read_cache() { if !tokens.domain.is_empty() { crate::output::ok(&format!("Using cached domain: {}", tokens.domain)); @@ -120,29 +139,15 @@ async fn resolve_domain(explicit: Option<&str>) -> Result { } } - // 3. Config: derive from production_host - let config = crate::config::load_config(); - if !config.production_host.is_empty() { - let host = &config.production_host; - let raw = host.split('@').last().unwrap_or(host); - let raw = raw.split(':').next().unwrap_or(raw); - // Take the last 2+ segments as the domain (e.g. admin.sunbeam.pt -> sunbeam.pt) - let parts: Vec<&str> = raw.split('.').collect(); - if parts.len() >= 2 { - let domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]); - return Ok(domain); - } - } - // 4. Try cluster discovery (may fail if not connected) match crate::kube::get_domain().await { - Ok(d) if !d.is_empty() && !d.starts_with(".") => return Ok(d), + Ok(d) if !d.is_empty() && !d.starts_with('.') => return Ok(d), _ => {} } Err(SunbeamError::config( "Could not determine domain. Use --domain flag, or configure with:\n \ - sunbeam config set --host user@your-server.example.com" + sunbeam config set --host user@your-server.example.com", )) } @@ -803,9 +808,16 @@ mod tests { #[test] fn test_cache_path_is_under_sunbeam() { - let path = cache_path(); + let path = cache_path_for_domain("sunbeam.pt"); let path_str = path.to_string_lossy(); assert!(path_str.contains("sunbeam")); - assert!(path_str.ends_with("auth.json")); + assert!(path_str.contains("auth")); + assert!(path_str.ends_with("sunbeam.pt.json")); + } + + #[test] + fn test_cache_path_default_domain() { + let path = cache_path_for_domain(""); + assert!(path.to_string_lossy().ends_with("default.json")); } } diff --git a/src/cli.rs b/src/cli.rs index 66745e2..36b24a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -278,8 +278,11 @@ impl std::fmt::Display for BuildTarget { #[derive(Subcommand, Debug)] pub enum ConfigAction { - /// Set configuration values. + /// Set configuration values for the current context. Set { + /// Domain suffix (e.g. sunbeam.pt). + #[arg(long, default_value = "")] + domain: String, /// Production SSH host (e.g. user@server.example.com). #[arg(long, default_value = "")] host: String, @@ -289,11 +292,19 @@ pub enum ConfigAction { /// ACME email for Let's Encrypt certificates. #[arg(long, default_value = "")] acme_email: String, + /// Context name to configure (default: current context). + #[arg(long, default_value = "")] + context_name: String, }, /// Get current configuration. Get, /// Clear configuration. Clear, + /// Switch the active context. + UseContext { + /// Context name to switch to. + name: String, + }, } #[derive(Subcommand, Debug)] @@ -751,28 +762,25 @@ mod tests { pub async fn dispatch() -> Result<()> { let cli = Cli::parse(); - let ctx = cli - .context - .as_deref() - .unwrap_or_else(|| default_context(&cli.env)); + // Resolve the active context from config + CLI flags + let config = crate::config::load_config(); + let active = crate::config::resolve_context( + &config, + &cli.env.to_string(), + cli.context.as_deref(), + &cli.domain, + ); - // For production, resolve SSH host - let ssh_host = match cli.env { - Env::Production => { - let host = crate::config::get_production_host(); - if host.is_empty() { - return Err(SunbeamError::config( - "Production host not configured. \ - Use `sunbeam config set --host` or set SUNBEAM_SSH_HOST.", - )); - } - Some(host) - } - Env::Local => None, + // Initialize kube context from the resolved context + let kube_ctx = if active.kube_context.is_empty() { + default_context(&cli.env) + } else { + &active.kube_context }; + crate::kube::set_context(kube_ctx, &active.ssh_host); - // Initialize kube context - crate::kube::set_context(ctx, ssh_host.as_deref().unwrap_or("")); + // Store active context globally for other modules to read + crate::config::set_active_context(active); match cli.verb { None => { @@ -867,50 +875,84 @@ pub async fn dispatch() -> Result<()> { Ok(()) } Some(ConfigAction::Set { + domain: set_domain, host, infra_dir, acme_email, + context_name, }) => { let mut config = crate::config::load_config(); + // Determine which context to modify + let ctx_name = if context_name.is_empty() { + if !config.current_context.is_empty() { + config.current_context.clone() + } else { + "production".to_string() + } + } else { + context_name + }; + + let ctx = config.contexts.entry(ctx_name.clone()).or_default(); + if !set_domain.is_empty() { + ctx.domain = set_domain; + } if !host.is_empty() { - config.production_host = host; + ctx.ssh_host = host.clone(); + config.production_host = host; // keep legacy field in sync } if !infra_dir.is_empty() { + ctx.infra_dir = infra_dir.clone(); config.infra_directory = infra_dir; } if !acme_email.is_empty() { + ctx.acme_email = acme_email.clone(); config.acme_email = acme_email; } + if config.current_context.is_empty() { + config.current_context = ctx_name; + } crate::config::save_config(&config) } + Some(ConfigAction::UseContext { name }) => { + let mut config = crate::config::load_config(); + if !config.contexts.contains_key(&name) { + crate::output::warn(&format!("Context '{name}' does not exist. Creating empty context.")); + config.contexts.insert(name.clone(), crate::config::Context::default()); + } + config.current_context = name.clone(); + crate::config::save_config(&config)?; + crate::output::ok(&format!("Switched to context '{name}'.")); + Ok(()) + } Some(ConfigAction::Get) => { let config = crate::config::load_config(); - let host_display = if config.production_host.is_empty() { - "(not set)" + let current = if config.current_context.is_empty() { + "(none)" } else { - &config.production_host + &config.current_context }; - let infra_display = if config.infra_directory.is_empty() { - "(not set)" - } else { - &config.infra_directory - }; - let email_display = if config.acme_email.is_empty() { - "(not set)" - } else { - &config.acme_email - }; - crate::output::ok(&format!("Production host: {host_display}")); - crate::output::ok(&format!( - "Infrastructure directory: {infra_display}" - )); - crate::output::ok(&format!("ACME email: {email_display}")); - - let effective = crate::config::get_production_host(); - if !effective.is_empty() { - crate::output::ok(&format!( - "Effective production host: {effective}" - )); + crate::output::ok(&format!("Current context: {current}")); + println!(); + for (name, ctx) in &config.contexts { + let marker = if name == current { " *" } else { "" }; + crate::output::ok(&format!("Context: {name}{marker}")); + if !ctx.domain.is_empty() { + crate::output::ok(&format!(" domain: {}", ctx.domain)); + } + if !ctx.kube_context.is_empty() { + crate::output::ok(&format!(" kube-context: {}", ctx.kube_context)); + } + if !ctx.ssh_host.is_empty() { + crate::output::ok(&format!(" ssh-host: {}", ctx.ssh_host)); + } + if !ctx.infra_dir.is_empty() { + crate::output::ok(&format!(" infra-dir: {}", ctx.infra_dir)); + } + if !ctx.acme_email.is_empty() { + crate::output::ok(&format!(" acme-email: {}", ctx.acme_email)); + } + println!(); } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 65205f6..74052e9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,89 @@ -use crate::error::{Result, ResultExt}; +use crate::error::{Result, ResultExt, SunbeamError}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// Config data model +// --------------------------------------------------------------------------- /// Sunbeam configuration stored at ~/.sunbeam.json. +/// +/// Supports kubectl-style named contexts. Each context bundles a domain, +/// kube context, SSH host, and infrastructure directory. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SunbeamConfig { + /// The active context name. If empty, uses "default". + #[serde(default, rename = "current-context")] + pub current_context: String, + + /// Named contexts. #[serde(default)] + pub contexts: HashMap, + + // --- Legacy fields (migrated on load) --- + #[serde(default, skip_serializing_if = "String::is_empty")] pub production_host: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] pub infra_directory: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] pub acme_email: String, } +/// A named context — everything needed to target a specific environment. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Context { + /// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io"). + #[serde(default)] + pub domain: String, + + /// Kubernetes context name (e.g. "production", "sunbeam"). + #[serde(default, rename = "kube-context")] + pub kube_context: String, + + /// SSH host for production tunnel (e.g. "sienna@62.210.145.138"). + #[serde(default, rename = "ssh-host")] + pub ssh_host: String, + + /// Infrastructure directory root. + #[serde(default, rename = "infra-dir")] + pub infra_dir: String, + + /// ACME email for cert-manager. + #[serde(default, rename = "acme-email")] + pub acme_email: String, +} + +// --------------------------------------------------------------------------- +// Active context (set once at startup, read everywhere) +// --------------------------------------------------------------------------- + +static ACTIVE_CONTEXT: OnceLock = OnceLock::new(); + +/// Initialize the active context. Called once from cli::dispatch(). +pub fn set_active_context(ctx: Context) { + let _ = ACTIVE_CONTEXT.set(ctx); +} + +/// Get the active context. Panics if not initialized (should never happen +/// after dispatch starts). +pub fn active_context() -> &'static Context { + ACTIVE_CONTEXT.get().expect("active context not initialized") +} + +/// Get the domain from the active context. Returns empty string if not set. +pub fn domain() -> &'static str { + ACTIVE_CONTEXT + .get() + .map(|c| c.domain.as_str()) + .unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Config file I/O +// --------------------------------------------------------------------------- + fn config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -20,12 +91,13 @@ fn config_path() -> PathBuf { } /// Load configuration from ~/.sunbeam.json, return default if not found. +/// Migrates legacy flat config to context-based format. pub fn load_config() -> SunbeamConfig { let path = config_path(); if !path.exists() { return SunbeamConfig::default(); } - match std::fs::read_to_string(&path) { + let mut config: SunbeamConfig = match std::fs::read_to_string(&path) { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { crate::output::warn(&format!( "Failed to parse config from {}: {e}", @@ -40,7 +112,27 @@ pub fn load_config() -> SunbeamConfig { )); SunbeamConfig::default() } + }; + + // Migrate legacy flat fields into a "production" context + if !config.production_host.is_empty() && !config.contexts.contains_key("production") { + let domain = derive_domain_from_host(&config.production_host); + config.contexts.insert( + "production".to_string(), + Context { + domain, + kube_context: "production".to_string(), + ssh_host: config.production_host.clone(), + infra_dir: config.infra_directory.clone(), + acme_email: config.acme_email.clone(), + }, + ); + if config.current_context.is_empty() { + config.current_context = "production".to_string(); + } } + + config } /// Save configuration to ~/.sunbeam.json. @@ -48,10 +140,7 @@ pub fn save_config(config: &SunbeamConfig) -> Result<()> { let path = config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_ctx(|| { - format!( - "Failed to create config directory: {}", - parent.display() - ) + format!("Failed to create config directory: {}", parent.display()) })?; } let content = serde_json::to_string_pretty(config)?; @@ -61,30 +150,103 @@ pub fn save_config(config: &SunbeamConfig) -> Result<()> { Ok(()) } +/// Resolve the context to use, given CLI flags and config. +pub fn resolve_context( + config: &SunbeamConfig, + env_flag: &str, + context_override: Option<&str>, + domain_override: &str, +) -> Context { + // Start from the named context (CLI --env or current-context) + let context_name = context_override + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if env_flag == "production" { + "production".to_string() + } else if env_flag == "local" { + "local".to_string() + } else if !config.current_context.is_empty() { + config.current_context.clone() + } else { + "local".to_string() + } + }); + + let mut ctx = config + .contexts + .get(&context_name) + .cloned() + .unwrap_or_else(|| { + // Synthesize defaults for well-known names + match context_name.as_str() { + "local" => Context { + kube_context: "sunbeam".to_string(), + ..Default::default() + }, + "production" => Context { + kube_context: "production".to_string(), + ssh_host: config.production_host.clone(), + infra_dir: config.infra_directory.clone(), + acme_email: config.acme_email.clone(), + domain: derive_domain_from_host(&config.production_host), + ..Default::default() + }, + _ => Default::default(), + } + }); + + // CLI flags override context values + if !domain_override.is_empty() { + ctx.domain = domain_override.to_string(); + } + + ctx +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt"). +fn derive_domain_from_host(host: &str) -> String { + let raw = host.split('@').last().unwrap_or(host); + let raw = raw.split(':').next().unwrap_or(raw); + let parts: Vec<&str> = raw.split('.').collect(); + if parts.len() >= 2 { + format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]) + } else { + String::new() + } +} + /// Get production host from config or SUNBEAM_SSH_HOST environment variable. pub fn get_production_host() -> String { let config = load_config(); + // Check active context first + if let Some(ctx) = ACTIVE_CONTEXT.get() { + if !ctx.ssh_host.is_empty() { + return ctx.ssh_host.clone(); + } + } if !config.production_host.is_empty() { return config.production_host; } std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default() } -/// Get infrastructure directory from config. -pub fn get_infra_directory() -> String { - load_config().infra_directory -} - /// Infrastructure manifests directory as a Path. -/// -/// Prefers the configured infra_directory; falls back to a path relative to -/// the current executable (works when running from the development checkout). pub fn get_infra_dir() -> PathBuf { + // Check active context + if let Some(ctx) = ACTIVE_CONTEXT.get() { + if !ctx.infra_dir.is_empty() { + return PathBuf::from(&ctx.infra_dir); + } + } let configured = load_config().infra_directory; if !configured.is_empty() { return PathBuf::from(configured); } - // Dev fallback: walk up from the executable to find monorepo root + // Dev fallback std::env::current_exe() .ok() .and_then(|p| p.canonicalize().ok()) @@ -115,10 +277,7 @@ pub fn clear_config() -> Result<()> { if path.exists() { std::fs::remove_file(&path) .with_ctx(|| format!("Failed to remove {}", path.display()))?; - crate::output::ok(&format!( - "Configuration cleared from {}", - path.display() - )); + crate::output::ok(&format!("Configuration cleared from {}", path.display())); } else { crate::output::warn("No configuration file found to clear"); } @@ -132,22 +291,81 @@ mod tests { #[test] fn test_default_config() { let config = SunbeamConfig::default(); - assert!(config.production_host.is_empty()); - assert!(config.infra_directory.is_empty()); - assert!(config.acme_email.is_empty()); + assert!(config.current_context.is_empty()); + assert!(config.contexts.is_empty()); } #[test] - fn test_config_roundtrip() { - let config = SunbeamConfig { - production_host: "user@example.com".to_string(), - infra_directory: "/path/to/infra".to_string(), - acme_email: "ops@example.com".to_string(), - }; + fn test_derive_domain_from_host() { + assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt"); + assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138"); + assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt"); + assert_eq!(derive_domain_from_host("localhost"), ""); + } + + #[test] + fn test_legacy_migration() { + let json = r#"{ + "production_host": "sienna@62.210.145.138", + "infra_directory": "/path/to/infra", + "acme_email": "ops@sunbeam.pt" + }"#; + let config: SunbeamConfig = serde_json::from_str(json).unwrap(); + // After load_config migration, contexts would be populated. + // Here we just test the struct deserializes legacy fields. + assert_eq!(config.production_host, "sienna@62.210.145.138"); + assert!(config.contexts.is_empty()); // migration happens in load_config() + } + + #[test] + fn test_context_roundtrip() { + let mut config = SunbeamConfig::default(); + config.current_context = "production".to_string(); + config.contexts.insert( + "production".to_string(), + Context { + domain: "sunbeam.pt".to_string(), + kube_context: "production".to_string(), + ssh_host: "sienna@server.sunbeam.pt".to_string(), + infra_dir: "/home/infra".to_string(), + acme_email: "ops@sunbeam.pt".to_string(), + }, + ); let json = serde_json::to_string(&config).unwrap(); let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(loaded.production_host, "user@example.com"); - assert_eq!(loaded.infra_directory, "/path/to/infra"); - assert_eq!(loaded.acme_email, "ops@example.com"); + assert_eq!(loaded.current_context, "production"); + let ctx = loaded.contexts.get("production").unwrap(); + assert_eq!(ctx.domain, "sunbeam.pt"); + assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt"); + } + + #[test] + fn test_resolve_context_from_env_flag() { + let mut config = SunbeamConfig::default(); + config.contexts.insert( + "production".to_string(), + Context { + domain: "sunbeam.pt".to_string(), + kube_context: "production".to_string(), + ..Default::default() + }, + ); + let ctx = resolve_context(&config, "production", None, ""); + assert_eq!(ctx.domain, "sunbeam.pt"); + assert_eq!(ctx.kube_context, "production"); + } + + #[test] + fn test_resolve_context_domain_override() { + let config = SunbeamConfig::default(); + let ctx = resolve_context(&config, "local", None, "custom.example.com"); + assert_eq!(ctx.domain, "custom.example.com"); + } + + #[test] + fn test_resolve_context_defaults_local() { + let config = SunbeamConfig::default(); + let ctx = resolve_context(&config, "local", None, ""); + assert_eq!(ctx.kube_context, "sunbeam"); } } diff --git a/src/pm.rs b/src/pm.rs index 9c313eb..bf2e288 100644 --- a/src/pm.rs +++ b/src/pm.rs @@ -1000,7 +1000,8 @@ fn display_ticket_detail(t: &Ticket) { /// When `source` is `None`, both Planka and Gitea are queried in parallel. #[allow(dead_code)] pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p")); let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g")); @@ -1052,7 +1053,8 @@ pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> { /// Show details for a single ticket by ID. #[allow(dead_code)] pub async fn cmd_pm_show(id: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let ticket_ref = parse_ticket_id(id)?; let ticket = match ticket_ref { @@ -1077,7 +1079,8 @@ pub async fn cmd_pm_show(id: &str) -> Result<()> { /// for Gitea it is `"org/repo"`. #[allow(dead_code)] pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let ticket = match source { "planka" | "p" => { @@ -1120,7 +1123,8 @@ pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) /// Add a comment to a ticket. #[allow(dead_code)] pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let ticket_ref = parse_ticket_id(id)?; match ticket_ref { @@ -1141,7 +1145,8 @@ pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> { /// Close a ticket. #[allow(dead_code)] pub async fn cmd_pm_close(id: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let ticket_ref = parse_ticket_id(id)?; match ticket_ref { @@ -1175,7 +1180,8 @@ pub async fn cmd_pm_close(id: &str) -> Result<()> { /// Assign a user to a ticket. #[allow(dead_code)] pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> { - let domain = crate::kube::get_domain().await?; + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } let ticket_ref = parse_ticket_id(id)?; match ticket_ref {