feat: delete 命令支持批量删除,--name 改为可选

省略 --name 时按 namespace(+ 可选 --kind)批量删除所有匹配记录;
支持 --dry-run 预览;删除前自动快照历史并写入审计日志。
移除独立的 delete-ns 子命令,合并为统一的 delete 入口。
更新 AGENTS.md 文档,版本 bump 至 0.9.3。

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 16:31:18 +08:00
parent 66b6417faa
commit d0796e9c9a
5 changed files with 259 additions and 42 deletions

View File

@@ -373,19 +373,34 @@ secrets update -n refining --kind service --name gitea --remove-tag staging
--- ---
### delete — 删除记录 ### delete — 删除记录(支持单条精确删除与批量删除)
删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。
```bash ```bash
# 参数说明(带典型值) # 参数说明(带典型值)
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart(必填)
# --kind server | service # --kind server | service(指定 --name 时必填;批量时可选)
# --name gitea | i-example0abcd1234efgh必须精确匹配) # --name gitea | i-example0abcd1234efgh精确匹配;省略则批量删除
# --dry-run 预览将删除的记录,不实际写入(仅批量模式有效)
# -o / --output text | json | json-compact
# 删除服务凭据 # 精确删除单条记录(--kind 必填)
secrets delete -n refining --kind service --name legacy-mqtt secrets delete -n refining --kind service --name legacy-mqtt
# 删除服务器记录
secrets delete -n ricnsmart --kind server --name i-old-server-id secrets delete -n ricnsmart --kind server --name i-old-server-id
# 预览批量删除(不写入数据库)
secrets delete -n refining --dry-run
secrets delete -n ricnsmart --kind server --dry-run
# 批量删除整个 namespace 的所有记录
secrets delete -n ricnsmart
# 批量删除 namespace 下指定 kind 的所有记录
secrets delete -n ricnsmart --kind server
# JSON 输出
secrets delete -n refining --kind service -o json
``` ```
--- ---

2
Cargo.lock generated
View File

@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets" name = "secrets"
version = "0.9.2" version = "0.9.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use serde_json::json; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid;
use crate::db; use crate::db;
use crate::models::{EntryRow, SecretFieldRow}; use crate::models::{EntryRow, SecretFieldRow};
@@ -8,13 +9,50 @@ use crate::output::{OutputMode, print_json};
pub struct DeleteArgs<'a> { pub struct DeleteArgs<'a> {
pub namespace: &'a str, pub namespace: &'a str,
pub kind: &'a str, /// Kind filter. Required when --name is given; optional for bulk deletes.
pub name: &'a str, pub kind: Option<&'a str>,
/// Exact record name. When None, bulk-delete all matching records.
pub name: Option<&'a str>,
/// Preview without writing to the database (bulk mode only).
pub dry_run: bool,
pub output: OutputMode, pub output: OutputMode,
} }
// ── Internal row type used for bulk queries ────────────────────────────────
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
pub id: Uuid,
pub version: i64,
pub kind: String,
pub name: String,
pub metadata: serde_json::Value,
pub tags: Vec<String>,
}
// ── Entry point ────────────────────────────────────────────────────────────
pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> { pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
let (namespace, kind, name) = (args.namespace, args.kind, args.name); match args.name {
Some(name) => {
let kind = args
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(pool, args.namespace, kind, name, args.output).await
}
None => delete_bulk(pool, args.namespace, args.kind, args.dry_run, args.output).await,
}
}
// ── Single-record delete (original behaviour) ─────────────────────────────
async fn delete_one(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting entry"); tracing::debug!(namespace, kind, name, "deleting entry");
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
@@ -34,16 +72,174 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
tx.rollback().await?; tx.rollback().await?;
tracing::warn!(namespace, kind, name, "entry not found for deletion"); tracing::warn!(namespace, kind, name, "entry not found for deletion");
let v = json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name}); let v = json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name});
match args.output { match output {
OutputMode::Text => println!("Not found: [{}/{}] {}", namespace, kind, name), OutputMode::Text => println!("Not found: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?, ref mode => print_json(&v, mode)?,
} }
return Ok(()); return Ok(());
}; };
// Snapshot entry history before deleting. snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
let v = json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(())
}
// ── Bulk delete by namespace (+ optional kind filter) ─────────────────────
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, ?kind, dry_run, "bulk-deleting entries");
let rows: Vec<FullEntryRow> = if let Some(k) = kind {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 AND kind = $2 \
ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 \
ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
};
if rows.is_empty() {
let v = json!({
"action": "noop",
"namespace": namespace,
"kind": kind,
"deleted": 0,
"dry_run": dry_run
});
match output {
OutputMode::Text => println!(
"No records found in namespace \"{}\"{}.",
namespace,
kind.map(|k| format!(" with kind \"{}\"", k))
.unwrap_or_default()
),
ref mode => print_json(&v, mode)?,
}
return Ok(());
}
if dry_run {
let count = rows.len();
match output {
OutputMode::Text => {
println!(
"dry-run: would delete {} record(s) in namespace \"{}\":",
count, namespace
);
for r in &rows {
println!(" [{}/{}] {}", namespace, r.kind, r.name);
}
}
ref mode => {
let items: Vec<_> = rows
.iter()
.map(|r| json!({"namespace": namespace, "kind": r.kind, "name": r.name}))
.collect();
print_json(
&json!({
"action": "dry_run",
"namespace": namespace,
"kind": kind,
"would_delete": count,
"entries": items
}),
mode,
)?;
}
}
return Ok(());
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
tags: row.tags.clone(),
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
crate::audit::log_tx(
&mut tx,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(json!({"namespace": namespace, "kind": row.kind, "name": row.name}));
tracing::info!(namespace, kind = %row.kind, name = %row.name, "bulk deleted");
}
let count = deleted.len();
match output {
OutputMode::Text => {
for item in &deleted {
println!(
"Deleted: [{}/{}] {}",
item["namespace"].as_str().unwrap_or(""),
item["kind"].as_str().unwrap_or(""),
item["name"].as_str().unwrap_or("")
);
}
println!("Total: {} record(s) deleted.", count);
}
ref mode => print_json(
&json!({
"action": "deleted",
"namespace": namespace,
"kind": kind,
"deleted": count,
"entries": deleted
}),
mode,
)?,
}
Ok(())
}
// ── Shared helper: snapshot history then DELETE ────────────────────────────
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &str,
name: &str,
row: &EntryRow,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history( if let Err(e) = db::snapshot_entry_history(
&mut tx, tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
entry_id: row.id, entry_id: row.id,
namespace, namespace,
@@ -60,18 +256,17 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
tracing::warn!(error = %e, "failed to snapshot entry history before delete"); tracing::warn!(error = %e, "failed to snapshot entry history before delete");
} }
// Snapshot all secret fields before cascade delete.
let fields: Vec<SecretFieldRow> = sqlx::query_as( let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \ "SELECT id, field_name, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1", FROM secrets WHERE entry_id = $1",
) )
.bind(row.id) .bind(row.id)
.fetch_all(&mut *tx) .fetch_all(&mut **tx)
.await?; .await?;
for f in &fields { for f in &fields {
if let Err(e) = db::snapshot_secret_history( if let Err(e) = db::snapshot_secret_history(
&mut tx, tx,
db::SecretSnapshotParams { db::SecretSnapshotParams {
entry_id: row.id, entry_id: row.id,
secret_id: f.id, secret_id: f.id,
@@ -85,25 +280,14 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
) )
.await .await
{ {
tracing::warn!(error = %e, "failed to snapshot secret field history before delete"); tracing::warn!(error = %e, "failed to snapshot secret history before delete");
} }
} }
// Delete the entry — secrets rows are removed via ON DELETE CASCADE.
sqlx::query("DELETE FROM entries WHERE id = $1") sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id) .bind(row.id)
.execute(&mut *tx) .execute(&mut **tx)
.await?; .await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
let v = json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name});
match args.output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(()) Ok(())
} }

