From efa76cae55c71ec8f58096c3cb153dd257930910 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 14:27:04 +0800 Subject: [PATCH] feat(add,update): key:=json typed values, nested path for meta/secrets, bump 0.7.4 Made-with: Cursor --- AGENTS.md | 26 +++-- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 97 ++++++++++++++++- src/commands/add.rs | 235 +++++++++++++++++++++++++++++++++++------ src/commands/update.rs | 44 ++++---- src/main.rs | 52 +++++++-- 7 files changed, 386 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6bc95ea..cda542e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ secrets/ audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用) commands/ init.rs # init 命令:主密钥初始化(每台设备一次) - add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值 + add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值与嵌套路径写入 config.rs # config 命令:set-db / show / path(持久化 database_url) search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map delete.rs # delete 命令:事务化,含历史快照 @@ -238,8 +238,8 @@ secrets search -n refining --kind service --name gitea -o env \ # --kind server | service # --name gitea | i-uf63f2uookgs5uxmrdyc # --tag aliyun | hongkong(可重复) -# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复) -# -s / --secret token= | ssh_key=@./key.pem | password=secret123(可重复) +# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://... | tls:cert@./cert.pem(可重复) +# -s / --secret token= | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem(可重复) # 添加服务器 secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ @@ -258,6 +258,10 @@ secrets add -n ricnsmart --kind service --name mqtt \ -m host=mqtt.ricnsmart.com -m port=1883 \ -s password=@./mqtt_password.txt +# 多行文件直接写入嵌套 secret 字段 +secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ + -s credentials:content@./keys/voson_shanghai_e.pem + # 使用类型化值(key:=)存储非字符串类型 secrets add -n refining --kind service --name prometheus \ -m scrape_interval:=15 \ @@ -279,10 +283,10 @@ secrets add -n refining --kind service --name prometheus \ # --name gitea | i-uf63f2uookgs5uxmrdyc # --add-tag production | backup(不影响已有 tag,可重复) # --remove-tag staging | deprecated(可重复) -# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复) -# --remove-meta old_port | legacy_key(删除 metadata 字段,可重复) -# -s / --secret token= | ssh_key=@./new.pem(新增或覆盖,可重复) -# --remove-secret old_password | deprecated_key(删除 secret 字段,可重复) +# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root(新增或覆盖,可重复) +# --remove-meta old_port | legacy_key | credentials:content(删除 metadata 字段,可重复) +# -s / --secret token= | ssh_key=@./new.pem | credentials:content@./new.pem(新增或覆盖,可重复) +# --remove-secret old_password | deprecated_key | credentials:content(删除 secret 字段,可重复) # 更新单个 metadata 字段 secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ @@ -301,6 +305,14 @@ secrets update -n refining --kind service --name gitea \ secrets update -n refining --kind service --name mqtt \ --remove-meta old_port --remove-secret old_password +# 从文件更新嵌套 secret 字段 +secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ + -s credentials:content@./keys/voson_shanghai_e.pem + +# 删除嵌套字段 +secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ + --remove-secret credentials:content + # 移除 tag secrets update -n refining --kind service --name gitea --remove-tag staging ``` diff --git a/Cargo.lock b/Cargo.lock index c7ad304..457e8c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c000f2a..09aee0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.7.3" +version = "0.7.4" edition = "2024" [dependencies] diff --git a/README.md b/README.md index bd96337..49d1f68 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,17 @@ secrets add -n refining --kind server --name my-server \ -m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \ -s username=root -s ssh_key=@./keys/server.pem +# 多行文件直接写入嵌套 secret 字段 +secrets add -n refining --kind server --name my-server \ + -s credentials:content@./keys/server.pem + +# 使用 typed JSON 写入 secret(布尔、数字、数组、对象) +secrets add -n refining --kind service --name deploy-bot \ + -s enabled:=true \ + -s retry_count:=3 \ + -s scopes:='["repo","workflow"]' \ + -s extra:='{"region":"ap-east-1","verify_tls":true}' + secrets add -n refining --kind service --name gitea \ --tag gitea \ -m url=https://gitea.refining.dev -m default_org=refining \ @@ -135,6 +146,7 @@ secrets add -n refining --kind service --name gitea \ secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 secrets update -n refining --kind service --name gitea --add-tag production -s token= secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key +secrets update -n refining --kind server --name my-server --remove-secret credentials:content # ── delete ─────────────────────────────────────────────────────────────────── secrets delete -n refining --kind service --name legacy-mqtt @@ -169,7 +181,90 @@ RUST_LOG=secrets=trace secrets search | `metadata` | 明文描述信息(ip、desc、domains 等) | | `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 | -`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。加解密使用主密钥(由 `secrets init` 设置)。 +`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`。支持 `key=value`、`key=@file`、`key:=`,也支持 `credentials:content@./key.pem` 这种嵌套字段文件写入语法,避免手动转义多行文本;删除时也支持 `--remove-secret credentials:content` 和 `--remove-meta credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。 + +### `-m` / `--meta` JSON 语法速查 + +`-m` 和 `-s` 走的是同一套解析规则,只是写入位置不同:`-m` 写到明文 `metadata`,适合端口、开关、标签、描述性配置等非敏感信息。 + +| 目标值 | 写法示例 | 实际存入 | +|------|------|------| +| 普通字符串 | `-m url=https://gitea.refining.dev` | `"https://gitea.refining.dev"` | +| 文件内容字符串 | `-m notes=@./service-notes.txt` | `"..."` | +| 布尔值 | `-m enabled:=true` | `true` | +| 数字 | `-m port:=3000` | `3000` | +| `null` | `-m deprecated_at:=null` | `null` | +| 数组 | `-m domains:='["gitea.refining.dev","git.refining.dev"]'` | `["gitea.refining.dev","git.refining.dev"]` | +| 对象 | `-m tls:='{"enabled":true,"redirect_http":true}'` | `{"enabled":true,"redirect_http":true}` | +| 嵌套路径 + JSON | `-m deploy:strategy:='{"type":"rolling","batch":2}'` | `{"deploy":{"strategy":{"type":"rolling","batch":2}}}` | + +常见规则: + +- `=` 表示按字符串存储。 +- `:=` 表示按 JSON 解析。 +- shell 中数组和对象建议整体用单引号包住。 +- 嵌套字段继续用冒号分隔:`-m runtime:max_open_conns:=20`。 + +示例:新增一条带 typed metadata 的记录 + +```bash +secrets add -n refining --kind service --name gitea \ + -m url=https://gitea.refining.dev \ + -m port:=3000 \ + -m enabled:=true \ + -m domains:='["gitea.refining.dev","git.refining.dev"]' \ + -m tls:='{"enabled":true,"redirect_http":true}' +``` + +示例:更新已有记录中的嵌套 metadata + +```bash +secrets update -n refining --kind service --name gitea \ + -m deploy:strategy:='{"type":"rolling","batch":2}' \ + -m runtime:max_open_conns:=20 +``` + +### `-s` / `--secret` JSON 语法速查 + +当你希望写入的不是普通字符串,而是 `true`、`123`、`null`、数组或对象时,用 `:=`,右侧按 JSON 解析。 + +| 目标值 | 写法示例 | 实际存入 | +|------|------|------| +| 普通字符串 | `-s token=abc123` | `"abc123"` | +| 文件内容字符串 | `-s ssh_key=@./id_ed25519` | `"-----BEGIN ..."` | +| 布尔值 | `-s enabled:=true` | `true` | +| 数字 | `-s retry_count:=3` | `3` | +| `null` | `-s deprecated_at:=null` | `null` | +| 数组 | `-s scopes:='["repo","workflow"]'` | `["repo","workflow"]` | +| 对象 | `-s extra:='{"region":"ap-east-1","verify_tls":true}'` | `{"region":"ap-east-1","verify_tls":true}` | +| 嵌套路径 + JSON | `-s auth:policy:='{"mfa":true,"ttl":3600}'` | `{"auth":{"policy":{"mfa":true,"ttl":3600}}}` | + +常见规则: + +- `=` 表示按字符串存储,不做 JSON 解析。 +- `:=` 表示按 JSON 解析,适合布尔、数字、数组、对象、`null`。 +- shell 里对象和数组通常要整体加引号,推荐单引号:`-s flags:='["a","b"]'`。 +- 嵌套字段继续用冒号分隔:`-s credentials:enabled:=true`。 +- 如果你就是想存一个“JSON 字符串字面量”,可以写成 `-s note:='"hello"'`,但大多数字符串场景直接用 `=` 更直观。 + +示例:新增一条同时包含字符串、文件、布尔、数组、对象的记录 + +```bash +secrets add -n refining --kind service --name deploy-bot \ + -s token=abc123 \ + -s ssh_key=@./keys/deploy-bot.pem \ + -s enabled:=true \ + -s scopes:='["repo","workflow"]' \ + -s policy:='{"ttl":3600,"mfa":true}' +``` + +示例:更新已有记录中的嵌套 JSON 字段 + +```bash +secrets update -n refining --kind service --name deploy-bot \ + -s auth:config:='{"issuer":"gitea","rotate":true}' \ + -s auth:retry:=5 +``` ## 审计日志 diff --git a/src/commands/add.rs b/src/commands/add.rs index 4dcfe69..9b1022b 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -7,11 +7,13 @@ use crate::crypto; use crate::db; use crate::output::OutputMode; -/// Parse "key=value" or "key:=" entries. -/// - `key=value` → stores the literal string `value` -/// - `key:=` → parses `` as a typed JSON value (number, bool, null, array, object) -/// - `value=@file` → reads the file content as a string (only for `=` form) -pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> { +/// Parse secret / metadata entries into a nested key path and JSON value. +/// - `key=value` → stores the literal string `value` +/// - `key:=` → parses `` as a typed JSON value +/// - `key=@file` → reads the file content as a string +/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }` +/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping +pub(crate) fn parse_kv(entry: &str) -> Result<(Vec, Value)> { // Typed JSON form: key:= if let Some((key, json_str)) = entry.split_once(":=") { let val: Value = serde_json::from_str(json_str).map_err(|e| { @@ -21,36 +23,141 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> { e ) })?; - return Ok((key.to_string(), val)); + return Ok((parse_key_path(key)?, val)); } // Plain string form: key=value or key=@file - let (key, raw_val) = entry.split_once('=').ok_or_else(|| { - anyhow::anyhow!( - "Invalid format '{}'. Expected: key=value, key=@file, or key:=", - entry - ) - })?; + if let Some((key, raw_val)) = entry.split_once('=') { + let value = if let Some(path) = raw_val.strip_prefix('@') { + fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))? + } else { + raw_val.to_string() + }; - let value = if let Some(path) = raw_val.strip_prefix('@') { - fs::read_to_string(path) - .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))? - } else { - raw_val.to_string() - }; + return Ok((parse_key_path(key)?, Value::String(value))); + } - Ok((key.to_string(), Value::String(value))) + // Shorthand file form: nested:key@file + if let Some((key, path)) = entry.split_once('@') { + let value = fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?; + return Ok((parse_key_path(key)?, Value::String(value))); + } + + anyhow::bail!( + "Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=", + entry + ) } pub(crate) fn build_json(entries: &[String]) -> Result { let mut map = Map::new(); for entry in entries { - let (key, value) = parse_kv(entry)?; - map.insert(key, value); + let (path, value) = parse_kv(entry)?; + insert_path(&mut map, &path, value)?; } Ok(Value::Object(map)) } +pub(crate) fn key_path_to_string(path: &[String]) -> String { + path.join(":") +} + +pub(crate) fn collect_key_paths(entries: &[String]) -> Result> { + entries + .iter() + .map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path))) + .collect() +} + +pub(crate) fn collect_field_paths(entries: &[String]) -> Result> { + entries + .iter() + .map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path))) + .collect() +} + +pub(crate) fn parse_key_path(key: &str) -> Result> { + let path: Vec = key + .split(':') + .map(str::trim) + .map(ToOwned::to_owned) + .collect(); + + if path.is_empty() || path.iter().any(|part| part.is_empty()) { + anyhow::bail!( + "Invalid key path '{}'. Use non-empty segments like 'credentials:content'.", + key + ); + } + + Ok(path) +} + +pub(crate) fn insert_path( + map: &mut Map, + path: &[String], + value: Value, +) -> Result<()> { + if path.is_empty() { + anyhow::bail!("Key path cannot be empty"); + } + + if path.len() == 1 { + map.insert(path[0].clone(), value); + return Ok(()); + } + + let head = path[0].clone(); + let tail = &path[1..]; + + match map.entry(head.clone()) { + serde_json::map::Entry::Vacant(entry) => { + let mut child = Map::new(); + insert_path(&mut child, tail, value)?; + entry.insert(Value::Object(child)); + } + serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() { + Value::Object(child) => insert_path(child, tail, value)?, + _ => { + anyhow::bail!( + "Cannot set nested key '{}' because '{}' is already a non-object value", + key_path_to_string(path), + head + ); + } + }, + } + + Ok(()) +} + +pub(crate) fn remove_path(map: &mut Map, path: &[String]) -> Result { + if path.is_empty() { + anyhow::bail!("Key path cannot be empty"); + } + + if path.len() == 1 { + return Ok(map.remove(&path[0]).is_some()); + } + + let Some(value) = map.get_mut(&path[0]) else { + return Ok(false); + }; + + let Value::Object(child) = value else { + return Ok(false); + }; + + let removed = remove_path(child, &path[1..])?; + if child.is_empty() { + map.remove(&path[0]); + } + + Ok(removed) +} + pub struct AddArgs<'a> { pub namespace: &'a str, pub kind: &'a str, @@ -68,16 +175,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res tracing::debug!(args.namespace, args.kind, args.name, "upserting record"); - let meta_keys: Vec<&str> = args - .meta_entries - .iter() - .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k)) - .collect(); - let secret_keys: Vec<&str> = args - .secret_entries - .iter() - .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k)) - .collect(); + let meta_keys = collect_key_paths(args.meta_entries)?; + let secret_keys = collect_key_paths(args.secret_entries)?; let mut tx = pool.begin().await?; @@ -191,3 +290,77 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res Ok(()) } + +#[cfg(test)] +mod tests { + use super::{build_json, key_path_to_string, parse_kv, remove_path}; + use serde_json::Value; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt")) + } + + #[test] + fn parse_nested_file_shorthand() { + let path = temp_file_path("ssh-key"); + fs::write(&path, "line1\nline2\n").expect("should write temp file"); + + let entry = format!("credentials:content@{}", path.display()); + let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand"); + + assert_eq!(key_path_to_string(&path_parts), "credentials:content"); + assert_eq!(value, serde_json::Value::String("line1\nline2\n".into())); + + fs::remove_file(path).expect("should remove temp file"); + } + + #[test] + fn build_nested_json_from_mixed_entries() { + let payload = vec![ + "credentials:type=ssh".to_string(), + "credentials:enabled:=true".to_string(), + "username=root".to_string(), + ]; + + let value = build_json(&payload).expect("should build nested json"); + + assert_eq!( + value, + serde_json::json!({ + "credentials": { + "type": "ssh", + "enabled": true + }, + "username": "root" + }) + ); + } + + #[test] + fn remove_nested_path_prunes_empty_parents() { + let mut value = serde_json::json!({ + "credentials": { + "content": "pem-data" + }, + "username": "root" + }); + + let map = match &mut value { + Value::Object(map) => map, + _ => panic!("expected object"), + }; + + let removed = remove_path(map, &["credentials".to_string(), "content".to_string()]) + .expect("should remove nested field"); + + assert!(removed); + assert_eq!(value, serde_json::json!({ "username": "root" })); + } +} diff --git a/src/commands/update.rs b/src/commands/update.rs index 3cb6ecf..772a26b 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -3,7 +3,9 @@ use serde_json::{Map, Value, json}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -use super::add::parse_kv; +use super::add::{ + collect_field_paths, collect_key_paths, insert_path, parse_key_path, parse_kv, remove_path, +}; use crate::crypto; use crate::db; use crate::output::OutputMode; @@ -89,11 +91,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> _ => Map::new(), }; for entry in args.meta_entries { - let (key, value) = parse_kv(entry)?; - meta_map.insert(key, value); + let (path, value) = parse_kv(entry)?; + insert_path(&mut meta_map, &path, value)?; } for key in args.remove_meta { - meta_map.remove(key); + let path = parse_key_path(key)?; + remove_path(&mut meta_map, &path)?; } let metadata = Value::Object(meta_map); @@ -108,11 +111,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> _ => Map::new(), }; for entry in args.secret_entries { - let (key, value) = parse_kv(entry)?; - enc_map.insert(key, value); + let (path, value) = parse_kv(entry)?; + insert_path(&mut enc_map, &path, value)?; } for key in args.remove_secrets { - enc_map.remove(key); + let path = parse_key_path(key)?; + remove_path(&mut enc_map, &path)?; } let secret_json = Value::Object(enc_map); let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?; @@ -148,16 +152,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> ); } - let meta_keys: Vec<&str> = args - .meta_entries - .iter() - .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k)) - .collect(); - let secret_keys: Vec<&str> = args - .secret_entries - .iter() - .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k)) - .collect(); + let meta_keys = collect_key_paths(args.meta_entries)?; + let remove_meta_keys = collect_field_paths(args.remove_meta)?; + let secret_keys = collect_key_paths(args.secret_entries)?; + let remove_secret_keys = collect_field_paths(args.remove_secrets)?; crate::audit::log_tx( &mut tx, @@ -169,9 +167,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> "add_tags": args.add_tags, "remove_tags": args.remove_tags, "meta_keys": meta_keys, - "remove_meta": args.remove_meta, + "remove_meta": remove_meta_keys, "secret_keys": secret_keys, - "remove_secrets": args.remove_secrets, + "remove_secrets": remove_secret_keys, }), ) .await; @@ -186,9 +184,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> "add_tags": args.add_tags, "remove_tags": args.remove_tags, "meta_keys": meta_keys, - "remove_meta": args.remove_meta, + "remove_meta": remove_meta_keys, "secret_keys": secret_keys, - "remove_secrets": args.remove_secrets, + "remove_secrets": remove_secret_keys, }); match args.output { @@ -210,13 +208,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> println!(" +metadata: {}", meta_keys.join(", ")); } if !args.remove_meta.is_empty() { - println!(" -metadata: {}", args.remove_meta.join(", ")); + println!(" -metadata: {}", remove_meta_keys.join(", ")); } if !args.secret_entries.is_empty() { println!(" +secrets: {}", secret_keys.join(", ")); } if !args.remove_secrets.is_empty() { - println!(" -secrets: {}", args.remove_secrets.join(", ")); + println!(" -secrets: {}", remove_secret_keys.join(", ")); } } } diff --git a/src/main.rs b/src/main.rs index dd35749..7fb1ac4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,10 +85,28 @@ EXAMPLES: -m url=https://gitea.refining.dev -m default_org=refining \\ -s token= + # Add typed JSON metadata + secrets add -n refining --kind service --name gitea \\ + -m port:=3000 \\ + -m enabled:=true \\ + -m domains:='[\"gitea.refining.dev\",\"git.refining.dev\"]' \\ + -m tls:='{\"enabled\":true,\"redirect_http\":true}' + # Add with token read from a file secrets add -n ricnsmart --kind service --name mqtt \\ -m host=mqtt.ricnsmart.com -m port=1883 \\ - -s password=@./mqtt_password.txt")] + -s password=@./mqtt_password.txt + + # Add typed JSON secrets + secrets add -n refining --kind service --name deploy-bot \\ + -s enabled:=true \\ + -s retry_count:=3 \\ + -s scopes:='[\"repo\",\"workflow\"]' \\ + -s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}' + + # Write a multiline file into a nested secret field + secrets add -n refining --kind server --name my-server \\ + -s credentials:content@./keys/server.pem")] Add { /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] @@ -102,10 +120,10 @@ EXAMPLES: /// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong #[arg(long = "tag")] tags: Vec, - /// Plaintext metadata: key=value (repeatable; value=@file reads from file) + /// Plaintext metadata: key=value, key:=, key=@file, or nested:path@file #[arg(long = "meta", short = 'm')] meta: Vec, - /// Secret entry: key=value (repeatable; value=@file reads from file) + /// Secret entry: key=value, key:=, key=@file, or nested:path@file #[arg(long = "secret", short = 's')] secrets: Vec, /// Output format: text (default on TTY), json, json-compact, env @@ -227,6 +245,11 @@ EXAMPLES: # Rotate a secret token secrets update -n refining --kind service --name gitea -s token= + # Update typed JSON metadata + secrets update -n refining --kind service --name gitea \\ + -m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\ + -m runtime:max_open_conns:=20 + # Add a tag and rotate password at the same time secrets update -n refining --kind service --name gitea \\ --add-tag production -s token= @@ -235,8 +258,21 @@ EXAMPLES: secrets update -n refining --kind service --name mqtt \\ --remove-meta old_port --remove-secret old_password + # Remove a nested field + secrets update -n refining --kind server --name my-server \\ + --remove-secret credentials:content + # Remove a tag - secrets update -n refining --kind service --name gitea --remove-tag staging")] + secrets update -n refining --kind service --name gitea --remove-tag staging + + # Update a nested secret field from a file + secrets update -n refining --kind server --name my-server \\ + -s credentials:content@./keys/server.pem + + # Update nested typed JSON fields + secrets update -n refining --kind service --name deploy-bot \\ + -s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\ + -s auth:retry:=5")] Update { /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] @@ -253,16 +289,16 @@ EXAMPLES: /// Remove a tag (repeatable) #[arg(long = "remove-tag")] remove_tags: Vec, - /// Set or overwrite a metadata field: key=value (repeatable, @file supported) + /// Set or overwrite a metadata field: key=value, key:=, key=@file, or nested:path@file #[arg(long = "meta", short = 'm')] meta: Vec, - /// Delete a metadata field by key (repeatable) + /// Delete a metadata field by key or nested path, e.g. old_port or credentials:content #[arg(long = "remove-meta")] remove_meta: Vec, - /// Set or overwrite a secret field: key=value (repeatable, @file supported) + /// Set or overwrite a secret field: key=value, key:=, key=@file, or nested:path@file #[arg(long = "secret", short = 's')] secrets: Vec, - /// Delete a secret field by key (repeatable) + /// Delete a secret field by key or nested path, e.g. old_password or credentials:content #[arg(long = "remove-secret")] remove_secrets: Vec, /// Output format: text (default on TTY), json, json-compact