feat: add export/import commands for batch backup (JSON/TOML/YAML)
Some checks failed
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m14s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Some checks failed
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m14s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
- export: filter by namespace/kind/name/tag/query, decrypt secrets, write to file or stdout - import: parse file, conflict check (error by default, --force to overwrite), --dry-run preview - Add ExportFormat enum, ExportData/ExportEntry in models.rs with TOML↔JSON conversion - Bump version to 0.9.0 Made-with: Cursor
This commit is contained in:
109
src/commands/export_cmd.rs
Normal file
109
src/commands/export_cmd.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::commands::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
use crate::crypto;
|
||||
use crate::models::{ExportData, ExportEntry, ExportFormat};
|
||||
|
||||
pub struct ExportArgs<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
/// Output file path. None means write to stdout.
|
||||
pub file: Option<&'a str>,
|
||||
/// Explicit format override (e.g. from --format flag).
|
||||
pub format: Option<&'a str>,
|
||||
/// When true, secrets are omitted and master_key is not used.
|
||||
pub no_secrets: bool,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: ExportArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
// Determine output format: --format > file extension > default JSON.
|
||||
let format = if let Some(fmt_str) = args.format {
|
||||
ExportFormat::from_str(fmt_str)?
|
||||
} else if let Some(path) = args.file {
|
||||
ExportFormat::from_extension(path).unwrap_or(ExportFormat::Json)
|
||||
} else {
|
||||
ExportFormat::Json
|
||||
};
|
||||
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
args.namespace,
|
||||
args.kind,
|
||||
args.name,
|
||||
args.tags,
|
||||
args.query,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
|
||||
let secrets_map = if !args.no_secrets && !entry_ids.is_empty() {
|
||||
fetch_secrets_for_entries(pool, &entry_ids).await?
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let key = if !args.no_secrets { master_key } else { None };
|
||||
|
||||
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
let secrets = if args.no_secrets {
|
||||
None
|
||||
} else {
|
||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if fields.is_empty() {
|
||||
Some(BTreeMap::new())
|
||||
} else {
|
||||
let mk =
|
||||
key.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
||||
let mut map = BTreeMap::new();
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
||||
map.insert(f.field_name.clone(), decrypted);
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
};
|
||||
|
||||
export_entries.push(ExportEntry {
|
||||
namespace: entry.namespace.clone(),
|
||||
kind: entry.kind.clone(),
|
||||
name: entry.name.clone(),
|
||||
tags: entry.tags.clone(),
|
||||
metadata: entry.metadata.clone(),
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
|
||||
let data = ExportData {
|
||||
version: 1,
|
||||
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
entries: export_entries,
|
||||
};
|
||||
|
||||
let serialized = format.serialize(&data)?;
|
||||
|
||||
if let Some(path) = args.file {
|
||||
std::fs::write(path, &serialized)?;
|
||||
println!(
|
||||
"Exported {} record(s) to {} ({:?})",
|
||||
data.entries.len(),
|
||||
path,
|
||||
format
|
||||
);
|
||||
} else {
|
||||
std::io::stdout().write_all(serialized.as_bytes())?;
|
||||
// Ensure trailing newline on stdout.
|
||||
if !serialized.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
233
src/commands/import_cmd.rs
Normal file
233
src/commands/import_cmd.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::commands::add::{self, AddArgs};
|
||||
use crate::models::ExportFormat;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
pub struct ImportArgs<'a> {
|
||||
pub file: &'a str,
|
||||
/// Overwrite existing records when there is a conflict (upsert).
|
||||
pub force: bool,
|
||||
/// Check and preview operations without writing to the database.
|
||||
pub dry_run: bool,
|
||||
pub output: OutputMode,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: ImportArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let format = ExportFormat::from_extension(args.file)?;
|
||||
let content = std::fs::read_to_string(args.file)
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", args.file, e))?;
|
||||
let data = format.deserialize(&content)?;
|
||||
|
||||
if data.version != 1 {
|
||||
anyhow::bail!(
|
||||
"Unsupported export version {}. Only version 1 is supported.",
|
||||
data.version
|
||||
);
|
||||
}
|
||||
|
||||
let total = data.entries.len();
|
||||
let mut inserted = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for entry in &data.entries {
|
||||
// Check if record already exists.
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3)",
|
||||
)
|
||||
.bind(&entry.namespace)
|
||||
.bind(&entry.kind)
|
||||
.bind(&entry.name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists && !args.force {
|
||||
let msg = format!(
|
||||
"[{}/{}/{}] conflict — record already exists (use --force to overwrite)",
|
||||
entry.namespace, entry.kind, entry.name
|
||||
);
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let v = serde_json::json!({
|
||||
"action": "conflict",
|
||||
"namespace": entry.namespace,
|
||||
"kind": entry.kind,
|
||||
"name": entry.name,
|
||||
});
|
||||
let s = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&v)?
|
||||
} else {
|
||||
serde_json::to_string(&v)?
|
||||
};
|
||||
eprintln!("{}", s);
|
||||
}
|
||||
_ => eprintln!("{}", msg),
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"Import aborted: conflict on [{}/{}/{}]",
|
||||
entry.namespace,
|
||||
entry.kind,
|
||||
entry.name
|
||||
));
|
||||
}
|
||||
|
||||
let action = if exists { "upsert" } else { "insert" };
|
||||
|
||||
if args.dry_run {
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let v = serde_json::json!({
|
||||
"action": action,
|
||||
"namespace": entry.namespace,
|
||||
"kind": entry.kind,
|
||||
"name": entry.name,
|
||||
"dry_run": true,
|
||||
});
|
||||
let s = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&v)?
|
||||
} else {
|
||||
serde_json::to_string(&v)?
|
||||
};
|
||||
println!("{}", s);
|
||||
}
|
||||
_ => println!(
|
||||
"[dry-run] {} [{}/{}/{}]",
|
||||
action, entry.namespace, entry.kind, entry.name
|
||||
),
|
||||
}
|
||||
if exists {
|
||||
skipped += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build secret_entries: convert BTreeMap<String, Value> to Vec<String> ("key:=json")
|
||||
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
||||
|
||||
// Build meta_entries from metadata JSON object.
|
||||
let meta_entries = build_meta_entries(&entry.metadata);
|
||||
|
||||
match add::run(
|
||||
pool,
|
||||
AddArgs {
|
||||
namespace: &entry.namespace,
|
||||
kind: &entry.kind,
|
||||
name: &entry.name,
|
||||
tags: &entry.tags,
|
||||
meta_entries: &meta_entries,
|
||||
secret_entries: &secret_entries,
|
||||
output: OutputMode::Text,
|
||||
},
|
||||
master_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let v = serde_json::json!({
|
||||
"action": action,
|
||||
"namespace": entry.namespace,
|
||||
"kind": entry.kind,
|
||||
"name": entry.name,
|
||||
});
|
||||
let s = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&v)?
|
||||
} else {
|
||||
serde_json::to_string(&v)?
|
||||
};
|
||||
println!("{}", s);
|
||||
}
|
||||
_ => println!(
|
||||
"Imported [{}/{}/{}]",
|
||||
entry.namespace, entry.kind, entry.name
|
||||
),
|
||||
}
|
||||
inserted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Error importing [{}/{}/{}]: {}",
|
||||
entry.namespace, entry.kind, entry.name, e
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let v = serde_json::json!({
|
||||
"total": total,
|
||||
"inserted": inserted,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"dry_run": args.dry_run,
|
||||
});
|
||||
let s = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&v)?
|
||||
} else {
|
||||
serde_json::to_string(&v)?
|
||||
};
|
||||
println!("{}", s);
|
||||
}
|
||||
_ => {
|
||||
if args.dry_run {
|
||||
println!(
|
||||
"\n[dry-run] {} total: {} would insert, {} would skip, {} would fail",
|
||||
total, inserted, skipped, failed
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"\nImport done: {} total — {} inserted, {} skipped, {} failed",
|
||||
total, inserted, skipped, failed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
anyhow::bail!("{} record(s) failed to import", failed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert metadata JSON object into Vec<String> of "key:=json_value" entries.
|
||||
fn build_meta_entries(metadata: &Value) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(obj) = metadata.as_object() {
|
||||
for (k, v) in obj {
|
||||
entries.push(value_to_kv_entry(k, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Convert a BTreeMap<String, Value> (secrets) into Vec<String> of "key:=json_value" entries.
|
||||
fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(map) = secrets {
|
||||
for (k, v) in map {
|
||||
entries.push(value_to_kv_entry(k, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Convert a key/value pair to a CLI-style entry string.
|
||||
/// Strings use `key=value`; everything else uses `key:=<json>`.
|
||||
fn value_to_kv_entry(key: &str, value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => format!("{}={}", key, s),
|
||||
other => format!("{}:={}", key, other),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod add;
|
||||
pub mod config;
|
||||
pub mod delete;
|
||||
pub mod export_cmd;
|
||||
pub mod import_cmd;
|
||||
pub mod init;
|
||||
pub mod rollback;
|
||||
pub mod run;
|
||||
|
||||
Reference in New Issue
Block a user