feat(run): 选择性字段注入、dry-run 预览、默认 JSON 输出
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m4s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m13s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled

- run 新增 -s/--secret 字段过滤,只注入指定字段到子进程(最小权限)
- run 新增 --dry-run 模式,输出变量名与来源映射,不执行命令、不暴露值
- run 新增 -o 参数,dry-run 默认 JSON 输出
- 默认输出格式改为始终 json,移除 TTY 自动切换逻辑,-o text 供人类使用
- build_injected_env_map 签名从 &[SecretField] 改为 &[&SecretField]
- 更新 AGENTS.md、README.md、.vscode/tasks.json
- version: 0.9.5 → 0.9.6

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 17:39:09 +08:00
parent 3a5ec92bf0
commit 955acfe9ec
9 changed files with 286 additions and 212 deletions

8
.vscode/tasks.json vendored
View File

@@ -104,9 +104,9 @@
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
"label": "test: inject service secrets", "label": "test: run service secrets",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets inject -n refining --kind service --name gitea", "command": "./target/debug/secrets run -n refining --kind service --name gitea -- printenv",
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
@@ -118,7 +118,7 @@
{ {
"label": "test: add + delete roundtrip", "label": "test: add + delete roundtrip",
"type": "shell", "type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search metadata ---' && ./target/debug/secrets search -n test && echo '--- inject secrets ---' && ./target/debug/secrets inject -n test --kind demo --name roundtrip-test && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test", "command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search metadata ---' && ./target/debug/secrets search -n test && echo '--- run secrets ---' && ./target/debug/secrets run -n test --kind demo --name roundtrip-test -- printenv && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
@@ -142,7 +142,7 @@
{ {
"label": "test: add with file secret", "label": "test: add with file secret",
"type": "shell", "type": "shell",
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./test-fixtures/example-key.pem && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify inject ---' && ./target/debug/secrets inject -n test --kind key --name test-key && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key", "command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./test-fixtures/example-key.pem && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify run ---' && ./target/debug/secrets run -n test --kind key --name test-key -- printenv && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build" "dependsOn": "build"
} }
] ]

View File

