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
234 lines
7.6 KiB
Rust
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),
|
|
}
|
|
}
|