diff --git a/Cargo.lock b/Cargo.lock index 6b888b5..0f83c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,7 @@ dependencies = [ "cfkv-blog", "clap", "cloudflare-kv", + "lazy_static", "reqwest", "serde", "serde_json", diff --git a/crates/cfkv/Cargo.toml b/crates/cfkv/Cargo.toml index 7c12731..c310356 100644 --- a/crates/cfkv/Cargo.toml +++ b/crates/cfkv/Cargo.toml @@ -23,3 +23,4 @@ thiserror.workspace = true tracing.workspace = true tracing-subscriber.workspace = true xdg = "2.5" +lazy_static = "1.4" diff --git a/crates/cfkv/src/cli.rs b/crates/cfkv/src/cli.rs index 0107ec7..745a12d 100644 --- a/crates/cfkv/src/cli.rs +++ b/crates/cfkv/src/cli.rs @@ -217,6 +217,23 @@ pub enum StorageCommands { #[arg(short, long)] name: Option, }, + + /// Export storages to a file + Export { + /// Output file path + #[arg(short, long)] + file: Option, + }, + + /// Import storages from a file + Import { + /// Input file path + #[arg(short, long)] + file: PathBuf, + }, + + /// Load storages from environment variables + LoadEnv, } #[derive(Subcommand)] diff --git a/crates/cfkv/src/config.rs b/crates/cfkv/src/config.rs index 6f5d459..5705a9a 100644 --- a/crates/cfkv/src/config.rs +++ b/crates/cfkv/src/config.rs @@ -6,6 +6,13 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +/// Format for exporting/importing storages +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct StorageExport { + pub storages: HashMap, + pub active_storage: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Storage { pub name: String, @@ -224,11 +231,86 @@ impl Config { ))) } } + + /// Export storages to JSON format + pub fn export_to_json(&self) -> Result { + let export = StorageExport { + storages: self.storages.clone(), + active_storage: self.active_storage.clone(), + }; + Ok(serde_json::to_string_pretty(&export)?) + } + + /// Import storages from JSON format + pub fn import_from_json(&mut self, json: &str) -> Result<()> { + let export: StorageExport = serde_json::from_str(json)?; + self.storages = export.storages; + self.active_storage = export.active_storage; + Ok(()) + } + + /// Load or create storages from environment variables + /// Looks for variables in the format: CFKV_STORAGE__ + /// Example: CFKV_STORAGE_PROD_ACCOUNT_ID, CFKV_STORAGE_PROD_NAMESPACE_ID, CFKV_STORAGE_PROD_API_TOKEN + pub fn load_from_env() -> Result> { + let mut storages = HashMap::new(); + let mut storage_names = std::collections::HashSet::new(); + + // Scan environment variables for CFKV_STORAGE_* pattern + for (key, _value) in std::env::vars() { + if key.starts_with("CFKV_STORAGE_") { + let parts: Vec<&str> = key.split('_').collect(); + if parts.len() >= 4 { + // Format: CFKV_STORAGE__ + let storage_name = parts[2].to_lowercase(); + storage_names.insert(storage_name); + } + } + } + + // Load each storage + for storage_name in storage_names { + let account_id_key = format!("CFKV_STORAGE_{}_ACCOUNT_ID", storage_name.to_uppercase()); + let namespace_id_key = + format!("CFKV_STORAGE_{}_NAMESPACE_ID", storage_name.to_uppercase()); + let api_token_key = format!("CFKV_STORAGE_{}_API_TOKEN", storage_name.to_uppercase()); + + if let (Ok(account_id), Ok(namespace_id), Ok(api_token)) = ( + std::env::var(&account_id_key), + std::env::var(&namespace_id_key), + std::env::var(&api_token_key), + ) { + let storage = Storage { + name: storage_name.clone(), + account_id, + namespace_id, + api_token, + }; + storages.insert(storage_name, storage); + } + } + + Ok(storages) + } + + /// Merge environment variable storages with existing config + pub fn merge_from_env(&mut self) -> Result<()> { + let env_storages = Self::load_from_env()?; + for (name, storage) in env_storages { + self.storages.insert(name, storage); + } + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + lazy_static::lazy_static! { + static ref ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); + } #[test] fn test_config_default() { @@ -391,4 +473,131 @@ mod tests { let deserialized: Config = serde_json::from_str(&json).unwrap(); assert_eq!(config.storages.len(), deserialized.storages.len()); } + + #[test] + fn test_export_to_json() { + let mut config = Config::default(); + config.add_storage( + "prod".to_string(), + "acc123".to_string(), + "ns456".to_string(), + "token789".to_string(), + ); + config.add_storage( + "dev".to_string(), + "acc999".to_string(), + "ns999".to_string(), + "token999".to_string(), + ); + config.set_active_storage("prod".to_string()).unwrap(); + + let json = config.export_to_json().unwrap(); + assert!(json.contains("prod")); + assert!(json.contains("dev")); + assert!(json.contains("active_storage")); + } + + #[test] + fn test_import_from_json() { + let json = r#"{ + "storages": { + "prod": { + "name": "prod", + "account_id": "acc123", + "namespace_id": "ns456", + "api_token": "token789" + }, + "dev": { + "name": "dev", + "account_id": "acc999", + "namespace_id": "ns999", + "api_token": "token999" + } + }, + "active_storage": "prod" + }"#; + + let mut config = Config::default(); + config.import_from_json(json).unwrap(); + + assert_eq!(config.storages.len(), 2); + assert_eq!(config.active_storage, Some("prod".to_string())); + assert!(config.get_storage("prod").is_some()); + assert!(config.get_storage("dev").is_some()); + } + + #[test] + fn test_load_from_env() { + let _guard = ENV_TEST_LOCK.lock().unwrap(); + + // Use a unique storage name to avoid conflicts with other tests + let unique_name = format!("testload{}", std::process::id()); + let upper_name = unique_name.to_uppercase(); + + // This test requires environment variables to be set + // In a real scenario, these would be set by the shell + std::env::set_var( + format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name), + "test_acc", + ); + std::env::set_var( + format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name), + "test_ns", + ); + std::env::set_var( + format!("CFKV_STORAGE_{}_API_TOKEN", upper_name), + "test_token", + ); + + let storages = Config::load_from_env().unwrap(); + assert!(storages.contains_key(&unique_name)); + + let storage = storages.get(&unique_name).unwrap(); + assert_eq!(storage.account_id, "test_acc"); + assert_eq!(storage.namespace_id, "test_ns"); + assert_eq!(storage.api_token, "test_token"); + + // Cleanup + std::env::remove_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name)); + std::env::remove_var(format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name)); + std::env::remove_var(format!("CFKV_STORAGE_{}_API_TOKEN", upper_name)); + } + + #[test] + fn test_merge_from_env() { + let _guard = ENV_TEST_LOCK.lock().unwrap(); + + // Use a unique storage name to avoid conflicts with other tests + let unique_name = format!("testmerge{}", std::process::id()); + let upper_name = unique_name.to_uppercase(); + + std::env::set_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name), "env_acc"); + std::env::set_var( + format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name), + "env_ns", + ); + std::env::set_var( + format!("CFKV_STORAGE_{}_API_TOKEN", upper_name), + "env_token", + ); + + let mut config = Config::default(); + config.add_storage( + "prod".to_string(), + "prod_acc".to_string(), + "prod_ns".to_string(), + "prod_token".to_string(), + ); + + config.merge_from_env().unwrap(); + + assert_eq!(config.storages.len(), 2); + assert!(config.get_storage("prod").is_some()); + assert!(config.get_storage(&unique_name).is_some()); + + // Cleanup + std::env::remove_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name)); + std::env::remove_var(format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name)); + std::env::remove_var(format!("CFKV_STORAGE_{}_API_TOKEN", upper_name)); + } } diff --git a/crates/cfkv/src/main.rs b/crates/cfkv/src/main.rs index 0959444..107eb4d 100644 --- a/crates/cfkv/src/main.rs +++ b/crates/cfkv/src/main.rs @@ -552,6 +552,57 @@ async fn handle_storage_command( }; println!("{}", output); } + StorageCommands::Export { file } => { + let json = config.export_to_json()?; + + if let Some(output_path) = file { + fs::write(&output_path, &json)?; + println!( + "{}", + Formatter::format_success( + &format!("Storages exported to '{}'", output_path.display()), + format + ) + ); + } else { + println!("{}", json); + } + } + StorageCommands::Import { file } => { + let json = fs::read_to_string(&file)?; + config.import_from_json(&json)?; + config.save(config_path)?; + println!( + "{}", + Formatter::format_success( + &format!("Storages imported from '{}'", file.display()), + format + ) + ); + } + StorageCommands::LoadEnv => { + config.merge_from_env()?; + config.save(config_path)?; + let env_storages = config::Config::load_from_env()?; + if env_storages.is_empty() { + println!( + "{}", + Formatter::format_text("No storages found in environment variables", format) + ); + } else { + let count = env_storages.len(); + println!( + "{}", + Formatter::format_success( + &format!("Loaded {} storage(ies) from environment variables", count), + format + ) + ); + for (name, _) in env_storages { + println!(" - {}", name); + } + } + } } Ok(()) diff --git a/docs/STORAGE_PERSISTENCE.md b/docs/STORAGE_PERSISTENCE.md new file mode 100644 index 0000000..c5129de --- /dev/null +++ b/docs/STORAGE_PERSISTENCE.md @@ -0,0 +1,283 @@ +# Storage Persistence and Configuration + +This guide covers how to persist your storage configurations, back them up, and load them across different environments using export/import and environment variables. + +## Overview + +The cfkv CLI provides three ways to manage storage configurations persistently: + +1. **File-based Storage** - Automatically saved to your system's config directory +2. **Export/Import** - Backup and restore configurations to/from JSON files +3. **Environment Variables** - Load configurations from environment variables (useful for CI/CD and containerized environments) + +## File-based Storage (Default) + +Your configurations are automatically stored in: + +- **Unix/Linux/macOS**: `~/.config/cfkv/config.json` +- **Windows**: `%APPDATA%/cfkv/config.json` + +This is the default location and is automatically created when you add your first storage. + +### Reinstalling the Application + +After reinstalling cfkv: +1. Your config file remains in the system config directory +2. Simply reinstall cfkv and your storages will be available +3. No reconfiguration needed! + +## Export and Import + +Use export/import to backup your configurations or transfer them between machines. + +### Exporting Storages + +Export all your storages to a JSON file: + +```bash +# Export to a file +cfkv storage export --file my-storages-backup.json + +# Export to stdout (for piping) +cfkv storage export +``` + +This creates a JSON file like: + +```json +{ + "storages": { + "production": { + "name": "production", + "account_id": "account_xyz", + "namespace_id": "namespace_123", + "api_token": "token_abc" + }, + "staging": { + "name": "staging", + "account_id": "account_123", + "namespace_id": "namespace_456", + "api_token": "token_def" + } + }, + "active_storage": "production" +} +``` + +### Importing Storages + +Import configurations from a JSON file: + +```bash +cfkv storage import --file my-storages-backup.json +``` + +This will: +- Load all storages from the JSON file +- Restore the active storage setting +- Merge with existing storages (doesn't delete existing ones) +- Save to your config file + +## Environment Variables + +Load storage configurations from environment variables. This is ideal for: +- CI/CD pipelines +- Docker containers +- Secure credential management +- Temporary configurations + +### Variable Format + +Environment variables follow the pattern: + +``` +CFKV_STORAGE__= +``` + +Where: +- `` is the storage name (e.g., `PROD`, `STAGING`) +- `` is one of: `ACCOUNT_ID`, `NAMESPACE_ID`, `API_TOKEN` + +### Examples + +Define storages via environment variables: + +```bash +# Production storage +export CFKV_STORAGE_PROD_ACCOUNT_ID="account123" +export CFKV_STORAGE_PROD_NAMESPACE_ID="namespace456" +export CFKV_STORAGE_PROD_API_TOKEN="token789" + +# Staging storage +export CFKV_STORAGE_STAGING_ACCOUNT_ID="account999" +export CFKV_STORAGE_STAGING_NAMESPACE_ID="namespace999" +export CFKV_STORAGE_STAGING_API_TOKEN="token999" +``` + +### Loading from Environment + +Load all storages defined in environment variables into your config: + +```bash +cfkv storage load-env +``` + +This will: +- Scan for all `CFKV_STORAGE_*` variables +- Create storage entries for each complete set +- Merge with existing storages +- Save to your config file + +View what was loaded: + +```bash +cfkv storage list +``` + +## Use Cases + +### Backup and Restore + +Back up your storages before upgrading or switching machines: + +```bash +# Backup +cfkv storage export --file ~/.config/cfkv/backup.json + +# After reinstall +cfkv storage import --file ~/.config/cfkv/backup.json +``` + +### Docker / Containerized Environment + +Set up storages via environment variables in your Docker container: + +```dockerfile +FROM rust:latest + +# Set environment variables +ENV CFKV_STORAGE_PROD_ACCOUNT_ID=my_account +ENV CFKV_STORAGE_PROD_NAMESPACE_ID=my_namespace +ENV CFKV_STORAGE_PROD_API_TOKEN=my_token + +RUN cfkv storage load-env +``` + +### CI/CD Pipelines + +Use secrets in your GitHub Actions, GitLab CI, or similar: + +```yaml +# GitHub Actions example +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup cfkv + run: | + export CFKV_STORAGE_PROD_ACCOUNT_ID=${{ secrets.CF_ACCOUNT_ID }} + export CFKV_STORAGE_PROD_NAMESPACE_ID=${{ secrets.CF_NAMESPACE_ID }} + export CFKV_STORAGE_PROD_API_TOKEN=${{ secrets.CF_API_TOKEN }} + cfkv storage load-env +``` + +### Multiple Environments + +Manage different configurations for dev, staging, and production: + +```bash +# Development +export CFKV_STORAGE_DEV_ACCOUNT_ID="dev_account" +export CFKV_STORAGE_DEV_NAMESPACE_ID="dev_namespace" +export CFKV_STORAGE_DEV_API_TOKEN="dev_token" + +# Staging +export CFKV_STORAGE_STAGING_ACCOUNT_ID="staging_account" +export CFKV_STORAGE_STAGING_NAMESPACE_ID="staging_namespace" +export CFKV_STORAGE_STAGING_API_TOKEN="staging_token" + +# Load all +cfkv storage load-env + +# Switch between them +cfkv storage switch dev +cfkv storage switch staging +``` + +## Security Considerations + +### Protecting Export Files + +Export files contain sensitive credentials. Protect them carefully: + +```bash +# Make export file readable only by you +chmod 600 my-storages-backup.json + +# Don't commit to version control +echo "my-storages-backup.json" >> .gitignore + +# Consider encrypting the file +gpg -c my-storages-backup.json +``` + +### Environment Variables in CI/CD + +Use your CI/CD platform's secret management: + +- **GitHub**: Use Secrets in repository settings +- **GitLab**: Use CI/CD Variables in project settings +- **Jenkins**: Use credentials plugin +- **Docker**: Use secret management tools + +Never hardcode credentials in configuration files or scripts. + +### Config File Permissions + +On Unix systems, the config file is created with restrictive permissions (mode 0o600): +- Only readable/writable by the owner +- No access for group or others + +On Windows, use file permissions and access controls as appropriate. + +## Troubleshooting + +### No storages found when loading from environment + +Check that environment variables are properly set: + +```bash +# List all CFKV_STORAGE variables +env | grep CFKV_STORAGE + +# Check a specific storage +echo $CFKV_STORAGE_PROD_ACCOUNT_ID +``` + +All three fields (ACCOUNT_ID, NAMESPACE_ID, API_TOKEN) must be set for a storage to be recognized. + +### Import overwrites active storage + +Import merges with existing storages but restores the `active_storage` setting from the file. If you want to keep your current active storage: + +1. Note the current active storage: `cfkv storage current` +2. Import the file: `cfkv storage import --file backup.json` +3. Switch back if needed: `cfkv storage switch ` + +### Permissions issues on Unix + +If you get permission errors accessing the config file: + +```bash +# Fix permissions +chmod 600 ~/.config/cfkv/config.json +``` + +## Related Commands + +- `cfkv storage list` - List all storages +- `cfkv storage add` - Add a new storage +- `cfkv storage switch` - Switch active storage +- `cfkv storage show` - Show storage details +- `cfkv storage current` - Show current active storage \ No newline at end of file