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
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:
87
src/commands/add.rs
Normal file
87
src/commands/add.rs
Normal 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
20
src/commands/delete.rs
Normal 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
3
src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod add;
|
||||
pub mod delete;
|
||||
pub mod search;
|
||||
104
src/commands/search.rs
Normal file
104
src/commands/search.rs
Normal 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
44
src/db.rs
Normal 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
132
src/main.rs
Normal 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
17
src/models.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user