From c1d86bc96db5fcfe1d61c5b537b41b75ae94036f Mon Sep 17 00:00:00 2001 From: voson Date: Wed, 18 Mar 2026 15:40:44 +0800 Subject: [PATCH] feat: add update command, bump to 0.2.0, doc version check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add secrets update: incremental merge for tags/metadata/encrypted - AGENTS.md: 提交前检查增加版本号与 git tag 说明 - README/AGENTS: update 命令文档与示例 - Cargo.toml 0.1.0 -> 0.2.0 (secrets-0.1.0 已存在) Made-with: Cursor --- .gitea/workflows/secrets.yml | 72 ++++++++++++++++---- AGENTS.md | 40 ++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 ++ src/commands/add.rs | 2 +- src/commands/mod.rs | 1 + src/commands/update.rs | 125 +++++++++++++++++++++++++++++++++++ src/main.rs | 58 ++++++++++++++++ 9 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 src/commands/update.rs diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index b789598..149b204 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -107,7 +107,7 @@ jobs: --arg tag "$tag" \ --arg name "${{ env.BINARY_NAME }} ${version}" \ --arg body "$body" \ - '{tag_name: $tag, name: $name, body: $body}') + '{tag_name: $tag, name: $name, body: $body, draft: true}') http_code=$(curl -sS -o /tmp/create-release.json -w '%{http_code}' \ -H "Authorization: token $RELEASE_TOKEN" \ @@ -120,7 +120,7 @@ jobs: fi if [ -n "$release_id" ]; then - echo "已创建 Release: ${release_id}" + echo "已创建草稿 Release: ${release_id}" echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" else echo "⚠ 创建 Release 失败 (HTTP ${http_code}),跳过产物上传" @@ -169,12 +169,15 @@ jobs: (.runners // .data // . // []) | any( ( - (.status // (if (.online // false) then "online" else "offline" end)) - | ascii_downcase - ) == "online" + (.online == true) + or ( + ((.status // "") | ascii_downcase) + | IN("online", "idle", "busy", "active") + ) + ) and ( (.labels // []) - | map(if type == "object" then (.name // .label // "") else tostring end) + | map(if type == "object" then (.name // .label // "") else tostring end | ascii_downcase) | index($label) ) != null ) @@ -182,7 +185,7 @@ jobs: } for pair in "debian:has_linux" "darwin-arm64:has_macos" "windows:has_windows"; do - label="${pair%%:*}"; key="${pair##*:}" + label="$(printf '%s' "${pair%%:*}" | tr '[:upper:]' '[:lower:]')"; key="${pair##*:}" if has_runner "$label"; then echo "${key}=true" >> "$GITHUB_OUTPUT" else @@ -228,7 +231,6 @@ jobs: if: needs.probe-runners.outputs.has_linux == 'true' runs-on: debian timeout-minutes: 1 - continue-on-error: true steps: - name: 安装依赖 run: | @@ -278,7 +280,6 @@ jobs: if: needs.probe-runners.outputs.has_macos == 'true' runs-on: darwin-arm64 timeout-minutes: 1 - continue-on-error: true steps: - name: 安装依赖 run: | @@ -326,7 +327,6 @@ jobs: if: needs.probe-runners.outputs.has_windows == 'true' runs-on: windows timeout-minutes: 1 - continue-on-error: true steps: - name: 安装依赖 shell: pwsh @@ -372,9 +372,51 @@ jobs: -Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } ` -Form @{ attachment = Get-Item $archive } + publish-release: + name: 发布草稿 Release + needs: [version, check, build-linux, build-macos, build-windows] + if: always() && needs.version.outputs.release_id != '' + runs-on: debian + timeout-minutes: 2 + steps: + - name: 发布草稿 + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + [ -z "$RELEASE_TOKEN" ] && exit 0 + + version_r="${{ needs.version.result }}" + check_r="${{ needs.check.result }}" + linux_r="${{ needs.build-linux.result }}" + macos_r="${{ needs.build-macos.result }}" + windows_r="${{ needs.build-windows.result }}" + + for result in "$version_r" "$check_r" "$linux_r" "$macos_r" "$windows_r"; do + case "$result" in + success|skipped) ;; + *) + echo "存在失败或取消的 job,保留草稿 Release" + exit 0 + ;; + esac + done + + release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}" + http_code=$(curl -sS -o /tmp/publish-release.json -w '%{http_code}' \ + -H "Authorization: token $RELEASE_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$release_api" \ + -d '{"draft":false}') + + if [ "$http_code" != "200" ]; then + echo "发布草稿 Release 失败 (HTTP ${http_code})" + cat /tmp/publish-release.json 2>/dev/null || true + exit 1 + fi + notify: name: 通知 - needs: [version, probe-runners, check, build-linux, build-macos, build-windows] + needs: [version, probe-runners, check, build-linux, build-macos, build-windows, publish-release] if: always() && github.event_name == 'push' runs-on: debian timeout-minutes: 1 @@ -399,8 +441,13 @@ jobs: linux_r="${{ needs.build-linux.result }}" macos_r="${{ needs.build-macos.result }}" windows_r="${{ needs.build-windows.result }}" + publish_r="${{ needs.publish-release.result }}" - if [ "$version_r" = "success" ] && [ "$check_r" = "success" ]; then + if [ "$version_r" = "success" ] && [ "$check_r" = "success" ] \ + && [ "$linux_r" != "failure" ] && [ "$linux_r" != "cancelled" ] \ + && [ "$macos_r" != "failure" ] && [ "$macos_r" != "cancelled" ] \ + && [ "$windows_r" != "failure" ] && [ "$windows_r" != "cancelled" ] \ + && [ "$publish_r" != "failure" ] && [ "$publish_r" != "cancelled" ]; then status="构建成功 ✅" else status="构建失败 ❌" @@ -425,6 +472,7 @@ jobs: msg="${msg} 构建结果:linux$(icon "$linux_r") macOS$(icon "$macos_r") windows$(icon "$windows_r") + Release:$(icon "$publish_r") 提交:${commit} 作者:${{ github.actor }} 详情:${url}" diff --git a/AGENTS.md b/AGENTS.md index bc47127..7d1f8c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,15 @@ secrets add -n --kind --name \ secrets search [-n ] [--kind ] [--tag ] [-q ] [--show-secrets] # -q 匹配范围:name、namespace、kind、metadata 全文内容、tags +# 增量更新已有记录(合并语义,记录不存在则报错) +secrets update -n --kind --name \ + [--add-tag ]... # 添加标签(不影响已有标签) + [--remove-tag ]... # 移除标签 + [-m key=value]... # 新增或覆盖 metadata 字段(不影响其他字段) + [--remove-meta ]... # 删除 metadata 字段 + [-s key=value]... # 新增或覆盖 encrypted 字段(不影响其他字段) + [--remove-secret ]... # 删除 encrypted 字段 + # 删除 secrets delete -n --kind --name ``` @@ -104,6 +113,19 @@ secrets search -n refining --kind service --show-secrets # 按 tag 筛选 secrets search --tag hongkong + +# 只更新一个 IP(不影响其他 metadata/secrets/tags) +secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ + -m ip=10.0.0.1 + +# 给一条记录新增 tag 并轮换密码 +secrets update -n refining --kind service --name gitea \ + --add-tag production \ + -s token= + +# 移除一个废弃的 metadata 字段 +secrets update -n refining --kind service --name mqtt \ + --remove-meta old_port ``` ## 代码规范 @@ -116,7 +138,23 @@ secrets search --tag hongkong ## 提交前检查(必须全部通过) -每次提交代码前,请在本地依次执行以下三项检查,**全部通过后再 push**: +每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**: + +### 1. 版本号(按需) + +若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。可通过 git tag 判断: + +```bash +# 查看当前 Cargo.toml 版本 +grep '^version' Cargo.toml + +# 查看是否已存在该版本对应的 tag(CI 使用格式 secrets-) +git tag -l 'secrets-*' +``` + +若当前版本已被 tag(例如已有 `secrets-0.1.0` 且 `Cargo.toml` 仍为 `0.1.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。 + +### 2. 格式、Lint、测试 ```bash cargo fmt -- --check # 格式检查(不通过则运行 cargo fmt 修复) diff --git a/Cargo.lock b/Cargo.lock index 0d98a91..b116ba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index eb853dd..ce80f52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.1.0" +version = "0.2.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 516c318..756d0c8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ secrets -h secrets help add secrets help search secrets help delete +secrets help update # 添加服务器 secrets add -n refining --kind server --name my-server \ @@ -53,6 +54,11 @@ secrets search --tag hongkong secrets search -q mqtt # 关键词匹配 name / metadata / tags secrets search -n refining --kind service --name gitea --show-secrets +# 增量更新已有记录(合并语义,记录不存在则报错) +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 delete -n refining --kind server --name my-server ``` @@ -83,6 +89,7 @@ src/ add.rs # upsert search.rs # 多条件查询 delete.rs # 删除 + update.rs # 增量更新(合并 tags/metadata/encrypted) scripts/ seed-data.sh # 导入 refining / ricnsmart 全量数据 ``` diff --git a/src/commands/add.rs b/src/commands/add.rs index e8a1de0..1a7971c 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -4,7 +4,7 @@ use sqlx::PgPool; use std::fs; /// Parse "key=value" entries. Value starting with '@' reads from file. -fn parse_kv(entry: &str) -> Result<(String, String)> { +pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> { let (key, raw_val) = entry.split_once('=').ok_or_else(|| { anyhow::anyhow!( "Invalid format '{}'. Expected: key=value or key=@file", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a4398d9..60032db 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod add; pub mod delete; pub mod search; +pub mod update; diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..e55d8f1 --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,125 @@ +use anyhow::Result; +use serde_json::{Map, Value}; +use sqlx::PgPool; + +use super::add::parse_kv; + +pub struct UpdateArgs<'a> { + pub namespace: &'a str, + pub kind: &'a str, + pub name: &'a str, + pub add_tags: &'a [String], + pub remove_tags: &'a [String], + pub meta_entries: &'a [String], + pub remove_meta: &'a [String], + pub secret_entries: &'a [String], + pub remove_secrets: &'a [String], +} + +pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> { + let row = sqlx::query!( + r#" + SELECT id, tags, metadata, encrypted + FROM secrets + WHERE namespace = $1 AND kind = $2 AND name = $3 + "#, + args.namespace, + args.kind, + args.name, + ) + .fetch_optional(pool) + .await?; + + let row = row.ok_or_else(|| { + anyhow::anyhow!( + "Not found: [{}/{}] {}. Use `add` to create it first.", + args.namespace, + args.kind, + args.name + ) + })?; + + // Merge tags + let mut tags: Vec = row.tags; + for t in args.add_tags { + if !tags.contains(t) { + tags.push(t.clone()); + } + } + tags.retain(|t| !args.remove_tags.contains(t)); + + // Merge metadata + let mut meta_map: Map = match row.metadata { + Value::Object(m) => m, + _ => Map::new(), + }; + for entry in args.meta_entries { + let (key, value) = parse_kv(entry)?; + meta_map.insert(key, Value::String(value)); + } + for key in args.remove_meta { + meta_map.remove(key); + } + let metadata = Value::Object(meta_map); + + // Merge encrypted + let mut enc_map: Map = match row.encrypted { + Value::Object(m) => m, + _ => Map::new(), + }; + for entry in args.secret_entries { + let (key, value) = parse_kv(entry)?; + enc_map.insert(key, Value::String(value)); + } + for key in args.remove_secrets { + enc_map.remove(key); + } + let encrypted = Value::Object(enc_map); + + sqlx::query!( + r#" + UPDATE secrets + SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW() + WHERE id = $4 + "#, + &tags, + metadata, + encrypted, + row.id, + ) + .execute(pool) + .await?; + + println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name); + + if !args.add_tags.is_empty() { + println!(" +tags: {}", args.add_tags.join(", ")); + } + if !args.remove_tags.is_empty() { + println!(" -tags: {}", args.remove_tags.join(", ")); + } + if !args.meta_entries.is_empty() { + let keys: Vec<&str> = args + .meta_entries + .iter() + .filter_map(|s| s.split_once('=').map(|(k, _)| k)) + .collect(); + println!(" +metadata: {}", keys.join(", ")); + } + if !args.remove_meta.is_empty() { + println!(" -metadata: {}", args.remove_meta.join(", ")); + } + if !args.secret_entries.is_empty() { + let keys: Vec<&str> = args + .secret_entries + .iter() + .filter_map(|s| s.split_once('=').map(|(k, _)| k)) + .collect(); + println!(" +secrets: {}", keys.join(", ")); + } + if !args.remove_secrets.is_empty() { + println!(" -secrets: {}", args.remove_secrets.join(", ")); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index bde7e0a..a41bb6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,37 @@ enum Commands { #[arg(long)] name: String, }, + + /// Incrementally update an existing record (merge semantics) + Update { + /// Namespace (e.g. refining, ricnsmart) + #[arg(short, long)] + namespace: String, + /// Kind of record (server, service, key, ...) + #[arg(long)] + kind: String, + /// Human-readable name + #[arg(long)] + name: String, + /// Add a tag (repeatable) + #[arg(long = "add-tag")] + add_tags: Vec, + /// Remove a tag (repeatable) + #[arg(long = "remove-tag")] + remove_tags: Vec, + /// Set or overwrite a metadata field: key=value (repeatable, @file supported) + #[arg(long = "meta", short = 'm')] + meta: Vec, + /// Remove a metadata field by key (repeatable) + #[arg(long = "remove-meta")] + remove_meta: Vec, + /// Set or overwrite a secret field: key=value (repeatable, @file supported) + #[arg(long = "secret", short = 's')] + secrets: Vec, + /// Remove a secret field by key (repeatable) + #[arg(long = "remove-secret")] + remove_secrets: Vec, + }, } #[tokio::main] @@ -130,6 +161,33 @@ async fn main() -> Result<()> { } => { commands::delete::run(&pool, namespace, kind, name).await?; } + Commands::Update { + namespace, + kind, + name, + add_tags, + remove_tags, + meta, + remove_meta, + secrets, + remove_secrets, + } => { + commands::update::run( + &pool, + commands::update::UpdateArgs { + namespace, + kind, + name, + add_tags, + remove_tags, + meta_entries: meta, + remove_meta, + secret_entries: secrets, + remove_secrets, + }, + ) + .await?; + } } Ok(())