diff --git a/AGENTS.md b/AGENTS.md index 9ad6698..b316aa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,19 +373,34 @@ secrets update -n refining --kind service --name gitea --remove-tag staging --- -### delete — 删除记录 +### delete — 删除记录(支持单条精确删除与批量删除) + +删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。 ```bash # 参数说明(带典型值) -# -n / --namespace refining | ricnsmart -# --kind server | service -# --name gitea | i-example0abcd1234efgh(必须精确匹配) +# -n / --namespace refining | ricnsmart(必填) +# --kind server | service(指定 --name 时必填;批量时可选) +# --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 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 ``` --- diff --git a/Cargo.lock b/Cargo.lock index 1dc995c..3ce8fe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.9.2" +version = "0.9.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index bbf240c..f9eb5f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.9.2" +version = "0.9.3" edition = "2024" [dependencies] diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 489d35c..0913079 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -1,6 +1,7 @@ use anyhow::Result; use serde_json::json; use sqlx::PgPool; +use uuid::Uuid; use crate::db; use crate::models::{EntryRow, SecretFieldRow}; @@ -8,13 +9,50 @@ use crate::output::{OutputMode, print_json}; pub struct DeleteArgs<'a> { pub namespace: &'a str, - pub kind: &'a str, - pub name: &'a str, + /// Kind filter. Required when --name is given; optional for bulk deletes. + 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, } +// ── 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, +} + +// ── Entry point ──────────────────────────────────────────────────────────── + 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"); let mut tx = pool.begin().await?; @@ -34,16 +72,174 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> { tx.rollback().await?; tracing::warn!(namespace, kind, name, "entry not found for deletion"); 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), ref mode => print_json(&v, mode)?, } 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 = 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( - &mut tx, + tx, db::EntrySnapshotParams { entry_id: row.id, 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"); } - // Snapshot all secret fields before cascade delete. let fields: Vec = sqlx::query_as( "SELECT id, field_name, field_type, value_len, encrypted \ FROM secrets WHERE entry_id = $1", ) .bind(row.id) - .fetch_all(&mut *tx) + .fetch_all(&mut **tx) .await?; for f in &fields { if let Err(e) = db::snapshot_secret_history( - &mut tx, + tx, db::SecretSnapshotParams { entry_id: row.id, secret_id: f.id, @@ -85,25 +280,14 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> { ) .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") .bind(row.id) - .execute(&mut *tx) + .execute(&mut **tx) .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(()) } diff --git a/src/main.rs b/src/main.rs index b478657..32262a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,23 +211,39 @@ EXAMPLES: output: Option, }, - /// 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: - # Delete a service credential + # Delete a single record (exact match) secrets delete -n refining --kind service --name legacy-mqtt - # Delete a server record - secrets delete -n ricnsmart --kind server --name i-old-server-id")] + # Preview what a bulk delete would remove (no writes) + 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 { /// Namespace, e.g. refining #[arg(short, long)] namespace: String, - /// Kind, e.g. server, service + /// Kind filter, e.g. server, service (required with --name; optional for bulk) #[arg(long)] - kind: String, - /// Exact name of the record to delete + kind: Option, + /// Exact name of the record to delete (omit for bulk delete) #[arg(long)] - name: String, + name: Option, + /// 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 #[arg(short, long = "output")] output: Option, @@ -640,17 +656,19 @@ async fn main() -> Result<()> { namespace, kind, name, + dry_run, output, } => { 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())?; commands::delete::run( &pool, commands::delete::DeleteArgs { namespace: &namespace, - kind: &kind, - name: &name, + kind: kind.as_deref(), + name: name.as_deref(), + dry_run, output: out, }, )