diff --git a/crates/miroir-ctl/Cargo.toml b/crates/miroir-ctl/Cargo.toml index 8ce977f..e7ac724 100644 --- a/crates/miroir-ctl/Cargo.toml +++ b/crates/miroir-ctl/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.42", features = ["full"] } diff --git a/crates/miroir-ctl/src/commands/alias.rs b/crates/miroir-ctl/src/commands/alias.rs new file mode 100644 index 0000000..02abf36 --- /dev/null +++ b/crates/miroir-ctl/src/commands/alias.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum AliasSubcommand { + /// Create a new alias + Create, + /// Delete an alias + Delete, + /// List all aliases + List, +} + +pub async fn run(_cmd: AliasSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/canary.rs b/crates/miroir-ctl/src/commands/canary.rs new file mode 100644 index 0000000..4c55228 --- /dev/null +++ b/crates/miroir-ctl/src/commands/canary.rs @@ -0,0 +1,17 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum CanarySubcommand { + /// Create a canary deployment + Create, + /// Promote a canary to primary + Promote, + /// Rollback a canary + Rollback, + /// Show canary status + Status, +} + +pub async fn run(_cmd: CanarySubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/cdc.rs b/crates/miroir-ctl/src/commands/cdc.rs new file mode 100644 index 0000000..c7ac631 --- /dev/null +++ b/crates/miroir-ctl/src/commands/cdc.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum CdcSubcommand { + /// Create a CDC subscription + Create, + /// List CDC subscriptions + List, + /// Delete a CDC subscription + Delete, +} + +pub async fn run(_cmd: CdcSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/dump.rs b/crates/miroir-ctl/src/commands/dump.rs new file mode 100644 index 0000000..3f96d52 --- /dev/null +++ b/crates/miroir-ctl/src/commands/dump.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum DumpSubcommand { + /// Dump data for a key or prefix + Keys { + /// Key or prefix to dump + #[arg(short, long)] + prefix: Option, + }, +} + +pub async fn run(_cmd: DumpSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/explain.rs b/crates/miroir-ctl/src/commands/explain.rs new file mode 100644 index 0000000..02a6e3c --- /dev/null +++ b/crates/miroir-ctl/src/commands/explain.rs @@ -0,0 +1,14 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum ExplainSubcommand { + /// Explain a query plan or operation + Query { + /// Query or operation to explain + query: String, + }, +} + +pub async fn run(_cmd: ExplainSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/mod.rs b/crates/miroir-ctl/src/commands/mod.rs new file mode 100644 index 0000000..e319629 --- /dev/null +++ b/crates/miroir-ctl/src/commands/mod.rs @@ -0,0 +1,15 @@ +pub mod alias; +pub mod canary; +pub mod cdc; +pub mod dump; +pub mod explain; +pub mod node; +pub mod rebalance; +pub mod reshard; +pub mod shadow; +pub mod status; +pub mod task; +pub mod tenant; +pub mod ttl; +pub mod ui; +pub mod verify; diff --git a/crates/miroir-ctl/src/commands/node.rs b/crates/miroir-ctl/src/commands/node.rs new file mode 100644 index 0000000..bd0723b --- /dev/null +++ b/crates/miroir-ctl/src/commands/node.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum NodeSubcommand { + /// Add a new node to the cluster + Add, + /// Remove a node from the cluster + Remove, + /// List all nodes + List, +} + +pub async fn run(_cmd: NodeSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/rebalance.rs b/crates/miroir-ctl/src/commands/rebalance.rs new file mode 100644 index 0000000..6740dba --- /dev/null +++ b/crates/miroir-ctl/src/commands/rebalance.rs @@ -0,0 +1,19 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum RebalanceSubcommand { + /// Show rebalancing status + Status { + /// Watch mode: continuously refresh status + #[arg(short, long)] + watch: bool, + }, + /// Start a rebalance operation + Start, + /// Cancel an active rebalance + Cancel, +} + +pub async fn run(_cmd: RebalanceSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/reshard.rs b/crates/miroir-ctl/src/commands/reshard.rs new file mode 100644 index 0000000..884ad9b --- /dev/null +++ b/crates/miroir-ctl/src/commands/reshard.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum ReshardSubcommand { + /// Start a reshard operation + Start, + /// Show reshard status + Status, + /// Cancel an active reshard + Cancel, +} + +pub async fn run(_cmd: ReshardSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/shadow.rs b/crates/miroir-ctl/src/commands/shadow.rs new file mode 100644 index 0000000..03233be --- /dev/null +++ b/crates/miroir-ctl/src/commands/shadow.rs @@ -0,0 +1,17 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum ShadowSubcommand { + /// Create a shadow index + Create, + /// Promote a shadow index to primary + Promote, + /// Delete a shadow index + Delete, + /// Show shadow index status + Status, +} + +pub async fn run(_cmd: ShadowSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/status.rs b/crates/miroir-ctl/src/commands/status.rs new file mode 100644 index 0000000..c01a981 --- /dev/null +++ b/crates/miroir-ctl/src/commands/status.rs @@ -0,0 +1,12 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +pub struct StatusSubcommand { + /// Watch mode: continuously refresh status + #[arg(short, long)] + watch: bool, +} + +pub async fn run(_cmd: StatusSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/task.rs b/crates/miroir-ctl/src/commands/task.rs new file mode 100644 index 0000000..96ca1b6 --- /dev/null +++ b/crates/miroir-ctl/src/commands/task.rs @@ -0,0 +1,22 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum TaskSubcommand { + /// Show all background tasks + List, + /// Show task status + Status { + /// Task ID + #[arg(short, long)] + id: Option, + }, + /// Cancel a task + Cancel { + /// Task ID + id: String, + }, +} + +pub async fn run(_cmd: TaskSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/tenant.rs b/crates/miroir-ctl/src/commands/tenant.rs new file mode 100644 index 0000000..670914d --- /dev/null +++ b/crates/miroir-ctl/src/commands/tenant.rs @@ -0,0 +1,17 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum TenantSubcommand { + /// Create a new tenant + Create, + /// List all tenants + List, + /// Delete a tenant + Delete, + /// Set tenant quota + SetQuota, +} + +pub async fn run(_cmd: TenantSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/ttl.rs b/crates/miroir-ctl/src/commands/ttl.rs new file mode 100644 index 0000000..3f75430 --- /dev/null +++ b/crates/miroir-ctl/src/commands/ttl.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum TtlSubcommand { + /// Set a TTL policy + Set, + /// Get TTL policy for a key + Get, + /// Remove a TTL policy + Remove, +} + +pub async fn run(_cmd: TtlSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/ui.rs b/crates/miroir-ctl/src/commands/ui.rs new file mode 100644 index 0000000..aa09bb8 --- /dev/null +++ b/crates/miroir-ctl/src/commands/ui.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum UiSubcommand { + /// Launch the web UI + Launch { + /// Port to listen on + #[arg(short, long, default_value = "3000")] + port: u16, + }, +} + +pub async fn run(_cmd: UiSubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/commands/verify.rs b/crates/miroir-ctl/src/commands/verify.rs new file mode 100644 index 0000000..cd34d35 --- /dev/null +++ b/crates/miroir-ctl/src/commands/verify.rs @@ -0,0 +1,15 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum VerifySubcommand { + /// Verify data integrity for a key prefix + Check { + /// Key prefix to verify + #[arg(short, long)] + prefix: Option, + }, +} + +pub async fn run(_cmd: VerifySubcommand) -> Result<(), Box> { + Err("This command is not yet implemented. See bead miroir-qon for tracking.".into()) +} diff --git a/crates/miroir-ctl/src/credentials.rs b/crates/miroir-ctl/src/credentials.rs new file mode 100644 index 0000000..c236326 --- /dev/null +++ b/crates/miroir-ctl/src/credentials.rs @@ -0,0 +1,258 @@ +//! Admin API key credential loading. +//! +//! Priority order per plan ยง9: +//! 1. `MIROIR_ADMIN_API_KEY` environment variable +//! 2. `~/.config/miroir/credentials` TOML file +//! 3. `--admin-key` CLI flag (WARNING: visible in process list!) + +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; + +const ENV_VAR: &str = "MIROIR_ADMIN_API_KEY"; +const CREDENTIALS_FILE: &str = "credentials"; + +/// Credentials loaded from `~/.config/miroir/credentials.toml` +#[derive(Deserialize)] +struct CredentialsFile { + default: Option, +} + +#[derive(Deserialize)] +struct CredentialsProfile { + admin_api_key: Option, +} + +/// Error types for credential loading +#[derive(Debug)] +pub enum CredentialError { + NotFound(String), + IoError(std::io::Error), + ParseError(String), +} + +impl std::fmt::Display for CredentialError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CredentialError::NotFound(msg) => write!(f, "Credential not found: {}", msg), + CredentialError::IoError(e) => write!(f, "IO error: {}", e), + CredentialError::ParseError(msg) => write!(f, "Parse error: {}", msg), + } + } +} + +impl std::error::Error for CredentialError {} + +/// Load admin API key from the first available source. +/// +/// Priority order: +/// 1. `MIROIR_ADMIN_API_KEY` environment variable +/// 2. `~/.config/miroir/credentials` TOML file (`default.admin_api_key`) +/// 3. Flag value (passed separately by caller) +/// +/// Returns `Ok(Some(key))` if found, `Ok(None)` if no source has a key. +pub fn load_admin_key(flag_key: Option) -> Result, CredentialError> { + // Priority 1: Environment variable + if let Ok(key) = std::env::var(ENV_VAR) { + if !key.is_empty() { + return Ok(Some(key)); + } + } + + // Priority 2: Credentials file + if let Some(key) = load_from_credentials_file()? { + return Ok(Some(key)); + } + + // Priority 3: CLI flag + if let Some(key) = flag_key { + if !key.is_empty() { + return Ok(Some(key)); + } + } + + Ok(None) +} + +/// Load admin key from `~/.config/miroir/credentials` TOML file. +fn load_from_credentials_file() -> Result, CredentialError> { + let config_dir = dirs::config_dir().ok_or_else(|| { + CredentialError::NotFound("Unable to determine config directory".to_string()) + })?; + + let miroir_config_dir = config_dir.join("miroir"); + let credentials_path = miroir_config_dir.join(CREDENTIALS_FILE); + + // Try .toml extension first, then plain name + let paths = [ + credentials_path.with_extension("toml"), + credentials_path.clone(), + ]; + + for path in &paths { + if path.exists() { + let contents = fs::read_to_string(path).map_err(CredentialError::IoError)?; + + let creds: CredentialsFile = toml::from_str(&contents) + .map_err(|e| CredentialError::ParseError(format!("Invalid TOML: {}", e)))?; + + if let Some(profile) = creds.default { + if let Some(key) = profile.admin_api_key { + if !key.is_empty() { + return Ok(Some(key)); + } + } + } + + // File exists but no key found + return Ok(None); + } + } + + Ok(None) +} + +/// Get the credentials file path for diagnostic messages +pub fn credentials_file_path() -> Option { + let config_dir = dirs::config_dir()?; + let miroir_config_dir = config_dir.join("miroir"); + Some( + miroir_config_dir + .join(CREDENTIALS_FILE) + .with_extension("toml"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + // Test isolation: each test gets its own temp config dir + fn setup_test_config_dir(_test_name: &str) -> tempfile::TempDir { + let temp_dir = tempfile::tempdir().unwrap(); + let miroir_dir = temp_dir.path().join("miroir"); + fs::create_dir_all(&miroir_dir).unwrap(); + temp_dir + } + + fn write_credentials_file(dir: &std::path::Path, content: &str) { + let creds_path = dir.join("credentials.toml"); + fs::write(creds_path, content).unwrap(); + } + + #[test] + fn test_env_var_takes_precedence() { + // Create a credentials file with a key + let temp_dir = setup_test_config_dir("env_precedence"); + write_credentials_file( + &temp_dir.path().join("miroir"), + r#" + [default] + admin_api_key = "file-key-12345" + "#, + ); + + // Mock env var (note: this affects the actual environment for the test process) + env::set_var(ENV_VAR, "env-key-67890"); + + // Env var should win even though file exists and flag is provided + let result = load_admin_key(Some("flag-key-abcde".to_string())).unwrap(); + assert_eq!(result, Some("env-key-67890".to_string())); + + env::remove_var(ENV_VAR); + } + + #[test] + fn test_credentials_file_without_env() { + let temp_dir = setup_test_config_dir("file_only"); + write_credentials_file( + &temp_dir.path().join("miroir"), + r#" + [default] + admin_api_key = "file-key-12345" + "#, + ); + + // Since we can't mock dirs::config_dir(), we'll test the parsing logic directly + let content = r#" + [default] + admin_api_key = "file-key-12345" + "#; + let creds: CredentialsFile = toml::from_str(content).unwrap(); + assert_eq!( + creds.default.unwrap().admin_api_key.unwrap(), + "file-key-12345" + ); + } + + #[test] + fn test_flag_as_fallback() { + // No env var, no file - flag should be used + env::remove_var(ENV_VAR); + let result = load_admin_key(Some("flag-key-xyz".to_string())).unwrap(); + assert_eq!(result, Some("flag-key-xyz".to_string())); + } + + #[test] + fn test_no_credentials_returns_none() { + env::remove_var(ENV_VAR); + let result = load_admin_key(None as Option).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_empty_key_is_ignored() { + env::set_var(ENV_VAR, ""); + let result = load_admin_key(Some("flag-key".to_string())).unwrap(); + // Empty env var should be skipped, flag key used + assert_eq!(result, Some("flag-key".to_string())); + env::remove_var(ENV_VAR); + } + + #[test] + fn test_credentials_file_parsing() { + // Test various TOML formats + let valid_cases = vec![ + ( + r#"[default] +admin_api_key = "key123""#, + "key123", + ), + ( + r#"[default] + admin_api_key = "key456" +"#, + "key456", + ), + ( + r#"[default] +admin_api_key = "key789" +[other] +admin_api_key = "other-key""#, + "key789", + ), + ]; + + for (toml, expected_key) in valid_cases { + let creds: CredentialsFile = toml::from_str(toml).unwrap(); + assert_eq!(creds.default.unwrap().admin_api_key.unwrap(), expected_key); + } + } + + #[test] + fn test_credentials_file_missing_default_section() { + let toml = r#"[other] +admin_api_key = "other-key""#; + let creds: CredentialsFile = toml::from_str(toml).unwrap(); + assert!(creds.default.is_none()); + } + + #[test] + fn test_credentials_file_missing_key() { + let toml = r#"[default]"#; + let creds: CredentialsFile = toml::from_str(toml).unwrap(); + assert!(creds.default.unwrap().admin_api_key.is_none()); + } +} diff --git a/crates/miroir-ctl/src/lib.rs b/crates/miroir-ctl/src/lib.rs index 7f3e13b..1512d38 100644 --- a/crates/miroir-ctl/src/lib.rs +++ b/crates/miroir-ctl/src/lib.rs @@ -1 +1,3 @@ -// miroir-ctl placeholder +//! miroir-ctl: Miroir management CLI library + +pub mod credentials; diff --git a/crates/miroir-ctl/src/main.rs b/crates/miroir-ctl/src/main.rs new file mode 100644 index 0000000..7e52a15 --- /dev/null +++ b/crates/miroir-ctl/src/main.rs @@ -0,0 +1,129 @@ +use clap::{Parser, Subcommand}; +use credentials::load_admin_key; + +mod commands; +mod credentials; + +#[derive(Parser)] +#[command(name = "miroir-ctl")] +#[command(about = "Miroir management CLI", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Admin API key for authentication. + /// + /// WARNING: This flag's value is visible in your shell history and the process list + /// (ps, top, etc.). For production use, prefer the MIROIR_ADMIN_API_KEY environment + /// variable or ~/.config/miroir/credentials file. + #[arg(long, global = true)] + admin_key: Option, + + /// API endpoint URL (default: http://localhost:8080) + #[arg(long, global = true)] + api_url: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Show cluster status and health + Status(commands::status::StatusSubcommand), + + /// Manage cluster nodes + #[command(subcommand)] + Node(commands::node::NodeSubcommand), + + /// Manage rebalancing operations + #[command(subcommand)] + Rebalance(commands::rebalance::RebalanceSubcommand), + + /// Manage resharding operations + #[command(subcommand)] + Reshard(commands::reshard::ReshardSubcommand), + + /// Verify data integrity + #[command(subcommand)] + Verify(commands::verify::VerifySubcommand), + + /// Monitor and manage background tasks + #[command(subcommand)] + Task(commands::task::TaskSubcommand), + + /// Dump and inspect data + #[command(subcommand)] + Dump(commands::dump::DumpSubcommand), + + /// Manage key aliases + #[command(subcommand)] + Alias(commands::alias::AliasSubcommand), + + /// Manage canary deployments + #[command(subcommand)] + Canary(commands::canary::CanarySubcommand), + + /// Manage TTL policies + #[command(subcommand)] + Ttl(commands::ttl::TtlSubcommand), + + /// Manage change data capture + #[command(subcommand)] + Cdc(commands::cdc::CdcSubcommand), + + /// Manage shadow indexing + #[command(subcommand)] + Shadow(commands::shadow::ShadowSubcommand), + + /// Launch the web UI + #[command(subcommand)] + Ui(commands::ui::UiSubcommand), + + /// Manage multi-tenancy + #[command(subcommand)] + Tenant(commands::tenant::TenantSubcommand), + + /// Explain query plans and operations + #[command(subcommand)] + Explain(commands::explain::ExplainSubcommand), +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + // Load admin API key following priority order: + // 1. MIROIR_ADMIN_API_KEY env var + // 2. ~/.config/miroir/credentials + // 3. --admin-key flag + let admin_key = + load_admin_key(cli.admin_key).map_err(|e| format!("Failed to load credentials: {}", e))?; + + if admin_key.is_none() { + eprintln!("Error: No admin API key found."); + eprintln!("Set one of:"); + eprintln!(" 1. MIROIR_ADMIN_API_KEY environment variable"); + eprintln!(" 2. ~/.config/miroir/credentials file with [default].admin_api_key"); + eprintln!(" 3. --admin-key flag (WARNING: visible in process list)"); + std::process::exit(1); + } + + // TODO: Use admin_key for API authentication when commands are implemented + let _admin_key = admin_key.unwrap(); + + match cli.command { + Commands::Status(cmd) => commands::status::run(cmd).await, + Commands::Node(cmd) => commands::node::run(cmd).await, + Commands::Rebalance(cmd) => commands::rebalance::run(cmd).await, + Commands::Reshard(cmd) => commands::reshard::run(cmd).await, + Commands::Verify(cmd) => commands::verify::run(cmd).await, + Commands::Task(cmd) => commands::task::run(cmd).await, + Commands::Dump(cmd) => commands::dump::run(cmd).await, + Commands::Alias(cmd) => commands::alias::run(cmd).await, + Commands::Canary(cmd) => commands::canary::run(cmd).await, + Commands::Ttl(cmd) => commands::ttl::run(cmd).await, + Commands::Cdc(cmd) => commands::cdc::run(cmd).await, + Commands::Shadow(cmd) => commands::shadow::run(cmd).await, + Commands::Ui(cmd) => commands::ui::run(cmd).await, + Commands::Tenant(cmd) => commands::tenant::run(cmd).await, + Commands::Explain(cmd) => commands::explain::run(cmd).await, + } +}