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

- 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:
voson
2026-03-19 15:29:26 +08:00
parent e1cd6e736c
commit 12aec6675a
9 changed files with 755 additions and 2 deletions

109
src/commands/export_cmd.rs Normal file
View 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
View 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),
}
}

View File

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

View File

@@ -436,6 +436,83 @@ EXAMPLES:
#[arg(long)]
check: bool,
},
/// Export records to a file (JSON, TOML, or YAML).
///
/// Decrypts and exports all matched records. Requires master key unless --no-secrets is used.
#[command(after_help = "EXAMPLES:
# Export everything to JSON
secrets export --file backup.json
# Export a specific namespace to TOML
secrets export -n refining --file refining.toml
# Export a specific kind
secrets export -n refining --kind service --file services.yaml
# Export by tag
secrets export --tag production --file prod.json
# Export schema only (no decryption needed)
secrets export --no-secrets --file schema.json
# Print to stdout in YAML
secrets export -n refining --format yaml")]
Export {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter
#[arg(long)]
name: Option<String>,
/// Filter by tag (repeatable)
#[arg(long)]
tag: Vec<String>,
/// Fuzzy keyword search
#[arg(short, long)]
query: Option<String>,
/// Output file path (format inferred from extension: .json / .toml / .yaml / .yml)
#[arg(long)]
file: Option<String>,
/// Explicit format: json, toml, or yaml (overrides file extension; required for stdout)
#[arg(long)]
format: Option<String>,
/// Omit secrets from output (no master key required)
#[arg(long)]
no_secrets: bool,
},
/// Import records from a file (JSON, TOML, or YAML).
///
/// Reads an export file and inserts or updates entries. Requires master key to re-encrypt secrets.
#[command(after_help = "EXAMPLES:
# Import a JSON backup (conflict = error by default)
secrets import backup.json
# Import and overwrite existing records
secrets import --force refining.toml
# Preview what would be imported (no writes)
secrets import --dry-run backup.yaml
# JSON output for the import summary
secrets import backup.json -o json")]
Import {
/// Input file path (format inferred from extension: .json / .toml / .yaml / .yml)
file: String,
/// Overwrite existing records on conflict (default: error and abort)
#[arg(long)]
force: bool,
/// Preview operations without writing to the database
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
}
#[derive(Subcommand)]
@@ -682,6 +759,61 @@ async fn main() -> Result<()> {
)
.await?;
}
Commands::Export {
namespace,
kind,
name,
tag,
query,
file,
format,
no_secrets,
} => {
let master_key = if no_secrets {
None
} else {
Some(crypto::load_master_key()?)
};
let _span = tracing::info_span!("cmd", command = "export").entered();
commands::export_cmd::run(
&pool,
commands::export_cmd::ExportArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
query: query.as_deref(),
file: file.as_deref(),
format: format.as_deref(),
no_secrets,
},
master_key.as_ref(),
)
.await?;
}
Commands::Import {
file,
force,
dry_run,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span = tracing::info_span!("cmd", command = "import").entered();
let out = resolve_output_mode(output.as_deref())?;
commands::import_cmd::run(
&pool,
commands::import_cmd::ImportArgs {
file: &file,
force,
dry_run,
output: out,
},
&master_key,
)
.await?;
}
}
Ok(())

View File

@@ -1,6 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, …).
@@ -36,3 +37,169 @@ pub struct SecretField {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Export / Import types ──────────────────────────────────────────────────────
/// Supported file formats for export/import.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportFormat {
Json,
Toml,
Yaml,
}
impl ExportFormat {
/// Infer format from file extension (.json / .toml / .yaml / .yml).
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!(
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
other
),
}
}
/// Parse from --format CLI value.
pub fn from_str(s: &str) -> anyhow::Result<Self> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
}
}
/// Serialize ExportData to a string in this format.
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(data)?),
Self::Toml => {
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
toml::to_string_pretty(&toml_val)
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
}
Self::Yaml => serde_yaml::to_string(data)
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
}
}
/// Deserialize ExportData from a string in this format.
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
match self {
Self::Json => Ok(serde_json::from_str(content)?),
Self::Toml => {
let toml_val: toml::Value = toml::from_str(content)
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
let json_val = toml_to_json_value(&toml_val);
Ok(serde_json::from_value(json_val)?)
}
Self::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
}
}
}
/// Top-level structure for export/import files.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportData {
pub version: u32,
pub exported_at: String,
pub entries: Vec<ExportEntry>,
}
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub namespace: String,
pub kind: String,
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.
/// `null` values are filtered out (TOML does not support null).
/// Mixed-type arrays are serialised as JSON strings.
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
match v {
Value::Null => anyhow::bail!("TOML does not support null values"),
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
anyhow::bail!("unsupported number: {}", n)
}
}
Value::String(s) => Ok(toml::Value::String(s.clone())),
Value::Array(arr) => {
// Check for uniform scalar type (TOML requires homogeneous arrays at the value level,
// though arrays of tables are handled separately via TOML's [[table]] syntax).
// For simplicity we convert each element; if types are mixed, toml crate will
// handle it gracefully or we fall back to a JSON string.
let items: anyhow::Result<Vec<toml::Value>> =
arr.iter().map(json_to_toml_value).collect();
match items {
Ok(vals) => Ok(toml::Value::Array(vals)),
Err(_) => {
// Fallback: serialise as JSON string
Ok(toml::Value::String(serde_json::to_string(v)?))
}
}
}
Value::Object(map) => {
let mut toml_map = toml::map::Map::new();
for (k, val) in map {
if val.is_null() {
// Skip null entries
continue;
}
match json_to_toml_value(val) {
Ok(tv) => {
toml_map.insert(k.clone(), tv);
}
Err(_) => {
// Fallback: serialise as JSON string
toml_map
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
}
}
}
Ok(toml::Value::Table(toml_map))
}
}
}
/// Convert a toml Value back to a serde_json Value.
pub fn toml_to_json_value(v: &toml::Value) -> Value {
match v {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
toml::Value::Table(map) => {
let obj: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
.collect();
Value::Object(obj)
}
}
}