@@ -15,7 +15,7 @@
secrets/ secrets/
src/ src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数 main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact output.rs # OutputMode 枚举(默认 json-o text 供人类使用
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表 db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串 crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
@@ -30,7 +30,7 @@ secrets/
update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护 update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
history.rs # history 命令:查看 entry 变更历史列表 history.rs # history 命令:查看 entry 变更历史列表
run.rs # inject / run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata run.rs # run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制 upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文 export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入 import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入
@@ -157,7 +157,7 @@ secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \ --tag aliyun --tag hongkong \
-s content=@./keys/my-shared-key.pem -s content=@./keys/my-shared-key.pem
# 2. 服务器通过 metadata.key_ref 引用(inject/run 时自动合并 key 的 secrets # 2. 服务器通过 metadata.key_ref 引用run 时自动合并 key 的 secrets
secrets add -n refining --kind server --name i-example0xyz789 \ secrets add -n refining --kind server --name i-example0xyz789 \
-m ip=192.0.2.1 -m key_ref=my-shared-key \ -m ip=192.0.2.1 -m key_ref=my-shared-key \
-s username=ecs-user -s username=ecs-user
@@ -203,9 +203,9 @@ secrets init # 提示输入主密码Argon2id 派生主密钥后存入 OS
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。** **读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则: 输出格式规则:
- TTY终端直接运行→ 默认 `text` - 默认始终输出 `json`pretty-printed无论 TTY 还是管道
- 非 TTY管道/重定向/AI 调用)→ 自动 `json-compact` - 显式 `-o json-compact` → 单行 JSON管道处理时更紧凑
- 显式 `-o json`美化 JSON - 显式 `-o text`人类可读文本格式
--- ---
@@ -253,8 +253,7 @@ secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \ secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org -f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run # 需要 secrets 时,改用 run
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea -- printenv
# 模糊关键词搜索 # 模糊关键词搜索
@@ -272,7 +271,7 @@ secrets search --tag aliyun --summary
secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10 secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact # 管道 / AI 调用(默认 json直接可解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
``` ```
@@ -438,55 +437,42 @@ secrets rollback -n refining --kind service --name gitea --to-version 3
--- ---
### inject — 输出临时环境变量
仅注入 secrets 表中的加密字段(解密后),不含 metadata。敏感值仅打印到 stdout不持久化、不写入当前 shell。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀(留空则以记录 name 作前缀)
# -o / --output text默认 KEY=VALUE| json | json-compact
# 打印单条记录的 secrets 变量KEY=VALUE 格式)
secrets inject -n refining --kind service --name gitea
# 自定义前缀
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON 格式(适合管道或脚本解析)
secrets inject -n refining --kind service --name gitea -o json
# eval 注入当前 shell谨慎使用
eval $(secrets inject -n refining --kind service --name gitea)
```
---
### run — 向子进程注入 secrets 并执行命令 ### run — 向子进程注入 secrets 并执行命令
仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。 仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
使用 `-s/--secret` 指定只注入哪些字段(最小权限原则);使用 `--dry-run` 预览将注入哪些变量名及来源,不执行命令。
```bash ```bash
# 参数说明 # 参数说明
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart
# --kind server | service # --kind server | service
# --name 记录名 # --name 记录名
# --tag 按 tag 过滤(可重复) # --tag 按 tag 过滤(可重复)
# -s / --secret 只注入指定字段名(可重复;省略则注入全部)
# --prefix 变量名前缀 # --prefix 变量名前缀
# -- <command> 执行命令及参数 # --dry-run 预览变量映射,不执行命令
# -o / --output json默认| json-compact | text
# -- <command> 执行的命令及参数(--dry-run 时可省略)
# 向脚本注入单条记录的 secrets # 注入全部 secrets 到脚本
secrets run -n refining --kind service --name gitea -- ./deploy.sh secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 只注入特定字段(最小化注入范围)
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# 按 tag 批量注入(多条记录合并) # 按 tag 批量注入(多条记录合并)
secrets run --tag production -- env | grep -i token secrets run --tag production -- env | grep -i token
# 验证注入哪些变量 # 预览将注入哪些变量(不执行命令,默认 JSON 输出)
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea --dry-run
# 配合字段过滤预览
secrets run -n refining --kind service --name gitea -s token --dry-run
# text 模式预览(人类阅读)
secrets run -n refining --kind service --name gitea --dry-run -o text
``` ```
--- ---
@@ -616,7 +602,7 @@ secrets --db-url "postgres://..." search -n refining
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断 - 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载) - 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json` - 输出:读命令通过 `OutputMode` 支持 text/json/json-compact;默认始终 `json`pretty`-o text` 供人类阅读;写命令 `add` 同样支持 `-o json`
## 提交前检查(必须全部通过) ## 提交前检查(必须全部通过)

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -64,31 +64,35 @@ secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \ secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org -f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run # 需要 secrets 时,改用 run只注入 token 字段到子进程)
secrets inject -n refining --kind service --name gitea secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
secrets run -n refining --kind service --name gitea -- printenv
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
`search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `inject` / `run`(仅注入加密字段,不含 metadata `search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `run`(仅注入加密字段到子进程,不含 metadata`-s` 指定只注入特定字段,最小化注入范围。
### 输出格式 ### 输出格式
| 场景 | 推荐命令 | | 场景 | 推荐命令 |
|------|----------| |------|----------|
| AI 解析 / 管道处理 | `-o json``-o json-compact` | | AI 解析 / 管道处理(默认) | jsonpretty-printed |
| 注入 secrets 到环境变量 | `inject` / `run` | | 管道紧凑格式 | `-o json-compact` |
| 人类查看 | 默认 `text`TTY 下自动启用) | | 注入 secrets 到子进程环境 | `run` |
| 非 TTY管道/重定向) | 自动 `json-compact` | | 人类查看 | `-o text` |
说明:`text` 输出中时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTCRFC3339 风格)以便脚本和 AI 稳定解析 默认始终输出 JSON无论是 TTY 还是管道。`text` 输出中时间本地时区显示;`json/json-compact` 使用 UTCRFC3339
```bash ```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact # 默认 JSON 输出,直接 jq 解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
# 需要 secrets 时,使用 inject / run # 需要 secrets 时,使用 run-s 指定只注入特定字段)
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
## 完整命令参考 ## 完整命令参考
@@ -177,6 +181,16 @@ secrets import backup.json # 导入(冲突时报
secrets import --force refining.toml # 冲突时覆盖已有记录 secrets import --force refining.toml # 冲突时覆盖已有记录
secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入) secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入)
# ── run ───────────────────────────────────────────────────────────────────────
secrets run -n refining --kind service --name gitea -- ./deploy.sh # 注入全部 secrets
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh # 只注入 token 字段
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances # 只注入指定字段
secrets run --tag production -- env # 按 tag 批量注入
secrets run -n refining --kind service --name gitea --dry-run # 预览变量映射
secrets run -n refining --kind service --name gitea -s token --dry-run # 过滤后预览
secrets run -n refining --kind service --name gitea --dry-run -o text # 人类可读预览
# ── 调试 ────────────────────────────────────────────────────────────────────── # ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search RUST_LOG=secrets=trace secrets search
@@ -193,7 +207,7 @@ RUST_LOG=secrets=trace secrets search
| entries | name | 人类可读唯一标识 | | entries | name | 人类可读唯一标识 |
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` | | entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| entries | metadata | 明文描述ip、desc、domains、key_ref 等) | | entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| secrets | field_name | 明文search 可见AI 可推断 inject 会生成什么变量 | | secrets | field_name | 明文search 可见AI 可推断 run 会注入哪些变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM | | secrets | encrypted | 仅加密值本身AES-256-GCM |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。 `-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
@@ -314,7 +328,7 @@ src/
delete.rs # 删除CASCADE 删除 secrets delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE
rollback.rs # rollback / history按 entry_version 恢复 rollback.rs # rollback / history按 entry_version 恢复
run.rs # inject / run仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata run.rs # run仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # 从 Gitea Release 自更新 upgrade.rs # 从 Gitea Release 自更新
export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文 export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import批量导入冲突检测dry-run重新加密写入 import_cmd.rs # import批量导入冲突检测dry-run重新加密写入

