feat: add update command, bump to 0.2.0, doc version check
- 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
This commit is contained in:
@@ -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}"
|
||||
|
||||
40
AGENTS.md
40
AGENTS.md
@@ -77,6 +77,15 @@ secrets add -n <namespace> --kind <kind> --name <name> \
|
||||
secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets]
|
||||
# -q 匹配范围:name、namespace、kind、metadata 全文内容、tags
|
||||
|
||||
# 增量更新已有记录(合并语义,记录不存在则报错)
|
||||
secrets update -n <namespace> --kind <kind> --name <name> \
|
||||
[--add-tag <tag>]... # 添加标签(不影响已有标签)
|
||||
[--remove-tag <tag>]... # 移除标签
|
||||
[-m key=value]... # 新增或覆盖 metadata 字段(不影响其他字段)
|
||||
[--remove-meta <key>]... # 删除 metadata 字段
|
||||
[-s key=value]... # 新增或覆盖 encrypted 字段(不影响其他字段)
|
||||
[--remove-secret <key>]... # 删除 encrypted 字段
|
||||
|
||||
# 删除
|
||||
secrets delete -n <namespace> --kind <kind> --name <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=<new-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-<version>)
|
||||
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 修复)
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1163,7 +1163,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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=<new-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 全量数据
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod add;
|
||||
pub mod delete;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
|
||||
125
src/commands/update.rs
Normal file
125
src/commands/update.rs
Normal file
@@ -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<String> = 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<String, Value> = 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<String, Value> = 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(())
|
||||
}
|
||||
58
src/main.rs
58
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<String>,
|
||||
/// Remove a tag (repeatable)
|
||||
#[arg(long = "remove-tag")]
|
||||
remove_tags: Vec<String>,
|
||||
/// Set or overwrite a metadata field: key=value (repeatable, @file supported)
|
||||
#[arg(long = "meta", short = 'm')]
|
||||
meta: Vec<String>,
|
||||
/// Remove a metadata field by key (repeatable)
|
||||
#[arg(long = "remove-meta")]
|
||||
remove_meta: Vec<String>,
|
||||
/// Set or overwrite a secret field: key=value (repeatable, @file supported)
|
||||
#[arg(long = "secret", short = 's')]
|
||||
secrets: Vec<String>,
|
||||
/// Remove a secret field by key (repeatable)
|
||||
#[arg(long = "remove-secret")]
|
||||
remove_secrets: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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(())
|
||||
|
||||
Reference in New Issue
Block a user