Files
secrets/src/commands/import_cmd.rs
voson 12aec6675a
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
feat: add export/import commands for batch backup (JSON/TOML/YAML)
- 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
2026-03-19 15:29:26 +08:00

234 lines
7.6 KiB
Rust

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),
}
}