View File

@@ -211,23 +211,39 @@ EXAMPLES:
output: Option<String>, output: Option<String>,
}, },
/// Delete a record permanently. Requires exact namespace + kind + name. /// Delete one record precisely, or bulk-delete by namespace.
///
/// With --name: deletes exactly that record (--kind also required).
/// Without --name: bulk-deletes all records matching namespace + optional --kind.
/// Use --dry-run to preview bulk deletes before committing.
#[command(after_help = "EXAMPLES: #[command(after_help = "EXAMPLES:
# Delete a service credential # Delete a single record (exact match)
secrets delete -n refining --kind service --name legacy-mqtt secrets delete -n refining --kind service --name legacy-mqtt
# Delete a server record # Preview what a bulk delete would remove (no writes)
secrets delete -n ricnsmart --kind server --name i-old-server-id")] secrets delete -n refining --dry-run
# Bulk-delete all records in a namespace
secrets delete -n ricnsmart
# Bulk-delete only server records in a namespace
secrets delete -n ricnsmart --kind server
# JSON output
secrets delete -n refining --kind service -o json")]
Delete { Delete {
/// Namespace, e.g. refining /// Namespace, e.g. refining
#[arg(short, long)] #[arg(short, long)]
namespace: String, namespace: String,
/// Kind, e.g. server, service /// Kind filter, e.g. server, service (required with --name; optional for bulk)
#[arg(long)] #[arg(long)]
kind: String, kind: Option<String>,
/// Exact name of the record to delete /// Exact name of the record to delete (omit for bulk delete)
#[arg(long)] #[arg(long)]
name: String, name: Option<String>,
/// Preview what would be deleted without making any changes (bulk mode only)
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact /// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")] #[arg(short, long = "output")]
output: Option<String>, output: Option<String>,
@@ -640,17 +656,19 @@ async fn main() -> Result<()> {
namespace, namespace,
kind, kind,
name, name,
dry_run,
output, output,
} => { } => {
let _span = let _span =
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered(); tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered();
let out = resolve_output_mode(output.as_deref())?; let out = resolve_output_mode(output.as_deref())?;
commands::delete::run( commands::delete::run(
&pool, &pool,
commands::delete::DeleteArgs { commands::delete::DeleteArgs {
namespace: &namespace, namespace: &namespace,
kind: &kind, kind: kind.as_deref(),
name: &name, name: name.as_deref(),
dry_run,
output: out, output: out,
}, },
) )