feat: secrets CLI MVP — add/search/delete with PostgreSQL JSONB
Some checks failed
Secrets CLI - Build & Release / 检查版本 (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Failing after 41s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 55s
Secrets CLI - Build & Release / 发送通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- Single `secrets` table with namespace/kind/name/tags/metadata/encrypted
- Auto-migrate on startup using uuidv7() primary keys and GIN indexes
- CLI commands: add (upsert, @file support), search (full-text + tags), delete
- Multi-platform Gitea Actions: debian (x86_64-musl), darwin-arm64, windows
  - continue-on-error + timeout-minutes=30 for offline runner tolerance
- VS Code tasks.json for local build/test/seed
- AGENTS.md for AI context

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 14:10:45 +08:00
parent 3b5e26213c
commit 102e394914
16 changed files with 3656 additions and 544 deletions

87
src/commands/add.rs Normal file
View File

@@ -0,0 +1,87 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
entry
)
})?;
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
Ok((key.to_string(), value))
}
fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
}
Ok(Value::Object(map))
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
tags: &[String],
meta_entries: &[String],
secret_entries: &[String],
) -> Result<()> {
let metadata = build_json(meta_entries)?;
let encrypted = build_json(secret_entries)?;
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
updated_at = NOW()
"#,
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(tags)
.bind(&metadata)
.bind(&encrypted)
.execute(pool)
.await?;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
let keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" metadata: {}", keys.join(", "));
}
if !secret_entries.is_empty() {
let keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" secrets: {}", keys.join(", "));
}
Ok(())
}

20
src/commands/delete.rs Normal file
View File

@@ -0,0 +1,20 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
let result = sqlx::query(
"DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(namespace)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())
}

3
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod add;
pub mod delete;
pub mod search;

104
src/commands/search.rs Normal file
View File

@@ -0,0 +1,104 @@
use anyhow::Result;
use sqlx::PgPool;
use crate::models::Secret;
pub async fn run(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
tag: Option<&str>,
query: Option<&str>,
show_secrets: bool,
) -> Result<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if tag.is_some() {
conditions.push(format!("tags @> ARRAY[${}]", idx));
idx += 1;
}
if query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
i = idx
));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY namespace, kind, name",
where_clause
);
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = namespace {
q = q.bind(v);
}
if let Some(v) = kind {
q = q.bind(v);
}
if let Some(v) = tag {
q = q.bind(v);
}
if let Some(v) = query {
q = q.bind(format!("%{}%", v));
}
let rows = q.fetch_all(pool).await?;
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
println!(
"[{}/{}] {}",
row.namespace, row.kind, row.name,
);
println!(" id: {}", row.id);
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
}
let meta_obj = row.metadata.as_object();
if let Some(m) = meta_obj {
if !m.is_empty() {
println!(" metadata: {}", serde_json::to_string_pretty(&row.metadata)?);
}
}
if show_secrets {
println!(" secrets: {}", serde_json::to_string_pretty(&row.encrypted)?);
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(" secrets: [{}] (--show-secrets to reveal)", keys.join(", "));
}
}
println!(" created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!();
}
println!("{} record(s) found.", rows.len());
Ok(())
}

44
src/db.rs Normal file
View File

@@ -0,0 +1,44 @@
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
);
-- idempotent column add for existing tables
DO $$ BEGIN
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace);
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops);
"#,
)
.execute(pool)
.await?;
Ok(())
}

132
src/main.rs Normal file
View File

@@ -0,0 +1,132 @@
mod commands;
mod db;
mod models;
use anyhow::Result;
use clap::{Parser, Subcommand};
use dotenvy::dotenv;
#[derive(Parser)]
#[command(name = "secrets", version, about = "Secrets & config manager backed by PostgreSQL")]
struct Cli {
/// Database URL (or set DATABASE_URL env var)
#[arg(long, env = "DATABASE_URL", global = true, default_value = "")]
db_url: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add or update a record (upsert)
Add {
/// Namespace (e.g. refining, ricnsmart)
#[arg(short, long)]
namespace: String,
/// Kind of record (server, service, key, ...)
#[arg(long)]
kind: String,
/// Human-readable name
#[arg(long)]
name: String,
/// Tags for categorization (repeatable)
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
},
/// Search records
Search {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind
#[arg(long)]
kind: Option<String>,
/// Filter by tag
#[arg(long)]
tag: Option<String>,
/// Search by keyword (matches name, namespace, kind)
#[arg(short, long)]
query: Option<String>,
/// Reveal encrypted secret values
#[arg(long)]
show_secrets: bool,
},
/// Delete a record
Delete {
/// Namespace
#[arg(short, long)]
namespace: String,
/// Kind
#[arg(long)]
kind: String,
/// Name
#[arg(long)]
name: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let cli = Cli::parse();
let db_url = if cli.db_url.is_empty() {
std::env::var("DATABASE_URL").map_err(|_| {
anyhow::anyhow!("DATABASE_URL not set. Use --db-url or set DATABASE_URL env var.")
})?
} else {
cli.db_url.clone()
};
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
match &cli.command {
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
} => {
commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?;
}
Commands::Search {
namespace,
kind,
tag,
query,
show_secrets,
} => {
commands::search::run(
&pool,
namespace.as_deref(),
kind.as_deref(),
tag.as_deref(),
query.as_deref(),
*show_secrets,
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
} => {
commands::delete::run(&pool, namespace, kind, name).await?;
}
}
Ok(())
}

17
src/models.rs Normal file
View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Secret {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub encrypted: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}