Compare commits
1 Commits
secrets-0.
...
secrets-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955acfe9ec |
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@@ -104,9 +104,9 @@
|
||||
"dependsOn": "build"
|
||||
},
|
||||
{
|
||||
"label": "test: inject service secrets",
|
||||
"label": "test: run service secrets",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -118,7 +118,7 @@
|
||||
{
|
||||
"label": "test: add + delete roundtrip",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -142,7 +142,7 @@
|
||||
{
|
||||
"label": "test: add with file secret",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
70
AGENTS.md
70
AGENTS.md
@@ -15,7 +15,7 @@
|
||||
secrets/
|
||||
src/
|
||||
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.toml(database_url)
|
||||
db.rs # PgPool 创建 + 建表/索引(DROP+CREATE,含所有表)
|
||||
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
|
||||
@@ -30,7 +30,7 @@ secrets/
|
||||
update.rs # update 命令:增量更新,secrets 行级 UPSERT/DELETE,CAS 并发保护
|
||||
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
|
||||
history.rs # history 命令:查看 entry 变更历史列表
|
||||
run.rs # inject / run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata)
|
||||
run.rs # run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata)
|
||||
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
|
||||
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML,含解密明文
|
||||
import_cmd.rs # import 命令:批量导入记录,冲突检测,dry-run,重新加密写入
|
||||
@@ -157,7 +157,7 @@ secrets add -n refining --kind key --name my-shared-key \
|
||||
--tag aliyun --tag hongkong \
|
||||
-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 \
|
||||
-m ip=192.0.2.1 -m key_ref=my-shared-key \
|
||||
-s username=ecs-user
|
||||
@@ -203,9 +203,9 @@ secrets init # 提示输入主密码,Argon2id 派生主密钥后存入 OS
|
||||
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
|
||||
|
||||
输出格式规则:
|
||||
- TTY(终端直接运行)→ 默认 `text`
|
||||
- 非 TTY(管道/重定向/AI 调用)→ 自动 `json-compact`
|
||||
- 显式 `-o json` → 美化 JSON
|
||||
- 默认始终输出 `json`(pretty-printed),无论 TTY 还是管道
|
||||
- 显式 `-o json-compact` → 单行 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 \
|
||||
-f metadata.url -f metadata.default_org
|
||||
|
||||
# 需要 secrets 时,改用 inject / run
|
||||
secrets inject -n refining --kind service --name gitea
|
||||
# 需要 secrets 时,改用 run
|
||||
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 10
|
||||
|
||||
# 管道 / AI 调用(非 TTY 自动 json-compact)
|
||||
# 管道 / AI 调用(默认 json,直接可解析)
|
||||
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 并执行命令
|
||||
|
||||
仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell,进程退出码透传。
|
||||
|
||||
使用 `-s/--secret` 指定只注入哪些字段(最小权限原则);使用 `--dry-run` 预览将注入哪些变量名及来源,不执行命令。
|
||||
|
||||
```bash
|
||||
# 参数说明
|
||||
# -n / --namespace refining | ricnsmart
|
||||
# --kind server | service
|
||||
# --name 记录名
|
||||
# --tag 按 tag 过滤(可重复)
|
||||
# -s / --secret 只注入指定字段名(可重复;省略则注入全部)
|
||||
# --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 aliyun \
|
||||
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
|
||||
|
||||
# 按 tag 批量注入(多条记录合并)
|
||||
secrets run --tag production -- env | grep -i token
|
||||
|
||||
# 验证注入了哪些变量
|
||||
secrets run -n refining --kind service --name gitea -- printenv
|
||||
# 预览将注入哪些变量(不执行命令,默认 JSON 输出)
|
||||
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!`
|
||||
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
|
||||
- 加密:`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
2
Cargo.lock
generated
@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.9.5"
|
||||
version = "0.9.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.9.5"
|
||||
version = "0.9.6"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
44
README.md
44
README.md
@@ -64,31 +64,35 @@ secrets search -n refining --kind service --name gitea -f metadata.url
|
||||
secrets search -n refining --kind service --name gitea \
|
||||
-f metadata.url -f metadata.default_org
|
||||
|
||||
# 需要 secrets 时,改用 inject / run
|
||||
secrets inject -n refining --kind service --name gitea
|
||||
secrets run -n refining --kind service --name gitea -- printenv
|
||||
# 需要 secrets 时,改用 run(只注入 token 字段到子进程)
|
||||
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
|
||||
|
||||
# 预览 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` |
|
||||
| 注入 secrets 到环境变量 | `inject` / `run` |
|
||||
| 人类查看 | 默认 `text`(TTY 下自动启用) |
|
||||
| 非 TTY(管道/重定向) | 自动 `json-compact` |
|
||||
| AI 解析 / 管道处理(默认) | json(pretty-printed) |
|
||||
| 管道紧凑格式 | `-o json-compact` |
|
||||
| 注入 secrets 到子进程环境 | `run` |
|
||||
| 人类查看 | `-o text` |
|
||||
|
||||
说明:`text` 输出中的时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTC(RFC3339 风格)以便脚本和 AI 稳定解析。
|
||||
默认始终输出 JSON,无论是 TTY 还是管道。`text` 输出中时间按本地时区显示;`json/json-compact` 使用 UTC(RFC3339)。
|
||||
|
||||
```bash
|
||||
# 管道直接 jq 解析(非 TTY 自动 json-compact)
|
||||
# 默认 JSON 输出,直接可 jq 解析
|
||||
secrets search -n refining --kind service | jq '.[].name'
|
||||
|
||||
# 需要 secrets 时,使用 inject / run
|
||||
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env
|
||||
secrets run -n refining --kind service --name gitea -- ./deploy.sh
|
||||
# 需要 secrets 时,使用 run(-s 指定只注入特定字段)
|
||||
secrets run -n refining --kind service --name gitea -s token -- ./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 --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
|
||||
RUST_LOG=secrets=trace secrets search
|
||||
@@ -193,7 +207,7 @@ RUST_LOG=secrets=trace secrets search
|
||||
| entries | name | 人类可读唯一标识 |
|
||||
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
|
||||
| entries | metadata | 明文描述(ip、desc、domains、key_ref 等) |
|
||||
| secrets | field_name | 明文,search 可见,AI 可推断 inject 会生成什么变量 |
|
||||
| secrets | field_name | 明文,search 可见,AI 可推断 run 会注入哪些变量 |
|
||||
| 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` 设置)。
|
||||
@@ -314,7 +328,7 @@ src/
|
||||
delete.rs # 删除(CASCADE 删除 secrets)
|
||||
update.rs # 增量更新(tags/metadata + secrets 行级 UPSERT/DELETE)
|
||||
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 自更新
|
||||
export_cmd.rs # export:批量导出,支持 JSON/TOML/YAML,含解密明文
|
||||
import_cmd.rs # import:批量导入,冲突检测,dry-run,重新加密写入
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
|
||||
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 namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub secret_fields: &'a [String],
|
||||
pub prefix: &'a str,
|
||||
pub dry_run: bool,
|
||||
pub output: OutputMode,
|
||||
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).
|
||||
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,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
prefix: &str,
|
||||
args: &CollectArgs<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> 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!(
|
||||
"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() {
|
||||
anyhow::bail!("No records matched the given filters.");
|
||||
}
|
||||
@@ -50,8 +62,17 @@ pub async fn collect_env_map(
|
||||
let mut map = HashMap::new();
|
||||
for entry in &entries {
|
||||
let empty = vec![];
|
||||
let fields = fields_map.get(&entry.id).unwrap_or(&empty);
|
||||
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?;
|
||||
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?;
|
||||
for (k, v) in row_map {
|
||||
map.insert(k, v);
|
||||
}
|
||||
@@ -59,64 +80,152 @@ pub async fn collect_env_map(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// `inject` command: print env vars to stdout.
|
||||
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let env_map = collect_env_map(
|
||||
pool,
|
||||
args.namespace,
|
||||
args.kind,
|
||||
args.name,
|
||||
args.tags,
|
||||
args.prefix,
|
||||
master_key,
|
||||
)
|
||||
.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));
|
||||
}
|
||||
}
|
||||
/// Like `collect_env_map` but also returns per-variable source info for dry-run display.
|
||||
async fn collect_env_map_with_source(
|
||||
pool: &PgPool,
|
||||
args: &CollectArgs<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<(HashMap<String, String>, Vec<EnvMapping>)> {
|
||||
if args.namespace.is_none()
|
||||
&& args.kind.is_none()
|
||||
&& args.name.is_none()
|
||||
&& 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.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `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<()> {
|
||||
if args.command.is_empty() {
|
||||
if !args.dry_run && args.command.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
|
||||
);
|
||||
}
|
||||
|
||||
let env_map = collect_env_map(
|
||||
pool,
|
||||
args.namespace,
|
||||
args.kind,
|
||||
args.name,
|
||||
args.tags,
|
||||
args.prefix,
|
||||
master_key,
|
||||
)
|
||||
.await?;
|
||||
let collect = CollectArgs {
|
||||
namespace: args.namespace,
|
||||
kind: args.kind,
|
||||
name: args.name,
|
||||
tags: args.tags,
|
||||
secret_fields: args.secret_fields,
|
||||
prefix: args.prefix,
|
||||
};
|
||||
|
||||
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!(
|
||||
vars = env_map.len(),
|
||||
@@ -137,7 +246,3 @@ pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
|
||||
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -121,11 +121,11 @@ struct PagedFetchArgs<'a> {
|
||||
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.
|
||||
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.
|
||||
pub async fn fetch_entries(
|
||||
pool: &PgPool,
|
||||
@@ -319,7 +319,7 @@ pub async fn build_injected_env_map(
|
||||
entry: &Entry,
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
fields: &[SecretField],
|
||||
fields: &[&SecretField],
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
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() => {
|
||||
let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
|
||||
println!(" secrets: {}", schema_str.join(", "));
|
||||
println!(" (use `secrets inject` or `secrets run` to get values)");
|
||||
println!(" (use `secrets run` to get values)");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
104
src/main.rs
104
src/main.rs
@@ -41,8 +41,8 @@ use output::resolve_output_mode;
|
||||
# Pipe-friendly (non-TTY defaults to json-compact automatically)
|
||||
secrets search -n refining --kind service | jq '.[].name'
|
||||
|
||||
# Inject secrets into environment variables when you really need them
|
||||
secrets inject -n refining --kind service --name gitea"
|
||||
# Run a command with secrets injected into its child process environment
|
||||
secrets run -n refining --kind service --name gitea -- printenv"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Database URL, overrides saved config (one-time override)
|
||||
@@ -132,7 +132,7 @@ EXAMPLES:
|
||||
#[arg(long = "tag")]
|
||||
tags: Vec<String>,
|
||||
/// 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')]
|
||||
meta: Vec<String>,
|
||||
/// 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 \\
|
||||
-f metadata.url -f metadata.default_org
|
||||
|
||||
# Inject decrypted secrets only when needed
|
||||
secrets inject -n refining --kind service --name gitea
|
||||
# Run a command with decrypted secrets only when needed
|
||||
secrets run -n refining --kind service --name gitea -- printenv
|
||||
|
||||
# Paginate large result sets
|
||||
@@ -392,54 +391,33 @@ EXAMPLES:
|
||||
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.
|
||||
///
|
||||
/// Secrets are available only to the child process; the current shell
|
||||
/// 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:
|
||||
# Run a script with a single service's secrets injected
|
||||
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)
|
||||
secrets run --tag production -- env | grep GITEA
|
||||
|
||||
# With prefix
|
||||
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)")]
|
||||
Run {
|
||||
#[arg(short, long)]
|
||||
@@ -450,11 +428,20 @@ EXAMPLES:
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
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)
|
||||
#[arg(long, default_value = "")]
|
||||
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
|
||||
#[arg(last = true, required = true)]
|
||||
#[arg(last = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
|
||||
@@ -770,40 +757,24 @@ async fn main() -> Result<()> {
|
||||
.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 {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
tag,
|
||||
secret_fields,
|
||||
prefix,
|
||||
dry_run,
|
||||
output,
|
||||
command,
|
||||
} => {
|
||||
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(
|
||||
&pool,
|
||||
commands::run::RunArgs {
|
||||
@@ -811,7 +782,10 @@ async fn main() -> Result<()> {
|
||||
kind: kind.as_deref(),
|
||||
name: name.as_deref(),
|
||||
tags: &tag,
|
||||
secret_fields: &secret_fields,
|
||||
prefix: &prefix,
|
||||
dry_run,
|
||||
output: out,
|
||||
command: &command,
|
||||
},
|
||||
&master_key,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use std::io::IsTerminal;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Output format for all commands.
|
||||
@@ -32,16 +31,12 @@ impl FromStr for OutputMode {
|
||||
|
||||
/// Resolve the effective output mode.
|
||||
/// - 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> {
|
||||
if let Some(s) = explicit {
|
||||
return s.parse();
|
||||
}
|
||||
if std::io::stdout().is_terminal() {
|
||||
Ok(OutputMode::Text)
|
||||
} else {
|
||||
Ok(OutputMode::JsonCompact)
|
||||
}
|
||||
Ok(OutputMode::Json)
|
||||
}
|
||||
|
||||
/// Format a UTC timestamp for local human-readable output.
|
||||
|
||||
Reference in New Issue
Block a user