View File

@@ -1,45 +1,57 @@
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries}; use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
use crate::output::OutputMode; use crate::output::OutputMode;
pub struct InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub output: OutputMode,
}
pub struct RunArgs<'a> { pub struct RunArgs<'a> {
pub namespace: Option<&'a str>, pub namespace: Option<&'a str>,
pub kind: Option<&'a str>, pub kind: Option<&'a str>,
pub name: Option<&'a str>, pub name: Option<&'a str>,
pub tags: &'a [String], pub tags: &'a [String],
pub secret_fields: &'a [String],
pub prefix: &'a str, pub prefix: &'a str,
pub dry_run: bool,
pub output: OutputMode,
pub command: &'a [String], pub command: &'a [String],
} }
/// A single environment variable with its origin for dry-run display.
pub struct EnvMapping {
pub var_name: String,
pub source: String,
pub field: String,
}
struct CollectArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
secret_fields: &'a [String],
prefix: &'a str,
}
/// Fetch entries matching the filter and build a flat env map (decrypted secrets only, no metadata). /// Fetch entries matching the filter and build a flat env map (decrypted secrets only, no metadata).
pub async fn collect_env_map( /// If `secret_fields` is non-empty, only those fields are decrypted and included.
async fn collect_env_map(
pool: &PgPool, pool: &PgPool,
namespace: Option<&str>, args: &CollectArgs<'_>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
master_key: &[u8; 32], master_key: &[u8; 32],
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() { if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!( anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run" "At least one filter (--namespace, --kind, --name, or --tag) is required for run"
); );
} }
let entries = fetch_entries(pool, namespace, kind, name, tags, None).await?; let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() { if entries.is_empty() {
anyhow::bail!("No records matched the given filters."); anyhow::bail!("No records matched the given filters.");
} }
@@ -50,8 +62,17 @@ pub async fn collect_env_map(
let mut map = HashMap::new(); let mut map = HashMap::new();
for entry in &entries { for entry in &entries {
let empty = vec![]; let empty = vec![];
let fields = fields_map.get(&entry.id).unwrap_or(&empty); let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?; let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
for (k, v) in row_map { for (k, v) in row_map {
map.insert(k, v); map.insert(k, v);
} }
@@ -59,64 +80,152 @@ pub async fn collect_env_map(
Ok(map) Ok(map)
} }
/// `inject` command: print env vars to stdout. /// Like `collect_env_map` but also returns per-variable source info for dry-run display.
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> { async fn collect_env_map_with_source(
let env_map = collect_env_map( pool: &PgPool,
pool, args: &CollectArgs<'_>,
args.namespace, master_key: &[u8; 32],
args.kind, ) -> Result<(HashMap<String, String>, Vec<EnvMapping>)> {
args.name, if args.namespace.is_none()
args.tags, && args.kind.is_none()
args.prefix, && args.name.is_none()
master_key, && args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
let mut mappings: Vec<EnvMapping> = Vec::new();
for entry in &entries {
let empty = vec![];
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
let source = format!("{}/{}/{}", entry.namespace, entry.kind, entry.name);
for field in &filtered_fields {
let var_name = format!(
"{}_{}",
env_prefix_name(&entry.name, args.prefix),
field.field_name.to_uppercase().replace(['-', '.'], "_")
);
if row_map.contains_key(&var_name) {
mappings.push(EnvMapping {
var_name: var_name.clone(),
source: source.clone(),
field: field.field_name.clone(),
});
}
}
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok((map, mappings))
}
fn env_prefix_name(entry_name: &str, prefix: &str) -> String {
let name_part = entry_name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
) )
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
} }
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
}
Ok(())
} }
/// `run` command: inject secrets into a child process environment and execute. /// `run` command: inject secrets into a child process environment and execute.
/// With `--dry-run`, prints the variable mapping (names and sources only) without executing.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> { pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if args.command.is_empty() { if !args.dry_run && args.command.is_empty() {
anyhow::bail!( anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]" "No command specified. Usage: secrets run [filter flags] -- <command> [args]"
); );
} }
let env_map = collect_env_map( let collect = CollectArgs {
pool, namespace: args.namespace,
args.namespace, kind: args.kind,
args.kind, name: args.name,
args.name, tags: args.tags,
args.tags, secret_fields: args.secret_fields,
args.prefix, prefix: args.prefix,
master_key, };
)
.await?; if args.dry_run {
let (env_map, mappings) = collect_env_map_with_source(pool, &collect, master_key).await?;
let total_vars = env_map.len();
let total_records = {
let mut seen = std::collections::HashSet::new();
for m in &mappings {
seen.insert(&m.source);
}
seen.len()
};
match args.output {
OutputMode::Text => {
for m in &mappings {
println!("{:<40} <- {} :: {}", m.var_name, m.source, m.field);
}
println!("---");
println!(
"{} variable(s) from {} record(s).",
total_vars, total_records
);
}
OutputMode::Json | OutputMode::JsonCompact => {
let vars: Vec<_> = mappings
.iter()
.map(|m| {
json!({
"name": m.var_name,
"source": m.source,
"field": m.field,
})
})
.collect();
let out = json!({
"variables": vars,
"total_vars": total_vars,
"total_records": total_records,
});
if args.output == OutputMode::Json {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("{}", serde_json::to_string(&out)?);
}
}
}
return Ok(());
}
let env_map = collect_env_map(pool, &collect, master_key).await?;
tracing::debug!( tracing::debug!(
vars = env_map.len(), vars = env_map.len(),
@@ -137,7 +246,3 @@ pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -
Ok(()) Ok(())
} }
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -94,7 +94,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
fn validate_safe_search_args(fields: &[String]) -> Result<()> { fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) { if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!( anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.", "Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.",
field field
); );
} }
@@ -121,11 +121,11 @@ struct PagedFetchArgs<'a> {
offset: u32, offset: u32,
} }
/// A very large limit used when callers need all matching records (export, inject, run). /// A very large limit used when callers need all matching records (export, run).
/// Postgres will stop scanning when this many rows are found; adjust if needed. /// Postgres will stop scanning when this many rows are found; adjust if needed.
pub const FETCH_ALL_LIMIT: u32 = 100_000; pub const FETCH_ALL_LIMIT: u32 = 100_000;
/// Fetch entries matching the given filters (used by search, inject, run). /// Fetch entries matching the given filters (used by search, run).
/// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records. /// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records.
pub async fn fetch_entries( pub async fn fetch_entries(
pool: &PgPool, pool: &PgPool,
@@ -319,7 +319,7 @@ pub async fn build_injected_env_map(
entry: &Entry, entry: &Entry,
prefix: &str, prefix: &str,
master_key: &[u8; 32], master_key: &[u8; 32],
fields: &[SecretField], fields: &[&SecretField],
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix); let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new(); let mut map = HashMap::new();
@@ -456,7 +456,7 @@ fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> R
Some(fields) if !fields.is_empty() => { Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect(); let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
println!(" secrets: {}", schema_str.join(", ")); println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)"); println!(" (use `secrets run` to get values)");
} }
_ => {} _ => {}
} }

