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:
29
AGENTS.md
29
AGENTS.md
@@ -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
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.9.2"
|
version = "0.9.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/main.rs
40
src/main.rs
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user