P0.4: Scaffold miroir-ctl crate

Add miroir-ctl management CLI with:
- clap root CLI with admin-key loading (env → credentials file → flag)
- All 15 subcommand stubs from plan §4
- Unit tests for credential loader priority order
- Clear "not yet implemented" messages pointing to tracking bead

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-18 21:01:11 -04:00
parent fe274a5c0e
commit 78e5fe1acb
20 changed files with 644 additions and 2 deletions

View file

@ -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"] }

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<String>,
},
}
pub async fn run(_cmd: DumpSubcommand) -> Result<(), Box<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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;

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<String>,
},
/// Cancel a task
Cancel {
/// Task ID
id: String,
},
}
pub async fn run(_cmd: TaskSubcommand) -> Result<(), Box<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<String>,
},
}
pub async fn run(_cmd: VerifySubcommand) -> Result<(), Box<dyn std::error::Error>> {
Err("This command is not yet implemented. See bead miroir-qon for tracking.".into())
}

View file

@ -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<CredentialsProfile>,
}
#[derive(Deserialize)]
struct CredentialsProfile {
admin_api_key: Option<String>,
}
/// 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<String>) -> Result<Option<String>, 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<Option<String>, 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<PathBuf> {
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<String>).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());
}
}

View file

@ -1 +1,3 @@
// miroir-ctl placeholder
//! miroir-ctl: Miroir management CLI library
pub mod credentials;

View file

@ -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<String>,
/// API endpoint URL (default: http://localhost:8080)
#[arg(long, global = true)]
api_url: Option<String>,
}
#[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<dyn std::error::Error>> {
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,
}
}