View File

@@ -41,8 +41,8 @@ use output::resolve_output_mode;
# Pipe-friendly (non-TTY defaults to json-compact automatically) # Pipe-friendly (non-TTY defaults to json-compact automatically)
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
# Inject secrets into environment variables when you really need them # Run a command with secrets injected into its child process environment
secrets inject -n refining --kind service --name gitea" secrets run -n refining --kind service --name gitea -- printenv"
)] )]
struct Cli { struct Cli {
/// Database URL, overrides saved config (one-time override) /// Database URL, overrides saved config (one-time override)
@@ -132,7 +132,7 @@ EXAMPLES:
#[arg(long = "tag")] #[arg(long = "tag")]
tags: Vec<String>, tags: Vec<String>,
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file. /// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key); inject/run merge its secrets. /// Use key_ref=<name> to reference a shared key entry (kind=key); run merges its secrets.
#[arg(long = "meta", short = 'm')] #[arg(long = "meta", short = 'm')]
meta: Vec<String>, meta: Vec<String>,
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file /// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
@@ -169,8 +169,7 @@ EXAMPLES:
secrets search -n refining --kind service --name gitea \\ secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org -f metadata.url -f metadata.default_org
# Inject decrypted secrets only when needed # Run a command with decrypted secrets only when needed
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets # Paginate large result sets
@@ -392,54 +391,33 @@ EXAMPLES:
output: Option<String>, output: Option<String>,
}, },
/// Print secrets as environment variables (stdout only, nothing persisted).
///
/// Outputs KEY=VALUE pairs for all matched records. Safe to pipe or eval.
#[command(after_help = "EXAMPLES:
# Print env vars for a single service
secrets inject -n refining --kind service --name gitea
# With a custom prefix
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON output (all vars as a JSON object)
secrets inject -n refining --kind service --name gitea -o json
# Eval into current shell (use with caution)
eval $(secrets inject -n refining --kind service --name gitea)
# For entries with metadata.key_ref, referenced key's secrets are merged automatically")]
Inject {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Output format: text/KEY=VALUE (default), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Run a command with secrets injected as environment variables. /// Run a command with secrets injected as environment variables.
/// ///
/// Secrets are available only to the child process; the current shell /// Secrets are available only to the child process; the current shell
/// environment is not modified. The process exit code is propagated. /// environment is not modified. The process exit code is propagated.
///
/// Use -s/--secret to inject only specific fields. Use --dry-run to preview
/// which variables would be injected without executing the command.
#[command(after_help = "EXAMPLES: #[command(after_help = "EXAMPLES:
# Run a script with a single service's secrets injected # Run a script with a single service's secrets injected
secrets run -n refining --kind service --name gitea -- ./deploy.sh secrets run -n refining --kind service --name gitea -- ./deploy.sh
# Inject only specific fields (minimal exposure)
secrets run -n refining --kind service --name aliyun \\
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# Run with a tag filter (all matched records merged) # Run with a tag filter (all matched records merged)
secrets run --tag production -- env | grep GITEA secrets run --tag production -- env | grep GITEA
# With prefix # With prefix
secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv
# Preview which variables would be injected (no command executed)
secrets run -n refining --kind service --name gitea --dry-run
# Preview with field filter and JSON output
secrets run -n refining --kind service --name gitea -s token --dry-run -o json
# metadata.key_ref entries get key secrets merged (e.g. server + shared PEM)")] # metadata.key_ref entries get key secrets merged (e.g. server + shared PEM)")]
Run { Run {
#[arg(short, long)] #[arg(short, long)]
@@ -450,11 +428,20 @@ EXAMPLES:
name: Option<String>, name: Option<String>,
#[arg(long)] #[arg(long)]
tag: Vec<String>, tag: Vec<String>,
/// Only inject these secret field names (repeatable). Omit to inject all fields.
#[arg(long = "secret", short = 's')]
secret_fields: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically) /// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
prefix: String, prefix: String,
/// Preview variables that would be injected without executing the command
#[arg(long)]
dry_run: bool,
/// Output format for --dry-run: json (default), json-compact, text
#[arg(short, long = "output")]
output: Option<String>,
/// Command and arguments to execute with injected environment /// Command and arguments to execute with injected environment
#[arg(last = true, required = true)] #[arg(last = true)]
command: Vec<String>, command: Vec<String>,
}, },
@@ -770,40 +757,24 @@ async fn main() -> Result<()> {
.await?; .await?;
} }
Commands::Inject {
namespace,
kind,
name,
tag,
prefix,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::run::run_inject(
&pool,
commands::run::InjectArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
output: out,
},
&master_key,
)
.await?;
}
Commands::Run { Commands::Run {
namespace, namespace,
kind, kind,
name, name,
tag, tag,
secret_fields,
prefix, prefix,
dry_run,
output,
command, command,
} => { } => {
let master_key = crypto::load_master_key()?; let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
if !dry_run && command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
commands::run::run_exec( commands::run::run_exec(
&pool, &pool,
commands::run::RunArgs { commands::run::RunArgs {
@@ -811,7 +782,10 @@ async fn main() -> Result<()> {
kind: kind.as_deref(), kind: kind.as_deref(),
name: name.as_deref(), name: name.as_deref(),
tags: &tag, tags: &tag,
secret_fields: &secret_fields,
prefix: &prefix, prefix: &prefix,
dry_run,
output: out,
command: &command, command: &command,
}, },
&master_key, &master_key,

View File

@@ -1,5 +1,4 @@
use chrono::{DateTime, Local, Utc}; use chrono::{DateTime, Local, Utc};
use std::io::IsTerminal;
use std::str::FromStr; use std::str::FromStr;
/// Output format for all commands. /// Output format for all commands.
@@ -32,16 +31,12 @@ impl FromStr for OutputMode {
/// Resolve the effective output mode. /// Resolve the effective output mode.
/// - Explicit value from `--output` takes priority. /// - Explicit value from `--output` takes priority.
/// - TTY → text; non-TTY (piped/redirected) → json-compact. /// - Default is always `Json` (AI-first); use `-o text` for human-readable output.
pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> { pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> {
if let Some(s) = explicit { if let Some(s) = explicit {
return s.parse(); return s.parse();
} }
if std::io::stdout().is_terminal() { Ok(OutputMode::Json)
Ok(OutputMode::Text)
} else {
Ok(OutputMode::JsonCompact)
}
} }
/// Format a UTC timestamp for local human-readable output. /// Format a UTC timestamp for local human-readable output.