From 955acfe9ec3d08b930eaa8e20838330e0aa8c2c0 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 17:39:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(run):=20=E9=80=89=E6=8B=A9=E6=80=A7?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=B3=A8=E5=85=A5=E3=80=81dry-run=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E3=80=81=E9=BB=98=E8=AE=A4=20JSON=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .vscode/tasks.json | 8 +- AGENTS.md | 70 +++++------- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 44 +++++--- src/commands/run.rs | 249 +++++++++++++++++++++++++++++------------ src/commands/search.rs | 10 +- src/main.rs | 104 +++++++---------- src/output.rs | 9 +- 9 files changed, 286 insertions(+), 212 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 32bce8d..70f226a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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" } ] diff --git a/AGENTS.md b/AGENTS.md index a350c2c..99c1969 100644 --- a/AGENTS.md +++ b/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 变量名前缀 -# -- 执行的命令及参数 +# --dry-run 预览变量映射,不执行命令 +# -o / --output json(默认)| json-compact | text +# -- 执行的命令及参数(--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` ## 提交前检查(必须全部通过) diff --git a/Cargo.lock b/Cargo.lock index d5e2eb6..8ecba86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.9.5" +version = "0.9.6" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index abd1946..03ee094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.9.5" +version = "0.9.6" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 6db8789..6d7d2dc 100644 --- a/README.md +++ b/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:=`,也支持 `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,重新加密写入 diff --git a/src/commands/run.rs b/src/commands/run.rs index e720696..41a9bc5 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -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> { - 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 = 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 = 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, Vec)> { + 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 = 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 = 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] -- [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('\'', "'\\''")) -} diff --git a/src/commands/search.rs b/src/commands/search.rs index b111711..babf2bd 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -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> { 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 = 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)"); } _ => {} } diff --git a/src/main.rs b/src/main.rs index 63d80a3..5ea4867 100644 --- a/src/main.rs +++ b/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, /// Plaintext metadata: key=value, key:=, key=@file, or nested:path@file. - /// Use key_ref= to reference a shared key entry (kind=key); inject/run merge its secrets. + /// Use key_ref= to reference a shared key entry (kind=key); run merges its secrets. #[arg(long = "meta", short = 'm')] meta: Vec, /// Secret entry: key=value, key:=, 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, }, - /// 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, - #[arg(long)] - kind: Option, - #[arg(long)] - name: Option, - #[arg(long)] - tag: Vec, - /// 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, - }, - /// 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, #[arg(long)] tag: Vec, + /// Only inject these secret field names (repeatable). Omit to inject all fields. + #[arg(long = "secret", short = 's')] + secret_fields: Vec, /// 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, /// Command and arguments to execute with injected environment - #[arg(last = true, required = true)] + #[arg(last = true)] command: Vec, }, @@ -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] -- [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, diff --git a/src/output.rs b/src/output.rs index deb3878..be3f239 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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 { 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.