feat: add grpc runtime transport

This commit is contained in:
2026-08-30 09:16:32 +08:00
parent 5028d0b821
commit 0e3b3bd82d
30 changed files with 2941 additions and 137 deletions

44
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up protoc
uses: arduino/setup-protoc@v3
with:
version: "25.1"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify generated protobuf code
run: make proto-check
- name: Go tests
run: GOCACHE=/tmp/configcenter-go-cache go test ./...
- name: Go race tests
run: GOCACHE=/tmp/configcenter-go-cache go test -race ./...
- name: Python SDK compile check
run: PYTHONPATH="$PWD/.tools/python:$PWD/sdk/python" python -m compileall -q sdk/python/configcenter

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.env .env
.idea/ .idea/
.vscode/ .vscode/
.tools/
bin/ bin/
coverage.out coverage.out
web/dist/ web/dist/

View File

@@ -1,4 +1,12 @@
.PHONY: dev test build web-install web-build compose-up compose-down integration-smoke loadtest .PHONY: dev test build proto proto-go proto-python proto-tools proto-check web-install web-build compose-up compose-down integration-smoke loadtest
PROTOC_GEN_GO_VERSION := v1.36.5
PROTOC_GEN_GO_GRPC_VERSION := v1.5.1
GRPCIO_TOOLS_VERSION := 1.71.0
PROTO_FILE := api/proto/configcenter/v1/config.proto
PROTO_GO_FILES := pkg/proto/v1/config.pb.go pkg/proto/v1/config_grpc.pb.go
PROTO_PY_FILES := sdk/python/configcenter/v1/config_pb2.py sdk/python/configcenter/v1/config_pb2_grpc.py
PROTO_TOOLS_STAMP := .tools/.proto-tools-$(PROTOC_GEN_GO_VERSION)-$(PROTOC_GEN_GO_GRPC_VERSION)-$(GRPCIO_TOOLS_VERSION)
dev: dev:
go run ./cmd/server go run ./cmd/server
@@ -10,6 +18,32 @@ build:
mkdir -p bin mkdir -p bin
GOCACHE=/tmp/configcenter-go-cache go build -buildvcs=false -trimpath -o bin/configcenter ./cmd/server GOCACHE=/tmp/configcenter-go-cache go build -buildvcs=false -trimpath -o bin/configcenter ./cmd/server
proto-tools: $(PROTO_TOOLS_STAMP)
$(PROTO_TOOLS_STAMP):
rm -rf .tools/bin .tools/python
mkdir -p .tools/bin .tools/python
GOBIN=$(CURDIR)/.tools/bin go install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION)
GOBIN=$(CURDIR)/.tools/bin go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION)
python3 -m pip install --disable-pip-version-check --target .tools/python grpcio-tools==$(GRPCIO_TOOLS_VERSION)
touch $(PROTO_TOOLS_STAMP)
proto-go: proto-tools
PATH="$(CURDIR)/.tools/bin:$$PATH" protoc -I api/proto \
--go_out=. --go_opt=module=github.com/longpeng/configcenter \
--go-grpc_out=. --go-grpc_opt=module=github.com/longpeng/configcenter \
$(PROTO_FILE)
proto-python: proto-tools
PYTHONPATH="$(CURDIR)/.tools/python" python3 -m grpc_tools.protoc -I api/proto \
--python_out=sdk/python --grpc_python_out=sdk/python $(PROTO_FILE)
touch sdk/python/configcenter/v1/__init__.py
proto: proto-go proto-python
proto-check: proto
git diff --exit-code -- $(PROTO_FILE) $(PROTO_GO_FILES) $(PROTO_PY_FILES) sdk/python/configcenter/v1/__init__.py
web-install: web-install:
npm --prefix web install npm --prefix web install

View File

@@ -9,12 +9,12 @@
- PostgreSQL 事务化发布,基于 advisory lock 的并发版本分配; - PostgreSQL 事务化发布,基于 advisory lock 的并发版本分配;
- Outbox claim、租约恢复、指数退避、最大重试和 release 状态回写; - Outbox claim、租约恢复、指数退避、最大重试和 release 状态回写;
- etcd 完整快照 + `__meta` 同事务写入; - etcd 完整快照 + `__meta` 同事务写入;
- 运行时配置读取与 SSE Watch服务端同 key Watch 扇出 - 运行时配置读取与 SSE/gRPC Watch统一使用 etcd MVCC revision支持断线续传与 compact 后全量恢复
- 发布历史和“生成新版本”的可追溯回滚; - 发布历史和“生成新版本”的可追溯回滚;
- 审计日志查询; - 审计日志查询;
- 可选 JWT 登录、bcrypt 密码、全局管理员与应用级 `viewer` / `app-owner` RBAC - 可选 JWT 登录、bcrypt 密码、全局管理员与应用级 `viewer` / `app-owner` RBAC
- IP/CIDR、实例 ID、稳定百分比三类灰度规则支持优先级覆盖 - IP/CIDR、实例 ID、稳定百分比三类灰度规则支持优先级覆盖
- Go/Python SDK支持自动重连、全量替换和本地文件缓存兜底 - Go/Python SDK支持 REST/SSE 与 gRPC transport、自动重连、全量替换和本地文件缓存兜底;
- React Web Console含登录、灰度、用户授权页面、Docker Compose 与本地内存开发模式; - React Web Console含登录、灰度、用户授权页面、Docker Compose 与本地内存开发模式;
- Prometheus 指标与告警规则、三副本服务/三节点 etcd Kubernetes 清单、SLO 压测工具。 - Prometheus 指标与告警规则、三副本服务/三节点 etcd Kubernetes 清单、SLO 压测工具。
- HTTP 延迟直方图/p95 告警、带校验和的数据库 migration 追踪、etcd mTLS 与定时备份维护任务。 - HTTP 延迟直方图/p95 告警、带校验和的数据库 migration 追踪、etcd mTLS 与定时备份维护任务。
@@ -34,7 +34,7 @@ make web-install
npm --prefix web run dev npm --prefix web run dev
``` ```
浏览器访问 `http://localhost:5173`。API 默认监听 `http://localhost:8080` 浏览器访问 `http://localhost:5173`HTTP API 默认监听 `http://localhost:8080`gRPC 默认监听 `localhost:9091`
完整依赖环境: 完整依赖环境:
@@ -55,6 +55,9 @@ docker compose --profile monitoring up --build
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
|---|---:|---| |---|---:|---|
| `HTTP_ADDR` | `:8080` | HTTP 监听地址 | | `HTTP_ADDR` | `:8080` | HTTP 监听地址 |
| `GRPC_ADDR` | `:9091` | gRPC 监听地址 |
| `GRPC_TLS_CERT_FILE` | 空 | gRPC 服务端 TLS 证书;与私钥同时配置 |
| `GRPC_TLS_KEY_FILE` | 空 | gRPC 服务端 TLS 私钥;与证书同时配置 |
| `DATABASE_URL` | 空 | 为空时使用内存控制面 | | `DATABASE_URL` | 空 | 为空时使用内存控制面 |
| `ETCD_ENDPOINTS` | 空 | 逗号分隔;为空时使用内存运行时 | | `ETCD_ENDPOINTS` | 空 | 逗号分隔;为空时使用内存运行时 |
| `ETCD_DIAL_TIMEOUT` | `5s` | etcd 连接超时 | | `ETCD_DIAL_TIMEOUT` | `5s` | etcd 连接超时 |
@@ -104,6 +107,8 @@ docker compose --profile monitoring up --build
认证关闭时服务以开发管理员身份运行,并可用 `X-User` 记录操作者;认证开启时操作者来自 JWT客户端不能通过请求头伪造。`viewer` 可以读取应用配置,`app-owner` 还可以维护命名空间、配置、发布、回滚和灰度规则,全局管理员可管理应用、环境、审计和用户授权。 认证关闭时服务以开发管理员身份运行,并可用 `X-User` 记录操作者;认证开启时操作者来自 JWT客户端不能通过请求头伪造。`viewer` 可以读取应用配置,`app-owner` 还可以维护命名空间、配置、发布、回滚和灰度规则,全局管理员可管理应用、环境、审计和用户授权。
正式 gRPC 契约位于 `api/proto/configcenter/v1/config.proto`,提供 `ConfigService.GetConfig/WatchConfig``AdminService.PublishConfig/RollbackConfig`。认证令牌通过 `authorization: Bearer <token>` metadata 传递。`WatchConfig.start_revision` 为包含式游标:首次订阅传 `0`,重连传 `last_seen_revision + 1`;若历史已被 compact服务端发送最新 `FULL_SYNC` 后从快照 revision 的下一版本继续监听。详细一致性约束见 `docs/adr/0001-runtime-revision-watch.md`
### 灰度匹配 ### 灰度匹配
`ip` 规则接受精确 IP 或 CIDR`instance` 规则接受实例 ID 列表;`percentage` 使用 salt 与实例 ID无实例时使用 IP做稳定哈希同一实例不会随机漂移。多条规则命中时按优先级从低到高覆盖并在响应的 `grayRuleIds` 中返回命中规则。 `ip` 规则接受精确 IP 或 CIDR`instance` 规则接受实例 ID 列表;`percentage` 使用 salt 与实例 ID无实例时使用 IP做稳定哈希同一实例不会随机漂移。多条规则命中时按优先级从低到高覆盖并在响应的 `grayRuleIds` 中返回命中规则。
@@ -139,6 +144,24 @@ go client.WatchAndSync(ctx, "application")
port := client.GetInt("application", "server.port", 8080) port := client.GetInt("application", "server.port", 8080)
``` ```
使用 gRPC transport
```go
client, err := configsdk.NewGRPC(configsdk.GRPCOptions{
Target: "configcenter.example.com:9091",
Env: "PROD",
App: "order-service",
Token: os.Getenv("CONFIGCENTER_TOKEN"),
Instance: "order-3",
CacheFile: "/var/lib/my-service/configcenter.json",
})
if err != nil { /* handle */ }
defer client.Close()
_ = client.Load(ctx, "application")
go client.WatchAndSync(ctx, "application")
```
Python SDK 位于 `sdk/python` Python SDK 位于 `sdk/python`
```python ```python
@@ -153,12 +176,30 @@ client.start_background_watch("application")
timeout = client.get("application", "order.timeout.minutes", "30") timeout = client.get("application", "order.timeout.minutes", "30")
``` ```
Python gRPC transport 需要安装可选依赖 `configcenter-client[grpc]`
```python
client = ConfigClient(
"configcenter.example.com:9091",
"PROD",
"order-service",
"/tmp/order-config.json",
token=os.environ.get("CONFIGCENTER_TOKEN"),
instance="order-3",
transport="grpc",
)
client.load("application")
client.start_background_watch("application")
```
本地缓存采用完整快照和原子 renameConfig Server/etcd 暂时不可用时,进程可以读取上一次成功同步的缓存启动。 本地缓存采用完整快照和原子 renameConfig Server/etcd 暂时不可用时,进程可以读取上一次成功同步的缓存启动。
## 开发与验证 ## 开发与验证
```bash ```bash
make test make test
make proto-check
go test -race ./...
make build make build
make web-build make web-build
docker compose config docker compose config
@@ -204,6 +245,7 @@ etcd 服务端证书需覆盖 `etcd`、`etcd.configcenter.svc.cluster.local` 和
```text ```text
cmd/server 服务入口 cmd/server 服务入口
internal/api/httpapi REST + SSE internal/api/httpapi REST + SSE
internal/api/grpcapi gRPC 服务、认证拦截器与错误映射
internal/store/postgres PostgreSQL repository 与 migration internal/store/postgres PostgreSQL repository 与 migration
internal/store/memory 本地开发/测试实现 internal/store/memory 本地开发/测试实现
internal/runtime/etcd etcd 快照与 Watch internal/runtime/etcd etcd 快照与 Watch
@@ -215,6 +257,7 @@ internal/metrics Prometheus 指标
cmd/loadtest 运行时读取 SLO 压测 cmd/loadtest 运行时读取 SLO 压测
deploy Kubernetes 与监控告警配置 deploy Kubernetes 与监控告警配置
pkg/sdk/go Go SDK pkg/sdk/go Go SDK
pkg/proto 生成的 Go protobuf/gRPC 代码
sdk/python Python SDK sdk/python Python SDK
api/proto gRPC 契约 api/proto gRPC 契约
web React Web Console web React Web Console

View File

@@ -4,19 +4,20 @@
当前基线已经完成 REST/SSE 管理面与运行时分发、PostgreSQL Outbox、etcd、JWT/RBAC、灰度规则、Web Console、Go/Python SDK、监控告警、压测和 Kubernetes 高可用清单。本文件仅记录尚未完成或必须在真实生产环境验收的工作。 当前基线已经完成 REST/SSE 管理面与运行时分发、PostgreSQL Outbox、etcd、JWT/RBAC、灰度规则、Web Console、Go/Python SDK、监控告警、压测和 Kubernetes 高可用清单。本文件仅记录尚未完成或必须在真实生产环境验收的工作。
## P1实现正式 gRPC 服务 ## P1(功能阻断):实现正式 gRPC 服务
- [ ] `api/proto/configcenter/v1/config.proto` 增加可重复执行的 Go/Python 代码生成流程,并在 CI 中检查生成代码未漂移 - [x] 固定 Runtime revision / Watch / Outbox 一致性模型:统一使用 etcd MVCC revision普通断线从未消费 revision 继续;仅 compact 时回退到 `FULL_SYNC`,见 `docs/adr/0001-runtime-revision-watch.md`
- [ ] 实现 `ConfigService.GetConfig``ConfigService.WatchConfig`,复用现有 Store、WatchHub、灰度匹配和 RBAC 逻辑 - [x] `api/proto/configcenter/v1/config.proto` 增加可重复执行的 Go/Python 代码生成流程,并在 CI 中检查生成代码未漂移
- [ ] 正确实现 `start_revision`重连时不丢事件revision 已 compact 时返回最新完整快照并继续监听 - [x] 实现 `ConfigService.GetConfig``ConfigService.WatchConfig`,复用现有 Store、WatchHub、灰度匹配和 RBAC 逻辑
- [ ] 实现 `AdminService.PublishConfig``AdminService.RollbackConfig`,保持与 REST API 相同的 Outbox、审计和权限语义 - [x] 在 gRPC 层落实 `start_revision` 契约:`0` 表示首次订阅;`>0` 表示第一个期望接收的 revisionSDK 使用 `last_seen_revision + 1` 重连revision 已 compact 时返回最新完整快照并从 `snapshot_revision + 1` 继续监听
- [ ] 增加 JWT metadata unary/stream interceptor、gRPC TLS、健康检查、优雅停机、错误码映射和请求指标 - [x] 实现 `AdminService.PublishConfig``AdminService.RollbackConfig`,保持与 REST API 相同的 Outbox、审计和权限语义
- [ ] 增加 bufconn 集成测试,覆盖读取、发布、回滚、流式断线重连、无权限和过期令牌 - [x] 增加 JWT metadata unary/stream interceptor、gRPC TLS、健康检查、优雅停机、错误码映射和请求指标
- [ ] Go/Python SDK 增加 gRPC transportREST/SSE transport 在迁移期继续兼容 - [x] 增加 bufconn 集成测试,覆盖读取、指定 revision 回放、compact 全量恢复、客户端主动断线后重新建流、发布、回滚、无权限和过期令牌
- [x] Go/Python SDK 增加 gRPC transportREST/SSE transport 在迁移期继续兼容。
完成标准gRPC 与 REST 对同一配置范围返回一致结果;断线恢复测试无丢失;认证、竞态检查、静态检查和端到端测试全部通过。 完成标准gRPC 与 REST 对同一配置范围返回一致结果;断线恢复测试无丢失;认证、竞态检查、静态检查和端到端测试全部通过。
## P1生产 mTLS 与灾备实地验收 ## P1(上线阻断):生产 mTLS 与灾备实地验收
- [ ] 签发 etcd server、peer、client 证书,覆盖 `etcd``etcd.configcenter.svc.cluster.local``*.etcd-peer.configcenter.svc.cluster.local` - [ ] 签发 etcd server、peer、client 证书,覆盖 `etcd``etcd.configcenter.svc.cluster.local``*.etcd-peer.configcenter.svc.cluster.local`
- [ ] 在预生产三节点集群验证双向 TLS、证书轮换、NetworkPolicy、节点滚动重启和 quorum 故障恢复。 - [ ] 在预生产三节点集群验证双向 TLS、证书轮换、NetworkPolicy、节点滚动重启和 quorum 故障恢复。
@@ -32,6 +33,7 @@
- [ ] Web Console 增加审计页面、筛选条件、详情查看和分页。 - [ ] Web Console 增加审计页面、筛选条件、详情查看和分页。
- [ ] 增加用户启用/禁用、密码重置和删除接口及管理页面。 - [ ] 增加用户启用/禁用、密码重置和删除接口及管理页面。
- [ ] 防止删除或禁用最后一个管理员,并限制用户破坏自己的当前管理会话。 - [ ] 防止删除或禁用最后一个管理员,并限制用户破坏自己的当前管理会话。
- [ ] 明确 JWT 即时失效策略:用户禁用、密码重置或权限收回后,已签发令牌必须在定义的时限内失效,并补齐对应测试。
- [ ] 用户状态、密码重置和角色变更全部写入审计日志;补齐 PostgreSQL、内存 Store 和 HTTP 权限测试。 - [ ] 用户状态、密码重置和角色变更全部写入审计日志;补齐 PostgreSQL、内存 Store 和 HTTP 权限测试。
完成标准:管理员可完整管理用户生命周期并追溯操作;越权、最后管理员保护和令牌失效场景都有自动化测试。 完成标准:管理员可完整管理用户生命周期并追溯操作;越权、最后管理员保护和令牌失效场景都有自动化测试。
@@ -51,9 +53,13 @@
- [ ] 建立版本号、变更日志、数据库 migration 发布规则及回滚准入检查。 - [ ] 建立版本号、变更日志、数据库 migration 发布规则及回滚准入检查。
- [ ] 在预生产执行持续压测和故障注入,验证 p95、错误率、Outbox 积压和 Watch 重连告警。 - [ ] 在预生产执行持续压测和故障注入,验证 p95、错误率、Outbox 积压和 Watch 重连告警。
完成标准:主分支 CI 全部通过;发布产物可追溯、可验证、可回滚;预生产持续压测和故障注入达到既定 SLO发布前无未处理的高危依赖或镜像漏洞。
## 推荐实施顺序 ## 推荐实施顺序
1. gRPC 服务及 SDK transport 1. ~~固定 revision / Watch / Outbox 一致性模型。~~ 已完成
2. 审计控制台与用户生命周期 2. ~~gRPC 代码生成、服务端和 SDK transport。~~ 已完成
3. Redis ADR 和清理/实现 3. 审计控制台与用户生命周期,并明确 JWT 即时失效策略
4. 生产证书、备份恢复及发布工程化验收。 4. Redis ADR 和清理/实现。
5. CI/CD、制品签名、SBOM、漏洞扫描和发布规则。
6. 预生产 mTLS、备份恢复、故障演练与生产准入验收。

View File

@@ -13,6 +13,8 @@ message GetConfigRequest {
string env = 1; string env = 1;
string app = 2; string app = 2;
string namespace = 3; string namespace = 3;
string ip = 4;
string instance = 5;
} }
message GetConfigResponse { message GetConfigResponse {
@@ -26,6 +28,8 @@ message WatchConfigRequest {
string app = 2; string app = 2;
string namespace = 3; string namespace = 3;
int64 start_revision = 4; int64 start_revision = 4;
string ip = 5;
string instance = 6;
} }
message ConfigEvent { message ConfigEvent {

View File

@@ -2,14 +2,23 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
"github.com/longpeng/configcenter/internal/api/grpcapi"
"github.com/longpeng/configcenter/internal/api/httpapi" "github.com/longpeng/configcenter/internal/api/httpapi"
"github.com/longpeng/configcenter/internal/auth" "github.com/longpeng/configcenter/internal/auth"
"github.com/longpeng/configcenter/internal/config" "github.com/longpeng/configcenter/internal/config"
@@ -66,34 +75,84 @@ func main() {
go worker.Run(ctx) go worker.Run(ctx)
api := httpapi.New(repository, runtimeStore, hub, authorizer, collector, cfg.AllowedOrigins, logger) api := httpapi.New(repository, runtimeStore, hub, authorizer, collector, cfg.AllowedOrigins, logger)
server := &http.Server{ httpServer := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
Handler: api.Handler(), Handler: api.Handler(),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
grpcAPI := grpcapi.New(repository, runtimeStore, hub, authorizer, collector, logger)
grpcOptions, err := grpcOptions(cfg, grpcAPI)
if err != nil {
logger.Error("configure grpc server", "error", err)
os.Exit(1)
}
grpcServer := grpc.NewServer(grpcOptions...)
grpcAPI.Register(grpcServer)
healthServer := health.NewServer()
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
grpcListener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
logger.Error("listen grpc", "address", cfg.GRPCAddr, "error", err)
os.Exit(1)
}
serverErrors := make(chan error, 1) serverErrors := make(chan error, 2)
go func() { go func() {
logger.Info("config center listening", "address", cfg.HTTPAddr) logger.Info("config center http listening", "address", cfg.HTTPAddr)
serverErrors <- server.ListenAndServe() serverErrors <- httpServer.ListenAndServe()
}()
go func() {
logger.Info("config center grpc listening", "address", cfg.GRPCAddr, "tls", cfg.GRPCTLSCertFile != "")
serverErrors <- grpcServer.Serve(grpcListener)
}() }()
select { select {
case <-ctx.Done(): case <-ctx.Done():
logger.Info("shutting down config center") logger.Info("shutting down config center")
case err := <-serverErrors: case err := <-serverErrors:
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed && !errors.Is(err, grpc.ErrServerStopped) {
logger.Error("http server stopped", "error", err) logger.Error("server stopped", "error", err)
} }
} }
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel() defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil { healthServer.Shutdown()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown", "error", err) logger.Error("graceful shutdown", "error", err)
} }
grpcStopped := make(chan struct{})
go func() {
grpcServer.GracefulStop()
close(grpcStopped)
}()
select {
case <-grpcStopped:
case <-shutdownCtx.Done():
grpcServer.Stop()
}
}
func grpcOptions(cfg config.Config, api *grpcapi.Server) ([]grpc.ServerOption, error) {
options := []grpc.ServerOption{
grpc.UnaryInterceptor(api.UnaryInterceptor),
grpc.StreamInterceptor(api.StreamInterceptor),
}
if (cfg.GRPCTLSCertFile == "") != (cfg.GRPCTLSKeyFile == "") {
return nil, fmt.Errorf("GRPC_TLS_CERT_FILE and GRPC_TLS_KEY_FILE must be configured together")
}
if cfg.GRPCTLSCertFile != "" {
transportCredentials, err := credentials.NewServerTLSFromFile(cfg.GRPCTLSCertFile, cfg.GRPCTLSKeyFile)
if err != nil {
return nil, fmt.Errorf("load grpc TLS certificate: %w", err)
}
options = append(options, grpc.Creds(transportCredentials))
}
return options, nil
} }
func openStore(ctx context.Context, cfg config.Config, logger *slog.Logger) (storepkg.Store, error) { func openStore(ctx context.Context, cfg config.Config, logger *slog.Logger) (storepkg.Store, error) {

View File

@@ -66,7 +66,7 @@ services:
timeout: 3s timeout: 3s
retries: 20 retries: 20
ports: ports:
- "8080:8080" - "18080:8080"
web: web:
build: build:

View File

@@ -0,0 +1,79 @@
# ADR-0001Runtime Revision、Watch 与 Outbox 一致性模型
状态Accepted
日期2026-08-30
## 背景
Config Center 同时存在 PostgreSQL 控制面、Transactional Outbox、etcd Runtime Store、REST/SSE 以及后续 gRPC/SDK transport。若各层分别使用 release version、outbox id 或本地计数作为 revision会导致断线恢复、compact 处理以及跨 transport 一致性无法定义。
## 决策
### 1. Runtime revision 的唯一来源
对外暴露的 `revision` 统一使用 **etcd MVCC revision**
- PostgreSQL release version 只表示某个配置作用域的发布版本,不作为 Watch cursor。
- Outbox ID 只表示可靠投递队列中的记录顺序,不作为 Watch cursor。
- etcd Runtime Store 的 `Put` 使用单个事务同时写配置 key 与 `__meta`,事务返回的 header revision 为该次发布的 runtime revision。
- 配置 key 的 `ModRevision` 是该配置快照实际生效的 revision`Get` 返回该值。
- Outbox worker 在写入 etcd 成功后,将该 etcd revision 回写到 release/outbox 状态,用于审计与排障。
内存 Runtime Store 仅用于开发和测试,但必须模拟相同的单调 revision、历史回放与 compact 行为。
### 2. Watch cursor 语义
`start_revision` 表示客户端希望接收的**第一个 revisioninclusive**。
- `start_revision == 0`:新订阅。服务端先读取一致性完整快照 `revision=N`,发送 `FULL_SYNC`,随后从 `N+1` 开始 Watch。
- `start_revision > 0`:重连。服务端从该 revision 开始 Watch不主动跳到最新快照。
- SDK 在正常断线重连时应保存最后成功处理的 revision `R`,并使用 `R+1` 作为新的 `start_revision`
### 3. 瞬时断线
Watch 因网络错误、连接关闭等瞬时故障中断时:
1. 保留当前 `next_revision`
2. 重建 Watch并继续从同一个 `next_revision` 监听;
3. 不允许先 `Get latest` 再从 `latest+1` 继续,因为这会跳过断线期间仍可从 MVCC 历史回放的事件。
因此,只要目标 revision 尚未被 compact断线恢复不会丢失可回放事件。
### 4. Revision compact
当 Runtime Store 明确返回 `ErrRevisionCompacted` 时,原 cursor 已无法完整回放。此时允许退化为状态恢复:
1. 读取当前完整快照,得到 `snapshot_revision=N`
2. 发送 `FULL_SYNC(revision=N)`
3.`N+1` 继续 Watch。
这保证 compact 后客户端恢复到一个完整、一致的最新状态。compact 前被删除的中间事件无法再重放,因此服务端必须通过 `FULL_SYNC` 明确表达该语义,而不能伪装成普通 `UPDATED`
### 5. Outbox 与 Runtime 的职责边界
权威关系如下:
`PostgreSQL transaction -> Release + Outbox -> Outbox Worker -> etcd transaction -> Runtime revision`
- PostgreSQL 是管理面、审计和发布记录的权威来源。
- Outbox 是“发布记录最终进入 Runtime”的可靠投递机制不是 Runtime Watch 事件源。
- etcd 是运行时配置读取和 Watch 的权威来源。
- REST/SSE/gRPC/Go SDK/Python SDK 必须共享同一套 etcd revision 语义。
### 6. 灰度规则
灰度规则当前保存在控制面 Store不一定改变配置 key 的 etcd revision。因此灰度规则变化允许通过 `Force` refresh 触发相同 runtime revision 的重新计算。
客户端不得把“相同 revision 的灰度 refresh”误判为新的 Runtime revision。后续 gRPC 协议若需要完整表达该场景,应增加明确的 refresh/full-sync 语义,而不是人为生成新的 etcd revision。
## 已落实的代码约束
- `runtime.ErrRevisionCompacted` 作为 Runtime Store 的统一 compact 错误。
- etcd Runtime Store 将 etcd compact 响应转换为统一错误,并保留 compact revision。
- memory Runtime Store 模拟有限历史与 compact。
- WatchHub 对普通断线从原 `next_revision` 重试;仅 compact 时重新读取完整快照并从 `snapshot_revision+1` 恢复。
- WatchHub 的初始化和 compact 恢复快照使用 `FULL_SYNC`
## 后续约束
正式 gRPC 实现、SSE reconnect 以及 Go/Python SDK transport 必须遵守本 ADR。任何修改 revision 来源或 `start_revision` 语义的变更,都需要新的 ADR而不能在单个 transport 中单独修改。

8
go.mod
View File

@@ -4,7 +4,11 @@ go 1.24.0
require ( require (
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
go.etcd.io/etcd/api/v3 v3.6.7
go.etcd.io/etcd/client/v3 v3.6.7 go.etcd.io/etcd/client/v3 v3.6.7
golang.org/x/crypto v0.42.0
google.golang.org/grpc v1.71.1
google.golang.org/protobuf v1.36.5
) )
require ( require (
@@ -16,17 +20,13 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
go.etcd.io/etcd/api/v3 v3.6.7 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.6.7 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.7 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.45.0 // indirect golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/grpc v1.71.1 // indirect
google.golang.org/protobuf v1.36.5 // indirect
) )

View File

@@ -0,0 +1,330 @@
package grpcapi
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"unicode"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
authpkg "github.com/longpeng/configcenter/internal/auth"
"github.com/longpeng/configcenter/internal/domain"
"github.com/longpeng/configcenter/internal/gray"
metricspkg "github.com/longpeng/configcenter/internal/metrics"
runtimepkg "github.com/longpeng/configcenter/internal/runtime"
"github.com/longpeng/configcenter/internal/store"
"github.com/longpeng/configcenter/internal/watch"
configcenterv1 "github.com/longpeng/configcenter/pkg/proto/v1"
)
type Server struct {
configcenterv1.UnimplementedConfigServiceServer
configcenterv1.UnimplementedAdminServiceServer
store store.Store
runtime runtimepkg.Store
hub *watch.Hub
authorizer *authpkg.Manager
metrics *metricspkg.Collector
logger *slog.Logger
}
func New(repository store.Store, runtimeStore runtimepkg.Store, hub *watch.Hub, authorizer *authpkg.Manager, metrics *metricspkg.Collector, logger *slog.Logger) *Server {
return &Server{
store: repository,
runtime: runtimeStore,
hub: hub,
authorizer: authorizer,
metrics: metrics,
logger: logger,
}
}
func (s *Server) Register(registrar grpc.ServiceRegistrar) {
configcenterv1.RegisterConfigServiceServer(registrar, s)
configcenterv1.RegisterAdminServiceServer(registrar, s)
}
func (s *Server) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
started := time.Now()
defer func() {
s.metrics.ObserveGRPC(info.FullMethod, status.Code(err).String(), time.Since(started))
}()
if !publicMethod(info.FullMethod) {
ctx, err = s.authenticateContext(ctx)
if err != nil {
return nil, mapError(err)
}
}
return handler(ctx, req)
}
func (s *Server) StreamInterceptor(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) {
started := time.Now()
defer func() {
s.metrics.ObserveGRPC(info.FullMethod, status.Code(err).String(), time.Since(started))
}()
if publicMethod(info.FullMethod) {
return handler(srv, stream)
}
ctx, err := s.authenticateContext(stream.Context())
if err != nil {
return mapError(err)
}
return handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx})
}
func (s *Server) GetConfig(ctx context.Context, request *configcenterv1.GetConfigRequest) (*configcenterv1.GetConfigResponse, error) {
key, appID, namespaceID, envID, err := s.resolveScope(ctx, request.GetEnv(), request.GetApp(), request.GetNamespace())
if err != nil {
return nil, mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
return nil, mapError(err)
}
current, err := s.runtime.Get(ctx, key)
if err != nil {
return nil, s.internal("grpc get runtime config", err)
}
rules, err := s.store.ListGrayRules(ctx, appID, namespaceID, envID)
if err != nil {
return nil, mapError(err)
}
current.Items, current.GrayRuleIDs = gray.Apply(current.Items, rules, gray.Target{IP: strings.TrimSpace(request.GetIp()), Instance: strings.TrimSpace(request.GetInstance())})
s.metrics.RuntimeRead()
s.metrics.GrayMatched(len(current.GrayRuleIDs))
return &configcenterv1.GetConfigResponse{
Items: protoItems(current.Items),
Revision: current.Revision,
ReleaseVersion: int64(current.ReleaseVersion),
}, nil
}
func (s *Server) WatchConfig(request *configcenterv1.WatchConfigRequest, stream grpc.ServerStreamingServer[configcenterv1.ConfigEvent]) error {
if request.GetStartRevision() < 0 {
return status.Error(codes.InvalidArgument, "start_revision must be non-negative")
}
ctx := stream.Context()
key, appID, namespaceID, envID, err := s.resolveScope(ctx, request.GetEnv(), request.GetApp(), request.GetNamespace())
if err != nil {
return mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
return mapError(err)
}
s.metrics.WatcherAdded()
defer s.metrics.WatcherRemoved()
updates := runtimepkg.StreamSnapshots(ctx, s.runtime, key, request.GetStartRevision())
refreshes, unsubscribe := s.hub.Subscribe(key, watch.Scope{ApplicationID: appID, NamespaceID: namespaceID, EnvironmentID: envID})
defer unsubscribe()
target := gray.Target{IP: strings.TrimSpace(request.GetIp()), Instance: strings.TrimSpace(request.GetInstance())}
for {
select {
case <-ctx.Done():
return nil
case event, open := <-updates:
if !open {
return nil
}
if err := s.sendConfigEvent(ctx, stream, event, appID, namespaceID, envID, target); err != nil {
return err
}
case event, open := <-refreshes:
if !open {
return nil
}
if !event.Force {
continue
}
if err := s.sendConfigEvent(ctx, stream, event, appID, namespaceID, envID, target); err != nil {
return err
}
}
}
}
func (s *Server) PublishConfig(ctx context.Context, request *configcenterv1.PublishRequest) (*configcenterv1.PublishResponse, error) {
if request.GetAppId() <= 0 || request.GetNamespaceId() <= 0 || request.GetEnvId() <= 0 {
return nil, status.Error(codes.InvalidArgument, "env_id, app_id and namespace_id must be positive")
}
if err := s.authorizer.RequireAppRole(ctx, request.GetAppId(), authpkg.RoleAppOwner); err != nil {
return nil, mapError(err)
}
release, err := s.store.Publish(ctx, domain.PublishRequest{
EnvironmentID: request.GetEnvId(),
AppID: request.GetAppId(),
NamespaceID: request.GetNamespaceId(),
Comment: strings.TrimSpace(request.GetComment()),
Operator: actor(ctx),
})
if err != nil {
return nil, mapError(err)
}
s.metrics.PublishAccepted()
return &configcenterv1.PublishResponse{ReleaseId: release.ID, ReleaseVersion: int64(release.Version), Status: release.Status}, nil
}
func (s *Server) RollbackConfig(ctx context.Context, request *configcenterv1.RollbackRequest) (*configcenterv1.RollbackResponse, error) {
if request.GetAppId() <= 0 || request.GetNamespaceId() <= 0 || request.GetEnvId() <= 0 || request.GetTargetVersion() <= 0 {
return nil, status.Error(codes.InvalidArgument, "env_id, app_id, namespace_id and target_version must be positive")
}
if err := s.authorizer.RequireAppRole(ctx, request.GetAppId(), authpkg.RoleAppOwner); err != nil {
return nil, mapError(err)
}
release, err := s.store.Rollback(ctx, domain.RollbackRequest{
EnvironmentID: request.GetEnvId(),
AppID: request.GetAppId(),
NamespaceID: request.GetNamespaceId(),
TargetVersion: int(request.GetTargetVersion()),
Operator: actor(ctx),
})
if err != nil {
return nil, mapError(err)
}
return &configcenterv1.RollbackResponse{ReleaseId: release.ID, NewReleaseVersion: int64(release.Version), Status: release.Status}, nil
}
func (s *Server) sendConfigEvent(ctx context.Context, stream grpc.ServerStreamingServer[configcenterv1.ConfigEvent], event domain.ConfigEvent, appID, namespaceID, envID int64, target gray.Target) error {
rules, err := s.store.ListGrayRules(ctx, appID, namespaceID, envID)
if err != nil {
return mapError(err)
}
items, matched := gray.Apply(event.Items, rules, target)
s.metrics.GrayMatched(len(matched))
eventType := configcenterv1.ConfigEvent_UPDATED
if event.Type == "FULL_SYNC" {
eventType = configcenterv1.ConfigEvent_FULL_SYNC
}
if err := stream.Send(&configcenterv1.ConfigEvent{Type: eventType, Items: protoItems(items), Revision: event.Revision}); err != nil {
return err
}
return nil
}
func (s *Server) resolveScope(ctx context.Context, env, app, namespace string) (string, int64, int64, int64, error) {
env = strings.ToLower(strings.TrimSpace(env))
app = strings.TrimSpace(app)
namespace = strings.TrimSpace(namespace)
if !validCode(env) || !validCode(app) || !validCode(namespace) {
return "", 0, 0, 0, status.Error(codes.InvalidArgument, "env, app and namespace have invalid format")
}
appID, namespaceID, envID, err := s.store.ResolveScope(ctx, env, app, namespace)
if err != nil {
return "", 0, 0, 0, err
}
return fmt.Sprintf("/config/%s/%s/%s", env, app, namespace), appID, namespaceID, envID, nil
}
func (s *Server) authenticateContext(ctx context.Context) (context.Context, error) {
values, _ := metadata.FromIncomingContext(ctx)
principal, err := s.authorizer.AuthenticateToken(bearerToken(values.Get("authorization")))
if err != nil {
return ctx, err
}
if !s.authorizer.Enabled() {
if users := values.Get("x-user"); len(users) > 0 {
if username := strings.TrimSpace(users[0]); username != "" && len(username) <= 64 {
principal.Username = username
principal.DisplayName = username
}
}
}
return authpkg.WithPrincipal(ctx, principal), nil
}
func bearerToken(values []string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if len(value) > len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") {
return strings.TrimSpace(value[len("Bearer "):])
}
}
return ""
}
func protoItems(items map[string]string) []*configcenterv1.ConfigItem {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]*configcenterv1.ConfigItem, 0, len(keys))
for _, key := range keys {
result = append(result, &configcenterv1.ConfigItem{Key: key, Value: items[key]})
}
return result
}
func actor(ctx context.Context) string {
if principal, ok := authpkg.Principal(ctx); ok && principal.Username != "" {
return principal.Username
}
return "admin"
}
func validCode(value string) bool {
if value == "" || len(value) > 64 {
return false
}
for _, character := range value {
if unicode.IsLetter(character) || unicode.IsDigit(character) || character == '-' || character == '_' || character == '.' {
continue
}
return false
}
return true
}
func publicMethod(method string) bool {
return strings.HasPrefix(method, "/grpc.health.v1.Health/")
}
func mapError(err error) error {
if err == nil {
return nil
}
if _, ok := status.FromError(err); ok {
if status.Code(err) != codes.Unknown {
return err
}
}
switch {
case errors.Is(err, authpkg.ErrUnauthorized):
return status.Error(codes.Unauthenticated, "authentication required or token expired")
case errors.Is(err, authpkg.ErrForbidden):
return status.Error(codes.PermissionDenied, "permission denied")
case errors.Is(err, store.ErrNotFound):
return status.Error(codes.NotFound, "resource not found")
case errors.Is(err, store.ErrConflict):
return status.Error(codes.AlreadyExists, "resource already exists")
case errors.Is(err, store.ErrNoPendingChange):
return status.Error(codes.FailedPrecondition, "no pending configuration changes")
case errors.Is(err, store.ErrInvalidRollback):
return status.Error(codes.FailedPrecondition, "invalid rollback target")
default:
return status.Error(codes.Internal, "internal server error")
}
}
func (s *Server) internal(operation string, err error) error {
s.logger.Error(operation, "error", err)
return status.Error(codes.Internal, "internal server error")
}
type contextServerStream struct {
grpc.ServerStream
ctx context.Context
}
func (s *contextServerStream) Context() context.Context { return s.ctx }

View File

@@ -0,0 +1,278 @@
package grpcapi
import (
"context"
"encoding/json"
"log/slog"
"net"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
authpkg "github.com/longpeng/configcenter/internal/auth"
"github.com/longpeng/configcenter/internal/domain"
"github.com/longpeng/configcenter/internal/metrics"
memoryruntime "github.com/longpeng/configcenter/internal/runtime/memory"
memory_store "github.com/longpeng/configcenter/internal/store/memory"
"github.com/longpeng/configcenter/internal/watch"
configcenterv1 "github.com/longpeng/configcenter/pkg/proto/v1"
)
func TestConfigServiceGetAndWatchFromRevision(t *testing.T) {
testServer := newTestServer(t, false, 8*time.Hour)
putRuntime(t, testServer.runtime, 1, map[string]string{"feature": "one"})
putRuntime(t, testServer.runtime, 2, map[string]string{"feature": "two"})
putRuntime(t, testServer.runtime, 3, map[string]string{"feature": "three"})
response, err := testServer.config.GetConfig(context.Background(), &configcenterv1.GetConfigRequest{
Env: "DEV", App: "demo-service", Namespace: "application",
})
if err != nil {
t.Fatal(err)
}
if response.GetRevision() != 3 || response.GetReleaseVersion() != 3 || itemValue(response.GetItems(), "feature") != "three" {
t.Fatalf("unexpected get response: %#v", response)
}
watchContext, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := testServer.config.WatchConfig(watchContext, &configcenterv1.WatchConfigRequest{
Env: "DEV", App: "demo-service", Namespace: "application", StartRevision: 2,
})
if err != nil {
t.Fatal(err)
}
first, err := stream.Recv()
if err != nil {
t.Fatal(err)
}
second, err := stream.Recv()
if err != nil {
t.Fatal(err)
}
if first.GetRevision() != 2 || first.GetType() != configcenterv1.ConfigEvent_UPDATED || itemValue(first.GetItems(), "feature") != "two" {
t.Fatalf("unexpected first replay event: %#v", first)
}
if second.GetRevision() != 3 || second.GetType() != configcenterv1.ConfigEvent_UPDATED || itemValue(second.GetItems(), "feature") != "three" {
t.Fatalf("unexpected second replay event: %#v", second)
}
}
func TestWatchFallsBackToFullSyncAfterCompaction(t *testing.T) {
testServer := newTestServer(t, false, 8*time.Hour)
for version := 1; version <= 257; version++ {
putRuntime(t, testServer.runtime, version, map[string]string{"version": string(rune('A' + (version % 26)))})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := testServer.config.WatchConfig(ctx, &configcenterv1.WatchConfigRequest{
Env: "DEV", App: "demo-service", Namespace: "application", StartRevision: 1,
})
if err != nil {
t.Fatal(err)
}
event, err := stream.Recv()
if err != nil {
t.Fatal(err)
}
if event.GetType() != configcenterv1.ConfigEvent_FULL_SYNC || event.GetRevision() != 257 {
t.Fatalf("unexpected compact recovery event: %#v", event)
}
}
func TestWatchReconnectsFromLastSeenRevisionPlusOne(t *testing.T) {
testServer := newTestServer(t, false, 8*time.Hour)
putRuntime(t, testServer.runtime, 1, map[string]string{"feature": "one"})
firstContext, cancelFirst := context.WithCancel(context.Background())
firstStream, err := testServer.config.WatchConfig(firstContext, &configcenterv1.WatchConfigRequest{
Env: "DEV", App: "demo-service", Namespace: "application",
})
if err != nil {
t.Fatal(err)
}
initial, err := firstStream.Recv()
if err != nil {
t.Fatal(err)
}
if initial.GetType() != configcenterv1.ConfigEvent_FULL_SYNC || initial.GetRevision() != 1 {
t.Fatalf("unexpected initial watch event: %#v", initial)
}
cancelFirst()
putRuntime(t, testServer.runtime, 2, map[string]string{"feature": "two"})
putRuntime(t, testServer.runtime, 3, map[string]string{"feature": "three"})
secondContext, cancelSecond := context.WithCancel(context.Background())
defer cancelSecond()
secondStream, err := testServer.config.WatchConfig(secondContext, &configcenterv1.WatchConfigRequest{
Env: "DEV", App: "demo-service", Namespace: "application", StartRevision: initial.GetRevision() + 1,
})
if err != nil {
t.Fatal(err)
}
second, err := secondStream.Recv()
if err != nil {
t.Fatal(err)
}
third, err := secondStream.Recv()
if err != nil {
t.Fatal(err)
}
if second.GetType() != configcenterv1.ConfigEvent_UPDATED || second.GetRevision() != 2 || itemValue(second.GetItems(), "feature") != "two" {
t.Fatalf("unexpected event after reconnect: %#v", second)
}
if third.GetType() != configcenterv1.ConfigEvent_UPDATED || third.GetRevision() != 3 || itemValue(third.GetItems(), "feature") != "three" {
t.Fatalf("unexpected second event after reconnect: %#v", third)
}
}
func TestAdminServicePublishAndRollbackUseAuthenticatedActor(t *testing.T) {
testServer := newTestServer(t, false, 8*time.Hour)
if _, err := testServer.repository.UpdateConfigItem(context.Background(), 30, domain.ConfigItem{Value: "9090", UpdatedBy: "editor"}); err != nil {
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("x-user", "grpc-admin"))
published, err := testServer.admin.PublishConfig(ctx, &configcenterv1.PublishRequest{
EnvId: 1, AppId: 10, NamespaceId: 20, Comment: "grpc publish", Operator: "spoofed",
})
if err != nil {
t.Fatal(err)
}
if published.GetReleaseVersion() != 2 || published.GetStatus() != "pending" {
t.Fatalf("unexpected publish response: %#v", published)
}
release, err := testServer.repository.GetRelease(context.Background(), published.GetReleaseId())
if err != nil {
t.Fatal(err)
}
if release.Operator != "grpc-admin" {
t.Fatalf("client supplied operator was trusted: %#v", release)
}
rolledBack, err := testServer.admin.RollbackConfig(ctx, &configcenterv1.RollbackRequest{
EnvId: 1, AppId: 10, NamespaceId: 20, TargetVersion: 1, Operator: "spoofed",
})
if err != nil {
t.Fatal(err)
}
if rolledBack.GetNewReleaseVersion() != 3 || rolledBack.GetStatus() != "pending" {
t.Fatalf("unexpected rollback response: %#v", rolledBack)
}
}
func TestAuthenticationRejectsMissingRoleAndExpiredToken(t *testing.T) {
const testPassword = "correct-horse-battery-staple"
testServer := newTestServer(t, true, 8*time.Hour)
user, err := testServer.authorizer.CreateUser(context.Background(), domain.User{Username: "reader", DisplayName: "Reader"}, testPassword)
if err != nil {
t.Fatal(err)
}
if user.ID == 0 {
t.Fatal("user was not created")
}
token, _, err := testServer.authorizer.Login(context.Background(), "reader", testPassword)
if err != nil {
t.Fatal(err)
}
_, err = testServer.config.GetConfig(bearerContext(token), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
expiring := newTestServer(t, true, time.Millisecond)
adminToken, _, err := expiring.authorizer.Login(context.Background(), "admin", testPassword)
if err != nil {
t.Fatal(err)
}
time.Sleep(1100 * time.Millisecond)
_, err = expiring.config.GetConfig(bearerContext(adminToken), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("expected unauthenticated for expired token, got %v", err)
}
}
type grpcTestServer struct {
config configcenterv1.ConfigServiceClient
admin configcenterv1.AdminServiceClient
runtime *memoryruntime.Store
repository *memory_store.Store
authorizer *authpkg.Manager
}
func newTestServer(t *testing.T, authEnabled bool, ttl time.Duration) grpcTestServer {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
repository := memory_store.New(true)
runtimeStore := memoryruntime.New()
authorizer, err := authpkg.New(repository, authEnabled, strings.Repeat("s", 32), ttl)
if err != nil {
t.Fatal(err)
}
if authEnabled {
if err := authorizer.Bootstrap(ctx, "admin", "correct-horse-battery-staple", "Administrator"); err != nil {
t.Fatal(err)
}
}
collector := metrics.New()
hub := watch.New(ctx, runtimeStore)
api := New(repository, runtimeStore, hub, authorizer, collector, slog.Default())
server := grpc.NewServer(grpc.UnaryInterceptor(api.UnaryInterceptor), grpc.StreamInterceptor(api.StreamInterceptor))
api.Register(server)
listener := bufconn.Listen(1 << 20)
go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop)
t.Cleanup(func() { _ = listener.Close() })
connection, err := grpc.NewClient(
"passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = connection.Close() })
return grpcTestServer{
config: configcenterv1.NewConfigServiceClient(connection),
admin: configcenterv1.NewAdminServiceClient(connection),
runtime: runtimeStore,
repository: repository,
authorizer: authorizer,
}
}
func putRuntime(t *testing.T, runtimeStore *memoryruntime.Store, version int, items map[string]string) {
t.Helper()
payload, err := json.Marshal(items)
if err != nil {
t.Fatal(err)
}
if _, err := runtimeStore.Put(context.Background(), "/config/dev/demo-service/application", payload, domain.Release{ID: int64(version), Version: version, Operator: "test", Time: time.Now()}); err != nil {
t.Fatal(err)
}
}
func itemValue(items []*configcenterv1.ConfigItem, key string) string {
for _, item := range items {
if item.GetKey() == key {
return item.GetValue()
}
}
return ""
}
func bearerContext(token string) context.Context {
return metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
}

View File

@@ -105,14 +105,24 @@ func (m *Manager) CreateUser(ctx context.Context, input domain.User, password st
} }
func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) { func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) {
if !m.enabled {
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}, nil
}
header := strings.TrimSpace(r.Header.Get("Authorization")) header := strings.TrimSpace(r.Header.Get("Authorization"))
if !m.enabled {
return m.AuthenticateToken("")
}
if !strings.HasPrefix(header, "Bearer ") { if !strings.HasPrefix(header, "Bearer ") {
return domain.Principal{}, ErrUnauthorized return domain.Principal{}, ErrUnauthorized
} }
return m.parse(strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))) return m.AuthenticateToken(strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
}
func (m *Manager) AuthenticateToken(token string) (domain.Principal, error) {
if !m.enabled {
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}, nil
}
if strings.TrimSpace(token) == "" {
return domain.Principal{}, ErrUnauthorized
}
return m.parse(strings.TrimSpace(token))
} }
func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context { func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context {

View File

@@ -9,6 +9,9 @@ import (
type Config struct { type Config struct {
HTTPAddr string HTTPAddr string
GRPCAddr string
GRPCTLSCertFile string
GRPCTLSKeyFile string
DatabaseURL string DatabaseURL string
EtcdEndpoints []string EtcdEndpoints []string
EtcdDialTimeout time.Duration EtcdDialTimeout time.Duration
@@ -32,6 +35,9 @@ type Config struct {
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddr: env("HTTP_ADDR", ":8080"), HTTPAddr: env("HTTP_ADDR", ":8080"),
GRPCAddr: env("GRPC_ADDR", ":9091"),
GRPCTLSCertFile: strings.TrimSpace(os.Getenv("GRPC_TLS_CERT_FILE")),
GRPCTLSKeyFile: strings.TrimSpace(os.Getenv("GRPC_TLS_KEY_FILE")),
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")), DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
EtcdEndpoints: csv(os.Getenv("ETCD_ENDPOINTS")), EtcdEndpoints: csv(os.Getenv("ETCD_ENDPOINTS")),
EtcdDialTimeout: duration("ETCD_DIAL_TIMEOUT", 5*time.Second), EtcdDialTimeout: duration("ETCD_DIAL_TIMEOUT", 5*time.Second),

View File

@@ -16,6 +16,7 @@ import (
type Collector struct { type Collector struct {
mu sync.Mutex mu sync.Mutex
http map[httpKey]httpValue http map[httpKey]httpValue
grpc map[grpcKey]httpValue
publishAccepted atomic.Uint64 publishAccepted atomic.Uint64
outboxApplied atomic.Uint64 outboxApplied atomic.Uint64
@@ -31,6 +32,11 @@ type httpKey struct {
status int status int
} }
type grpcKey struct {
method string
code string
}
type httpValue struct { type httpValue struct {
count uint64 count uint64
durationSum float64 durationSum float64
@@ -39,7 +45,9 @@ type httpValue struct {
var httpDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5} var httpDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}
func New() *Collector { return &Collector{http: make(map[httpKey]httpValue)} } func New() *Collector {
return &Collector{http: make(map[httpKey]httpValue), grpc: make(map[grpcKey]httpValue)}
}
func (c *Collector) ObserveHTTP(method, route string, status int, duration time.Duration) { func (c *Collector) ObserveHTTP(method, route string, status int, duration time.Duration) {
if route == "" { if route == "" {
@@ -63,6 +71,25 @@ func (c *Collector) ObserveHTTP(method, route string, status int, duration time.
c.mu.Unlock() c.mu.Unlock()
} }
func (c *Collector) ObserveGRPC(method, code string, duration time.Duration) {
key := grpcKey{method: method, code: code}
c.mu.Lock()
value := c.grpc[key]
if value.buckets == nil {
value.buckets = make([]uint64, len(httpDurationBuckets))
}
value.count++
seconds := duration.Seconds()
value.durationSum += seconds
for index, upperBound := range httpDurationBuckets {
if seconds <= upperBound {
value.buckets[index]++
}
}
c.grpc[key] = value
c.mu.Unlock()
}
func (c *Collector) PublishAccepted() { c.publishAccepted.Add(1) } func (c *Collector) PublishAccepted() { c.publishAccepted.Add(1) }
func (c *Collector) OutboxApplied() { c.outboxApplied.Add(1) } func (c *Collector) OutboxApplied() { c.outboxApplied.Add(1) }
func (c *Collector) OutboxFailed() { c.outboxFailed.Add(1) } func (c *Collector) OutboxFailed() { c.outboxFailed.Add(1) }
@@ -113,12 +140,38 @@ func (c *Collector) Handler(repository store.Store) http.HandlerFunc {
} }
c.mu.Unlock() c.mu.Unlock()
writeHelp(&output, "configcenter_grpc_requests_total", "gRPC requests by method and status code", "counter")
writeHelp(&output, "configcenter_grpc_request_duration_seconds", "gRPC request duration by method and status code", "histogram")
c.mu.Lock()
grpcKeys := make([]grpcKey, 0, len(c.grpc))
for key := range c.grpc {
grpcKeys = append(grpcKeys, key)
}
sort.Slice(grpcKeys, func(i, j int) bool {
if grpcKeys[i].method != grpcKeys[j].method {
return grpcKeys[i].method < grpcKeys[j].method
}
return grpcKeys[i].code < grpcKeys[j].code
})
for _, key := range grpcKeys {
value := c.grpc[key]
labels := fmt.Sprintf(`method="%s",code="%s"`, escape(key.method), escape(key.code))
fmt.Fprintf(&output, "configcenter_grpc_requests_total{%s} %d\n", labels, value.count)
for index, upperBound := range httpDurationBuckets {
fmt.Fprintf(&output, "configcenter_grpc_request_duration_seconds_bucket{%s,le=\"%s\"} %d\n", labels, strconv.FormatFloat(upperBound, 'g', -1, 64), value.buckets[index])
}
fmt.Fprintf(&output, "configcenter_grpc_request_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, value.count)
fmt.Fprintf(&output, "configcenter_grpc_request_duration_seconds_sum{%s} %s\n", labels, strconv.FormatFloat(value.durationSum, 'f', 6, 64))
fmt.Fprintf(&output, "configcenter_grpc_request_duration_seconds_count{%s} %d\n", labels, value.count)
}
c.mu.Unlock()
writeCounter(&output, "configcenter_publish_accepted_total", "Accepted configuration releases", c.publishAccepted.Load()) writeCounter(&output, "configcenter_publish_accepted_total", "Accepted configuration releases", c.publishAccepted.Load())
writeCounter(&output, "configcenter_outbox_applied_total", "Successfully applied outbox entries", c.outboxApplied.Load()) writeCounter(&output, "configcenter_outbox_applied_total", "Successfully applied outbox entries", c.outboxApplied.Load())
writeCounter(&output, "configcenter_outbox_failed_attempts_total", "Failed outbox apply attempts", c.outboxFailed.Load()) writeCounter(&output, "configcenter_outbox_failed_attempts_total", "Failed outbox apply attempts", c.outboxFailed.Load())
writeCounter(&output, "configcenter_runtime_reads_total", "Runtime configuration reads", c.runtimeReads.Load()) writeCounter(&output, "configcenter_runtime_reads_total", "Runtime configuration reads", c.runtimeReads.Load())
writeCounter(&output, "configcenter_gray_rule_matches_total", "Gray rules matched during runtime reads and watches", c.grayMatches.Load()) writeCounter(&output, "configcenter_gray_rule_matches_total", "Gray rules matched during runtime reads and watches", c.grayMatches.Load())
writeGauge(&output, "configcenter_watch_subscribers", "Current SSE watch subscribers", c.watchers.Load()) writeGauge(&output, "configcenter_watch_subscribers", "Current SSE and gRPC watch subscribers", c.watchers.Load())
writeGauge(&output, "configcenter_outbox_pending", "Pending outbox entries", stats.Pending) writeGauge(&output, "configcenter_outbox_pending", "Pending outbox entries", stats.Pending)
writeGauge(&output, "configcenter_outbox_processing", "Processing outbox entries", stats.Processing) writeGauge(&output, "configcenter_outbox_processing", "Processing outbox entries", stats.Processing)
writeGauge(&output, "configcenter_outbox_failed", "Terminally failed outbox entries", stats.Failed) writeGauge(&output, "configcenter_outbox_failed", "Terminally failed outbox entries", stats.Failed)

View File

@@ -5,10 +5,12 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"time" "time"
"go.etcd.io/etcd/api/v3/v3rpc/rpctypes"
clientv3 "go.etcd.io/etcd/client/v3" clientv3 "go.etcd.io/etcd/client/v3"
"github.com/longpeng/configcenter/internal/domain" "github.com/longpeng/configcenter/internal/domain"
@@ -134,8 +136,12 @@ func (s *Store) Watch(ctx context.Context, key string, startRevision int64) <-ch
defer close(result) defer close(result)
for response := range watch { for response := range watch {
if err := response.Err(); err != nil { if err := response.Err(); err != nil {
watchErr := err
if response.CompactRevision > 0 || errors.Is(err, rpctypes.ErrCompacted) {
watchErr = fmt.Errorf("%w: requested=%d compacted=%d", runtimepkg.ErrRevisionCompacted, startRevision, response.CompactRevision)
}
select { select {
case result <- runtimepkg.WatchResult{Err: err}: case result <- runtimepkg.WatchResult{Err: watchErr, CompactRevision: response.CompactRevision}:
case <-ctx.Done(): case <-ctx.Done():
} }
return return

View File

@@ -3,6 +3,7 @@ package memory
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"sync" "sync"
"github.com/longpeng/configcenter/internal/domain" "github.com/longpeng/configcenter/internal/domain"
@@ -32,6 +33,7 @@ type Store struct {
subscribers map[int64]subscriber subscribers map[int64]subscriber
nextSubID int64 nextSubID int64
history []historyEvent history []historyEvent
compactRev int64
} }
func New() *Store { func New() *Store {
@@ -62,6 +64,10 @@ func (s *Store) Put(_ context.Context, key string, payload []byte, release domai
event := domain.ConfigEvent{Type: "UPDATED", Items: clone(items), Revision: revision} event := domain.ConfigEvent{Type: "UPDATED", Items: clone(items), Revision: revision}
s.history = append(s.history, historyEvent{key: key, event: event}) s.history = append(s.history, historyEvent{key: key, event: event})
if len(s.history) > 256 { if len(s.history) > 256 {
removed := s.history[:len(s.history)-256]
if len(removed) > 0 {
s.compactRev = removed[len(removed)-1].event.Revision
}
s.history = append([]historyEvent(nil), s.history[len(s.history)-256:]...) s.history = append([]historyEvent(nil), s.history[len(s.history)-256:]...)
} }
for _, sub := range s.subscribers { for _, sub := range s.subscribers {
@@ -88,8 +94,22 @@ func (s *Store) Get(_ context.Context, key string) (domain.RuntimeConfig, error)
} }
func (s *Store) Watch(ctx context.Context, key string, startRevision int64) <-chan runtimepkg.WatchResult { func (s *Store) Watch(ctx context.Context, key string, startRevision int64) <-chan runtimepkg.WatchResult {
ch := make(chan runtimepkg.WatchResult, 16)
s.mu.Lock() s.mu.Lock()
bufferSize := 16
if len(s.history)+16 > bufferSize {
bufferSize = len(s.history) + 16
}
ch := make(chan runtimepkg.WatchResult, bufferSize)
if startRevision > 0 && startRevision <= s.compactRev {
compactRevision := s.compactRev
s.mu.Unlock()
ch <- runtimepkg.WatchResult{
Err: fmt.Errorf("%w: requested=%d compacted=%d", runtimepkg.ErrRevisionCompacted, startRevision, compactRevision),
CompactRevision: compactRevision,
}
close(ch)
return ch
}
s.nextSubID++ s.nextSubID++
id := s.nextSubID id := s.nextSubID
s.subscribers[id] = subscriber{key: key, ch: ch} s.subscribers[id] = subscriber{key: key, ch: ch}

View File

@@ -0,0 +1,56 @@
package memory
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/longpeng/configcenter/internal/domain"
runtimepkg "github.com/longpeng/configcenter/internal/runtime"
)
func TestWatchReportsCompactedRevision(t *testing.T) {
store := New()
ctx := context.Background()
payload, err := json.Marshal(map[string]string{"feature": "value"})
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 257; version++ {
if _, err := store.Put(ctx, "/config/PROD/orders/application", payload, domain.Release{Version: version}); err != nil {
t.Fatal(err)
}
}
result := <-store.Watch(ctx, "/config/PROD/orders/application", 1)
if !errors.Is(result.Err, runtimepkg.ErrRevisionCompacted) {
t.Fatalf("expected compacted error, got %#v", result)
}
if result.CompactRevision != 1 {
t.Fatalf("unexpected compact revision: %d", result.CompactRevision)
}
}
func TestWatchReplaysEveryAvailableRevision(t *testing.T) {
store := New()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for version := 1; version <= 20; version++ {
payload, err := json.Marshal(map[string]string{"version": string(rune('A' + version - 1))})
if err != nil {
t.Fatal(err)
}
if _, err := store.Put(ctx, "/config/PROD/orders/application", payload, domain.Release{Version: version}); err != nil {
t.Fatal(err)
}
}
stream := store.Watch(ctx, "/config/PROD/orders/application", 5)
for revision := int64(5); revision <= 20; revision++ {
result := <-stream
if result.Err != nil || result.Event.Revision != revision {
t.Fatalf("unexpected replay at revision %d: %#v", revision, result)
}
}
}

View File

@@ -2,13 +2,17 @@ package runtime
import ( import (
"context" "context"
"errors"
"github.com/longpeng/configcenter/internal/domain" "github.com/longpeng/configcenter/internal/domain"
) )
var ErrRevisionCompacted = errors.New("runtime revision compacted")
type WatchResult struct { type WatchResult struct {
Event domain.ConfigEvent Event domain.ConfigEvent
Err error Err error
CompactRevision int64
} }
type Store interface { type Store interface {

131
internal/runtime/stream.go Normal file
View File

@@ -0,0 +1,131 @@
package runtime
import (
"context"
"errors"
"time"
"github.com/longpeng/configcenter/internal/domain"
)
const (
streamInitialBackoff = 100 * time.Millisecond
streamMaxBackoff = 5 * time.Second
)
// StreamSnapshots exposes the runtime watch contract used by every transport.
// startRevision is inclusive. A value <= 0 means a fresh subscription: emit a
// FULL_SYNC snapshot first, then watch from snapshot revision + 1.
func StreamSnapshots(ctx context.Context, store Store, key string, startRevision int64) <-chan domain.ConfigEvent {
events := make(chan domain.ConfigEvent, 16)
go func() {
defer close(events)
nextRevision := startRevision
if nextRevision <= 0 {
current, ok := readSnapshot(ctx, store, key)
if !ok || !sendEvent(ctx, events, fullSync(current)) {
return
}
nextRevision = current.Revision + 1
}
backoff := streamInitialBackoff
for {
stream := store.Watch(ctx, key, nextRevision)
compacted := false
for {
select {
case <-ctx.Done():
return
case result, open := <-stream:
if !open {
goto reconnect
}
if result.Err != nil {
compacted = errors.Is(result.Err, ErrRevisionCompacted)
goto reconnect
}
if result.Event.Revision > 0 {
if nextRevision > 0 && result.Event.Revision < nextRevision {
continue
}
nextRevision = result.Event.Revision + 1
}
if !sendEvent(ctx, events, result.Event) {
return
}
backoff = streamInitialBackoff
}
}
reconnect:
if ctx.Err() != nil {
return
}
if compacted {
current, ok := readSnapshot(ctx, store, key)
if !ok || !sendEvent(ctx, events, fullSync(current)) {
return
}
nextRevision = current.Revision + 1
backoff = streamInitialBackoff
continue
}
if !waitForStreamRetry(ctx, backoff) {
return
}
backoff = nextStreamBackoff(backoff)
}
}()
return events
}
func readSnapshot(ctx context.Context, store Store, key string) (domain.RuntimeConfig, bool) {
backoff := streamInitialBackoff
for {
current, err := store.Get(ctx, key)
if err == nil {
return current, true
}
if !waitForStreamRetry(ctx, backoff) {
return domain.RuntimeConfig{}, false
}
backoff = nextStreamBackoff(backoff)
}
}
func fullSync(current domain.RuntimeConfig) domain.ConfigEvent {
return domain.ConfigEvent{Type: "FULL_SYNC", Items: current.Items, Revision: current.Revision}
}
func sendEvent(ctx context.Context, target chan<- domain.ConfigEvent, event domain.ConfigEvent) bool {
select {
case target <- event:
return true
case <-ctx.Done():
return false
}
}
func waitForStreamRetry(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextStreamBackoff(current time.Duration) time.Duration {
if current >= streamMaxBackoff {
return streamMaxBackoff
}
next := current * 2
if next > streamMaxBackoff {
return streamMaxBackoff
}
return next
}

View File

@@ -3,7 +3,6 @@ package watch
import ( import (
"context" "context"
"sync" "sync"
"time"
"github.com/longpeng/configcenter/internal/domain" "github.com/longpeng/configcenter/internal/domain"
runtimepkg "github.com/longpeng/configcenter/internal/runtime" runtimepkg "github.com/longpeng/configcenter/internal/runtime"
@@ -69,58 +68,8 @@ func (h *Hub) Subscribe(key string, scope Scope) (<-chan domain.ConfigEvent, fun
} }
func (h *Hub) run(ctx context.Context, key string, target *keyWatch) { func (h *Hub) run(ctx context.Context, key string, target *keyWatch) {
nextRevision := int64(0) for event := range runtimepkg.StreamSnapshots(ctx, h.runtime, key, 0) {
backoff := 100 * time.Millisecond h.broadcast(key, target, event)
for {
current, err := h.runtime.Get(ctx, key)
if err == nil {
if current.Revision > 0 {
nextRevision = current.Revision + 1
}
h.broadcast(key, target, domain.ConfigEvent{Type: "UPDATED", Items: current.Items, Revision: current.Revision})
break
}
if !waitForRetry(ctx, backoff) {
return
}
backoff = nextBackoff(backoff)
}
backoff = 100 * time.Millisecond
for {
stream := h.runtime.Watch(ctx, key, nextRevision)
disconnected := false
for !disconnected {
select {
case <-ctx.Done():
return
case result, open := <-stream:
if !open || result.Err != nil {
disconnected = true
continue
}
if result.Event.Revision > 0 {
if nextRevision > 0 && result.Event.Revision < nextRevision {
continue
}
nextRevision = result.Event.Revision + 1
}
h.broadcast(key, target, result.Event)
backoff = 100 * time.Millisecond
}
}
if ctx.Err() != nil {
return
}
if current, err := h.runtime.Get(ctx, key); err == nil {
if current.Revision > 0 {
nextRevision = current.Revision + 1
}
h.broadcast(key, target, domain.ConfigEvent{Type: "UPDATED", Items: current.Items, Revision: current.Revision})
}
if !waitForRetry(ctx, backoff) {
return
}
backoff = nextBackoff(backoff)
} }
} }
@@ -167,25 +116,3 @@ func (h *Hub) broadcast(key string, target *keyWatch, event domain.ConfigEvent)
} }
} }
} }
func waitForRetry(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextBackoff(current time.Duration) time.Duration {
if current >= 5*time.Second {
return 5 * time.Second
}
next := current * 2
if next > 5*time.Second {
return 5 * time.Second
}
return next
}

View File

@@ -35,12 +35,8 @@ func TestHubReconnectsFromNextRevision(t *testing.T) {
t.Fatalf("unexpected first event: %#v", event) t.Fatalf("unexpected first event: %#v", event)
} }
runtimeStore.setCurrent(domain.RuntimeConfig{Items: map[string]string{"feature": "two"}, Revision: 4}) runtimeStore.setCurrent(domain.RuntimeConfig{Items: map[string]string{"feature": "four"}, Revision: 6})
first <- runtimepkg.WatchResult{Err: errors.New("watch connection lost")} first <- runtimepkg.WatchResult{Err: errors.New("watch connection lost")}
resync := receiveEvent(t, events)
if resync.Revision != 4 || resync.Force || resync.Items["feature"] != "two" {
t.Fatalf("unexpected reconnect resync: %#v", resync)
}
waitForWatchCalls(t, runtimeStore, 2) waitForWatchCalls(t, runtimeStore, 2)
calls := runtimeStore.watchRevisions() calls := runtimeStore.watchRevisions()
@@ -48,11 +44,47 @@ func TestHubReconnectsFromNextRevision(t *testing.T) {
t.Fatalf("unexpected watch revisions: %v", calls) t.Fatalf("unexpected watch revisions: %v", calls)
} }
second <- runtimepkg.WatchResult{Event: domain.ConfigEvent{Type: "UPDATED", Items: map[string]string{"feature": "stale"}, Revision: 4}}
second <- runtimepkg.WatchResult{Event: domain.ConfigEvent{Type: "UPDATED", Items: map[string]string{"feature": "three"}, Revision: 5}} second <- runtimepkg.WatchResult{Event: domain.ConfigEvent{Type: "UPDATED", Items: map[string]string{"feature": "three"}, Revision: 5}}
second <- runtimepkg.WatchResult{Event: domain.ConfigEvent{Type: "UPDATED", Items: map[string]string{"feature": "four"}, Revision: 6}}
if event := receiveEvent(t, events); event.Revision != 5 || event.Items["feature"] != "three" { if event := receiveEvent(t, events); event.Revision != 5 || event.Items["feature"] != "three" {
t.Fatalf("unexpected resumed event: %#v", event) t.Fatalf("unexpected resumed event: %#v", event)
} }
if event := receiveEvent(t, events); event.Revision != 6 || event.Items["feature"] != "four" {
t.Fatalf("unexpected second resumed event: %#v", event)
}
}
func TestHubFallsBackToFullSyncOnlyWhenRevisionCompacted(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
first := make(chan runtimepkg.WatchResult, 4)
second := make(chan runtimepkg.WatchResult, 4)
runtimeStore := &fakeRuntimeStore{
current: domain.RuntimeConfig{Items: map[string]string{"feature": "one"}, Revision: 3},
streams: []chan runtimepkg.WatchResult{first, second},
}
hub := New(ctx, runtimeStore)
events, unsubscribe := hub.Subscribe("/config/PROD/orders/application", Scope{ApplicationID: 1, NamespaceID: 2, EnvironmentID: 3})
defer unsubscribe()
initial := receiveEvent(t, events)
if initial.Type != "FULL_SYNC" || initial.Revision != 3 {
t.Fatalf("unexpected initial event: %#v", initial)
}
waitForWatchCalls(t, runtimeStore, 1)
runtimeStore.setCurrent(domain.RuntimeConfig{Items: map[string]string{"feature": "latest"}, Revision: 8})
first <- runtimepkg.WatchResult{Err: errors.Join(runtimepkg.ErrRevisionCompacted, errors.New("compacted")), CompactRevision: 7}
resync := receiveEvent(t, events)
if resync.Type != "FULL_SYNC" || resync.Revision != 8 || resync.Items["feature"] != "latest" {
t.Fatalf("unexpected compacted resync: %#v", resync)
}
waitForWatchCalls(t, runtimeStore, 2)
if calls := runtimeStore.watchRevisions(); calls[1] != 9 {
t.Fatalf("watch did not resume from snapshot+1: %v", calls)
}
} }
func TestHubRefreshesMatchingScopeWithoutRevisionChange(t *testing.T) { func TestHubRefreshesMatchingScopeWithoutRevisionChange(t *testing.T) {
@@ -97,7 +129,7 @@ func TestHubRetriesInitialSnapshotBeforeWatching(t *testing.T) {
defer unsubscribe() defer unsubscribe()
event := receiveEvent(t, events) event := receiveEvent(t, events)
if event.Revision != 9 || event.Items["feature"] != "ready" { if event.Type != "FULL_SYNC" || event.Revision != 9 || event.Items["feature"] != "ready" {
t.Fatalf("unexpected snapshot after retry: %#v", event) t.Fatalf("unexpected snapshot after retry: %#v", event)
} }
waitForWatchCalls(t, runtimeStore, 1) waitForWatchCalls(t, runtimeStore, 1)

857
pkg/proto/v1/config.pb.go Normal file
View File

@@ -0,0 +1,857 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v4.25.1
// source: configcenter/v1/config.proto
package configcenterv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ConfigEvent_EventType int32
const (
ConfigEvent_FULL_SYNC ConfigEvent_EventType = 0
ConfigEvent_UPDATED ConfigEvent_EventType = 1
)
// Enum value maps for ConfigEvent_EventType.
var (
ConfigEvent_EventType_name = map[int32]string{
0: "FULL_SYNC",
1: "UPDATED",
}
ConfigEvent_EventType_value = map[string]int32{
"FULL_SYNC": 0,
"UPDATED": 1,
}
)
func (x ConfigEvent_EventType) Enum() *ConfigEvent_EventType {
p := new(ConfigEvent_EventType)
*p = x
return p
}
func (x ConfigEvent_EventType) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ConfigEvent_EventType) Descriptor() protoreflect.EnumDescriptor {
return file_configcenter_v1_config_proto_enumTypes[0].Descriptor()
}
func (ConfigEvent_EventType) Type() protoreflect.EnumType {
return &file_configcenter_v1_config_proto_enumTypes[0]
}
func (x ConfigEvent_EventType) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ConfigEvent_EventType.Descriptor instead.
func (ConfigEvent_EventType) EnumDescriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{4, 0}
}
type ConfigItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConfigItem) Reset() {
*x = ConfigItem{}
mi := &file_configcenter_v1_config_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConfigItem) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigItem) ProtoMessage() {}
func (x *ConfigItem) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConfigItem.ProtoReflect.Descriptor instead.
func (*ConfigItem) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{0}
}
func (x *ConfigItem) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *ConfigItem) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
type GetConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Env string `protobuf:"bytes,1,opt,name=env,proto3" json:"env,omitempty"`
App string `protobuf:"bytes,2,opt,name=app,proto3" json:"app,omitempty"`
Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"`
Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"`
Instance string `protobuf:"bytes,5,opt,name=instance,proto3" json:"instance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetConfigRequest) Reset() {
*x = GetConfigRequest{}
mi := &file_configcenter_v1_config_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetConfigRequest) ProtoMessage() {}
func (x *GetConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead.
func (*GetConfigRequest) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{1}
}
func (x *GetConfigRequest) GetEnv() string {
if x != nil {
return x.Env
}
return ""
}
func (x *GetConfigRequest) GetApp() string {
if x != nil {
return x.App
}
return ""
}
func (x *GetConfigRequest) GetNamespace() string {
if x != nil {
return x.Namespace
}
return ""
}
func (x *GetConfigRequest) GetIp() string {
if x != nil {
return x.Ip
}
return ""
}
func (x *GetConfigRequest) GetInstance() string {
if x != nil {
return x.Instance
}
return ""
}
type GetConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Items []*ConfigItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
Revision int64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"`
ReleaseVersion int64 `protobuf:"varint,3,opt,name=release_version,json=releaseVersion,proto3" json:"release_version,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetConfigResponse) Reset() {
*x = GetConfigResponse{}
mi := &file_configcenter_v1_config_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetConfigResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetConfigResponse) ProtoMessage() {}
func (x *GetConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead.
func (*GetConfigResponse) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{2}
}
func (x *GetConfigResponse) GetItems() []*ConfigItem {
if x != nil {
return x.Items
}
return nil
}
func (x *GetConfigResponse) GetRevision() int64 {
if x != nil {
return x.Revision
}
return 0
}
func (x *GetConfigResponse) GetReleaseVersion() int64 {
if x != nil {
return x.ReleaseVersion
}
return 0
}
type WatchConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Env string `protobuf:"bytes,1,opt,name=env,proto3" json:"env,omitempty"`
App string `protobuf:"bytes,2,opt,name=app,proto3" json:"app,omitempty"`
Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"`
StartRevision int64 `protobuf:"varint,4,opt,name=start_revision,json=startRevision,proto3" json:"start_revision,omitempty"`
Ip string `protobuf:"bytes,5,opt,name=ip,proto3" json:"ip,omitempty"`
Instance string `protobuf:"bytes,6,opt,name=instance,proto3" json:"instance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WatchConfigRequest) Reset() {
*x = WatchConfigRequest{}
mi := &file_configcenter_v1_config_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WatchConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WatchConfigRequest) ProtoMessage() {}
func (x *WatchConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WatchConfigRequest.ProtoReflect.Descriptor instead.
func (*WatchConfigRequest) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{3}
}
func (x *WatchConfigRequest) GetEnv() string {
if x != nil {
return x.Env
}
return ""
}
func (x *WatchConfigRequest) GetApp() string {
if x != nil {
return x.App
}
return ""
}
func (x *WatchConfigRequest) GetNamespace() string {
if x != nil {
return x.Namespace
}
return ""
}
func (x *WatchConfigRequest) GetStartRevision() int64 {
if x != nil {
return x.StartRevision
}
return 0
}
func (x *WatchConfigRequest) GetIp() string {
if x != nil {
return x.Ip
}
return ""
}
func (x *WatchConfigRequest) GetInstance() string {
if x != nil {
return x.Instance
}
return ""
}
type ConfigEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type ConfigEvent_EventType `protobuf:"varint,1,opt,name=type,proto3,enum=configcenter.v1.ConfigEvent_EventType" json:"type,omitempty"`
Items []*ConfigItem `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"`
Revision int64 `protobuf:"varint,3,opt,name=revision,proto3" json:"revision,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConfigEvent) Reset() {
*x = ConfigEvent{}
mi := &file_configcenter_v1_config_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConfigEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigEvent) ProtoMessage() {}
func (x *ConfigEvent) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConfigEvent.ProtoReflect.Descriptor instead.
func (*ConfigEvent) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{4}
}
func (x *ConfigEvent) GetType() ConfigEvent_EventType {
if x != nil {
return x.Type
}
return ConfigEvent_FULL_SYNC
}
func (x *ConfigEvent) GetItems() []*ConfigItem {
if x != nil {
return x.Items
}
return nil
}
func (x *ConfigEvent) GetRevision() int64 {
if x != nil {
return x.Revision
}
return 0
}
type PublishRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
EnvId int64 `protobuf:"varint,1,opt,name=env_id,json=envId,proto3" json:"env_id,omitempty"`
AppId int64 `protobuf:"varint,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"`
NamespaceId int64 `protobuf:"varint,3,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"`
Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"`
Operator string `protobuf:"bytes,5,opt,name=operator,proto3" json:"operator,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PublishRequest) Reset() {
*x = PublishRequest{}
mi := &file_configcenter_v1_config_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PublishRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PublishRequest) ProtoMessage() {}
func (x *PublishRequest) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PublishRequest.ProtoReflect.Descriptor instead.
func (*PublishRequest) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{5}
}
func (x *PublishRequest) GetEnvId() int64 {
if x != nil {
return x.EnvId
}
return 0
}
func (x *PublishRequest) GetAppId() int64 {
if x != nil {
return x.AppId
}
return 0
}
func (x *PublishRequest) GetNamespaceId() int64 {
if x != nil {
return x.NamespaceId
}
return 0
}
func (x *PublishRequest) GetComment() string {
if x != nil {
return x.Comment
}
return ""
}
func (x *PublishRequest) GetOperator() string {
if x != nil {
return x.Operator
}
return ""
}
type PublishResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ReleaseId int64 `protobuf:"varint,1,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"`
ReleaseVersion int64 `protobuf:"varint,2,opt,name=release_version,json=releaseVersion,proto3" json:"release_version,omitempty"`
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PublishResponse) Reset() {
*x = PublishResponse{}
mi := &file_configcenter_v1_config_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PublishResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PublishResponse) ProtoMessage() {}
func (x *PublishResponse) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PublishResponse.ProtoReflect.Descriptor instead.
func (*PublishResponse) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{6}
}
func (x *PublishResponse) GetReleaseId() int64 {
if x != nil {
return x.ReleaseId
}
return 0
}
func (x *PublishResponse) GetReleaseVersion() int64 {
if x != nil {
return x.ReleaseVersion
}
return 0
}
func (x *PublishResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
type RollbackRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
EnvId int64 `protobuf:"varint,1,opt,name=env_id,json=envId,proto3" json:"env_id,omitempty"`
AppId int64 `protobuf:"varint,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"`
NamespaceId int64 `protobuf:"varint,3,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"`
TargetVersion int64 `protobuf:"varint,4,opt,name=target_version,json=targetVersion,proto3" json:"target_version,omitempty"`
Operator string `protobuf:"bytes,5,opt,name=operator,proto3" json:"operator,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RollbackRequest) Reset() {
*x = RollbackRequest{}
mi := &file_configcenter_v1_config_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RollbackRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RollbackRequest) ProtoMessage() {}
func (x *RollbackRequest) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RollbackRequest.ProtoReflect.Descriptor instead.
func (*RollbackRequest) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{7}
}
func (x *RollbackRequest) GetEnvId() int64 {
if x != nil {
return x.EnvId
}
return 0
}
func (x *RollbackRequest) GetAppId() int64 {
if x != nil {
return x.AppId
}
return 0
}
func (x *RollbackRequest) GetNamespaceId() int64 {
if x != nil {
return x.NamespaceId
}
return 0
}
func (x *RollbackRequest) GetTargetVersion() int64 {
if x != nil {
return x.TargetVersion
}
return 0
}
func (x *RollbackRequest) GetOperator() string {
if x != nil {
return x.Operator
}
return ""
}
type RollbackResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ReleaseId int64 `protobuf:"varint,1,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"`
NewReleaseVersion int64 `protobuf:"varint,2,opt,name=new_release_version,json=newReleaseVersion,proto3" json:"new_release_version,omitempty"`
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RollbackResponse) Reset() {
*x = RollbackResponse{}
mi := &file_configcenter_v1_config_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RollbackResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RollbackResponse) ProtoMessage() {}
func (x *RollbackResponse) ProtoReflect() protoreflect.Message {
mi := &file_configcenter_v1_config_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RollbackResponse.ProtoReflect.Descriptor instead.
func (*RollbackResponse) Descriptor() ([]byte, []int) {
return file_configcenter_v1_config_proto_rawDescGZIP(), []int{8}
}
func (x *RollbackResponse) GetReleaseId() int64 {
if x != nil {
return x.ReleaseId
}
return 0
}
func (x *RollbackResponse) GetNewReleaseVersion() int64 {
if x != nil {
return x.NewReleaseVersion
}
return 0
}
func (x *RollbackResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
var File_configcenter_v1_config_proto protoreflect.FileDescriptor
var file_configcenter_v1_config_proto_rawDesc = string([]byte{
0x0a, 0x1c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2f, 0x76,
0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x22,
0x34, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x10, 0x0a,
0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x80, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e,
0x76, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x10, 0x0a, 0x03,
0x61, 0x70, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x61, 0x70, 0x70, 0x12, 0x1c,
0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x70, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x70, 0x12, 0x1a, 0x0a, 0x08,
0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x22, 0x8b, 0x01, 0x0a, 0x11, 0x47, 0x65, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31,
0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d,
0x73, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a,
0x0f, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x56,
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xa9, 0x01, 0x0a, 0x12, 0x57, 0x61, 0x74, 0x63, 0x68,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a,
0x03, 0x65, 0x6e, 0x76, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12,
0x10, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x61, 0x70,
0x70, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12,
0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f,
0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x02, 0x69, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e,
0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e,
0x63, 0x65, 0x22, 0xc1, 0x01, 0x0a, 0x0b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x76, 0x65,
0x6e, 0x74, 0x12, 0x3a, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e,
0x32, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x76, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x31,
0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d,
0x73, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20,
0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x27, 0x0a,
0x09, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0d, 0x0a, 0x09, 0x46, 0x55,
0x4c, 0x4c, 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44,
0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x22, 0x97, 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x62, 0x6c, 0x69,
0x73, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x65, 0x6e, 0x76,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x65, 0x6e, 0x76, 0x49, 0x64,
0x12, 0x15, 0x0a, 0x06, 0x61, 0x70, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x05, 0x61, 0x70, 0x70, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x61, 0x6d, 0x65, 0x73,
0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6e,
0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d,
0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72,
0x22, 0x71, 0x0a, 0x0f, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65,
0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x76, 0x65,
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x6c,
0x65, 0x61, 0x73, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73,
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
0x74, 0x75, 0x73, 0x22, 0xa5, 0x01, 0x0a, 0x0f, 0x52, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x65, 0x6e, 0x76, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x65, 0x6e, 0x76, 0x49, 0x64, 0x12, 0x15,
0x0a, 0x06, 0x61, 0x70, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
0x61, 0x70, 0x70, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61,
0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6e, 0x61, 0x6d,
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x79, 0x0a, 0x10, 0x52,
0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x49, 0x64, 0x12, 0x2e,
0x0a, 0x13, 0x6e, 0x65, 0x77, 0x5f, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x76, 0x65,
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6e, 0x65, 0x77,
0x52, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16,
0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x32, 0xb7, 0x01, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65,
0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0b,
0x57, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x23, 0x2e, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x61,
0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x1c, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01,
0x32, 0xb9, 0x01, 0x0a, 0x0c, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x12, 0x52, 0x0a, 0x0d, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x12, 0x1f, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0e, 0x52, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63,
0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x20, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x6c, 0x62, 0x61,
0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x6c,
0x62, 0x61, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3e, 0x5a, 0x3c,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x6f, 0x6e, 0x67, 0x70,
0x65, 0x6e, 0x67, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72,
0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
})
var (
file_configcenter_v1_config_proto_rawDescOnce sync.Once
file_configcenter_v1_config_proto_rawDescData []byte
)
func file_configcenter_v1_config_proto_rawDescGZIP() []byte {
file_configcenter_v1_config_proto_rawDescOnce.Do(func() {
file_configcenter_v1_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_configcenter_v1_config_proto_rawDesc), len(file_configcenter_v1_config_proto_rawDesc)))
})
return file_configcenter_v1_config_proto_rawDescData
}
var file_configcenter_v1_config_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_configcenter_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_configcenter_v1_config_proto_goTypes = []any{
(ConfigEvent_EventType)(0), // 0: configcenter.v1.ConfigEvent.EventType
(*ConfigItem)(nil), // 1: configcenter.v1.ConfigItem
(*GetConfigRequest)(nil), // 2: configcenter.v1.GetConfigRequest
(*GetConfigResponse)(nil), // 3: configcenter.v1.GetConfigResponse
(*WatchConfigRequest)(nil), // 4: configcenter.v1.WatchConfigRequest
(*ConfigEvent)(nil), // 5: configcenter.v1.ConfigEvent
(*PublishRequest)(nil), // 6: configcenter.v1.PublishRequest
(*PublishResponse)(nil), // 7: configcenter.v1.PublishResponse
(*RollbackRequest)(nil), // 8: configcenter.v1.RollbackRequest
(*RollbackResponse)(nil), // 9: configcenter.v1.RollbackResponse
}
var file_configcenter_v1_config_proto_depIdxs = []int32{
1, // 0: configcenter.v1.GetConfigResponse.items:type_name -> configcenter.v1.ConfigItem
0, // 1: configcenter.v1.ConfigEvent.type:type_name -> configcenter.v1.ConfigEvent.EventType
1, // 2: configcenter.v1.ConfigEvent.items:type_name -> configcenter.v1.ConfigItem
2, // 3: configcenter.v1.ConfigService.GetConfig:input_type -> configcenter.v1.GetConfigRequest
4, // 4: configcenter.v1.ConfigService.WatchConfig:input_type -> configcenter.v1.WatchConfigRequest
6, // 5: configcenter.v1.AdminService.PublishConfig:input_type -> configcenter.v1.PublishRequest
8, // 6: configcenter.v1.AdminService.RollbackConfig:input_type -> configcenter.v1.RollbackRequest
3, // 7: configcenter.v1.ConfigService.GetConfig:output_type -> configcenter.v1.GetConfigResponse
5, // 8: configcenter.v1.ConfigService.WatchConfig:output_type -> configcenter.v1.ConfigEvent
7, // 9: configcenter.v1.AdminService.PublishConfig:output_type -> configcenter.v1.PublishResponse
9, // 10: configcenter.v1.AdminService.RollbackConfig:output_type -> configcenter.v1.RollbackResponse
7, // [7:11] is the sub-list for method output_type
3, // [3:7] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_configcenter_v1_config_proto_init() }
func file_configcenter_v1_config_proto_init() {
if File_configcenter_v1_config_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_configcenter_v1_config_proto_rawDesc), len(file_configcenter_v1_config_proto_rawDesc)),
NumEnums: 1,
NumMessages: 9,
NumExtensions: 0,
NumServices: 2,
},
GoTypes: file_configcenter_v1_config_proto_goTypes,
DependencyIndexes: file_configcenter_v1_config_proto_depIdxs,
EnumInfos: file_configcenter_v1_config_proto_enumTypes,
MessageInfos: file_configcenter_v1_config_proto_msgTypes,
}.Build()
File_configcenter_v1_config_proto = out.File
file_configcenter_v1_config_proto_goTypes = nil
file_configcenter_v1_config_proto_depIdxs = nil
}

View File

@@ -0,0 +1,303 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.1
// source: configcenter/v1/config.proto
package configcenterv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ConfigService_GetConfig_FullMethodName = "/configcenter.v1.ConfigService/GetConfig"
ConfigService_WatchConfig_FullMethodName = "/configcenter.v1.ConfigService/WatchConfig"
)
// ConfigServiceClient is the client API for ConfigService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ConfigServiceClient interface {
GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error)
WatchConfig(ctx context.Context, in *WatchConfigRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConfigEvent], error)
}
type configServiceClient struct {
cc grpc.ClientConnInterface
}
func NewConfigServiceClient(cc grpc.ClientConnInterface) ConfigServiceClient {
return &configServiceClient{cc}
}
func (c *configServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetConfigResponse)
err := c.cc.Invoke(ctx, ConfigService_GetConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *configServiceClient) WatchConfig(ctx context.Context, in *WatchConfigRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConfigEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &ConfigService_ServiceDesc.Streams[0], ConfigService_WatchConfig_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchConfigRequest, ConfigEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ConfigService_WatchConfigClient = grpc.ServerStreamingClient[ConfigEvent]
// ConfigServiceServer is the server API for ConfigService service.
// All implementations must embed UnimplementedConfigServiceServer
// for forward compatibility.
type ConfigServiceServer interface {
GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error)
WatchConfig(*WatchConfigRequest, grpc.ServerStreamingServer[ConfigEvent]) error
mustEmbedUnimplementedConfigServiceServer()
}
// UnimplementedConfigServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedConfigServiceServer struct{}
func (UnimplementedConfigServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
}
func (UnimplementedConfigServiceServer) WatchConfig(*WatchConfigRequest, grpc.ServerStreamingServer[ConfigEvent]) error {
return status.Errorf(codes.Unimplemented, "method WatchConfig not implemented")
}
func (UnimplementedConfigServiceServer) mustEmbedUnimplementedConfigServiceServer() {}
func (UnimplementedConfigServiceServer) testEmbeddedByValue() {}
// UnsafeConfigServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ConfigServiceServer will
// result in compilation errors.
type UnsafeConfigServiceServer interface {
mustEmbedUnimplementedConfigServiceServer()
}
func RegisterConfigServiceServer(s grpc.ServiceRegistrar, srv ConfigServiceServer) {
// If the following call pancis, it indicates UnimplementedConfigServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ConfigService_ServiceDesc, srv)
}
func _ConfigService_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConfigServiceServer).GetConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConfigService_GetConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConfigServiceServer).GetConfig(ctx, req.(*GetConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConfigService_WatchConfig_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchConfigRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(ConfigServiceServer).WatchConfig(m, &grpc.GenericServerStream[WatchConfigRequest, ConfigEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ConfigService_WatchConfigServer = grpc.ServerStreamingServer[ConfigEvent]
// ConfigService_ServiceDesc is the grpc.ServiceDesc for ConfigService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ConfigService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "configcenter.v1.ConfigService",
HandlerType: (*ConfigServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetConfig",
Handler: _ConfigService_GetConfig_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchConfig",
Handler: _ConfigService_WatchConfig_Handler,
ServerStreams: true,
},
},
Metadata: "configcenter/v1/config.proto",
}
const (
AdminService_PublishConfig_FullMethodName = "/configcenter.v1.AdminService/PublishConfig"
AdminService_RollbackConfig_FullMethodName = "/configcenter.v1.AdminService/RollbackConfig"
)
// AdminServiceClient is the client API for AdminService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AdminServiceClient interface {
PublishConfig(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error)
RollbackConfig(ctx context.Context, in *RollbackRequest, opts ...grpc.CallOption) (*RollbackResponse, error)
}
type adminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient {
return &adminServiceClient{cc}
}
func (c *adminServiceClient) PublishConfig(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishResponse)
err := c.cc.Invoke(ctx, AdminService_PublishConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminServiceClient) RollbackConfig(ctx context.Context, in *RollbackRequest, opts ...grpc.CallOption) (*RollbackResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RollbackResponse)
err := c.cc.Invoke(ctx, AdminService_RollbackConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminServiceServer is the server API for AdminService service.
// All implementations must embed UnimplementedAdminServiceServer
// for forward compatibility.
type AdminServiceServer interface {
PublishConfig(context.Context, *PublishRequest) (*PublishResponse, error)
RollbackConfig(context.Context, *RollbackRequest) (*RollbackResponse, error)
mustEmbedUnimplementedAdminServiceServer()
}
// UnimplementedAdminServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminServiceServer struct{}
func (UnimplementedAdminServiceServer) PublishConfig(context.Context, *PublishRequest) (*PublishResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PublishConfig not implemented")
}
func (UnimplementedAdminServiceServer) RollbackConfig(context.Context, *RollbackRequest) (*RollbackResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RollbackConfig not implemented")
}
func (UnimplementedAdminServiceServer) mustEmbedUnimplementedAdminServiceServer() {}
func (UnimplementedAdminServiceServer) testEmbeddedByValue() {}
// UnsafeAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminServiceServer will
// result in compilation errors.
type UnsafeAdminServiceServer interface {
mustEmbedUnimplementedAdminServiceServer()
}
func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminService_ServiceDesc, srv)
}
func _AdminService_PublishConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminServiceServer).PublishConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminService_PublishConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminServiceServer).PublishConfig(ctx, req.(*PublishRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminService_RollbackConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RollbackRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminServiceServer).RollbackConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminService_RollbackConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminServiceServer).RollbackConfig(ctx, req.(*RollbackRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "configcenter.v1.AdminService",
HandlerType: (*AdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "PublishConfig",
Handler: _AdminService_PublishConfig_Handler,
},
{
MethodName: "RollbackConfig",
Handler: _AdminService_RollbackConfig_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "configcenter/v1/config.proto",
}

View File

@@ -5,6 +5,7 @@ package configsdk
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -17,6 +18,12 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
configcenterv1 "github.com/longpeng/configcenter/pkg/proto/v1"
) )
type Options struct { type Options struct {
@@ -30,6 +37,17 @@ type Options struct {
HTTPClient *http.Client HTTPClient *http.Client
} }
type GRPCOptions struct {
Target string
Env string
App string
Token string
IP string
Instance string
CacheFile string
DialOptions []grpc.DialOption
}
type Client struct { type Client struct {
baseURL string baseURL string
env string env string
@@ -39,6 +57,8 @@ type Client struct {
instance string instance string
cacheFile string cacheFile string
http *http.Client http *http.Client
grpcConn *grpc.ClientConn
grpc configcenterv1.ConfigServiceClient
mu sync.RWMutex mu sync.RWMutex
cache map[string]map[string]string cache map[string]map[string]string
@@ -94,6 +114,46 @@ func New(options Options) (*Client, error) {
return client, nil return client, nil
} }
func NewGRPC(options GRPCOptions) (*Client, error) {
if strings.TrimSpace(options.Target) == "" || strings.TrimSpace(options.Env) == "" || strings.TrimSpace(options.App) == "" {
return nil, errors.New("gRPC target, environment and application are required")
}
dialOptions := append([]grpc.DialOption(nil), options.DialOptions...)
if len(dialOptions) == 0 {
dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})))
}
connection, err := grpc.NewClient(strings.TrimSpace(options.Target), dialOptions...)
if err != nil {
return nil, fmt.Errorf("connect config center gRPC: %w", err)
}
client := &Client{
env: strings.TrimSpace(options.Env),
app: strings.TrimSpace(options.App),
token: strings.TrimSpace(options.Token),
ip: strings.TrimSpace(options.IP),
instance: strings.TrimSpace(options.Instance),
cacheFile: options.CacheFile,
grpcConn: connection,
grpc: configcenterv1.NewConfigServiceClient(connection),
cache: make(map[string]map[string]string),
revisions: make(map[string]int64),
}
if options.CacheFile != "" {
if err := client.loadDiskCache(); err != nil && !errors.Is(err, os.ErrNotExist) {
_ = connection.Close()
return nil, fmt.Errorf("load config cache: %w", err)
}
}
return client, nil
}
func (c *Client) Close() error {
if c.grpcConn != nil {
return c.grpcConn.Close()
}
return nil
}
func (c *Client) GetString(namespace, key, fallback string) string { func (c *Client) GetString(namespace, key, fallback string) string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -130,6 +190,9 @@ func (c *Client) Snapshot(namespace string) map[string]string {
} }
func (c *Client) Load(ctx context.Context, namespace string) error { func (c *Client) Load(ctx context.Context, namespace string) error {
if c.grpc != nil {
return c.loadGRPC(ctx, namespace)
}
endpoint := c.endpoint("/v1/config", namespace) endpoint := c.endpoint("/v1/config", namespace)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -181,6 +244,9 @@ func (c *Client) WatchAndSync(ctx context.Context, namespace string) error {
} }
func (c *Client) watchOnce(ctx context.Context, namespace string) error { func (c *Client) watchOnce(ctx context.Context, namespace string) error {
if c.grpc != nil {
return c.watchGRPCOnce(ctx, namespace)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/v1/watch", namespace), nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/v1/watch", namespace), nil)
if err != nil { if err != nil {
return err return err
@@ -226,6 +292,56 @@ func (c *Client) watchOnce(ctx context.Context, namespace string) error {
return io.EOF return io.EOF
} }
func (c *Client) loadGRPC(ctx context.Context, namespace string) error {
response, err := c.grpc.GetConfig(c.grpcContext(ctx), &configcenterv1.GetConfigRequest{
Env: c.env, App: c.app, Namespace: namespace, Ip: c.ip, Instance: c.instance,
})
if err != nil {
return err
}
return c.apply(namespace, protoItemMap(response.GetItems()), response.GetRevision())
}
func (c *Client) watchGRPCOnce(ctx context.Context, namespace string) error {
c.mu.RLock()
lastRevision := c.revisions[namespace]
c.mu.RUnlock()
startRevision := int64(0)
if lastRevision > 0 {
startRevision = lastRevision + 1
}
stream, err := c.grpc.WatchConfig(c.grpcContext(ctx), &configcenterv1.WatchConfigRequest{
Env: c.env, App: c.app, Namespace: namespace, StartRevision: startRevision, Ip: c.ip, Instance: c.instance,
})
if err != nil {
return err
}
for {
event, err := stream.Recv()
if err != nil {
return err
}
if err := c.apply(namespace, protoItemMap(event.GetItems()), event.GetRevision()); err != nil {
return err
}
}
}
func (c *Client) grpcContext(ctx context.Context) context.Context {
if c.token == "" {
return ctx
}
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
}
func protoItemMap(items []*configcenterv1.ConfigItem) map[string]string {
result := make(map[string]string, len(items))
for _, item := range items {
result[item.GetKey()] = item.GetValue()
}
return result
}
func (c *Client) endpoint(path, namespace string) string { func (c *Client) endpoint(path, namespace string) string {
query := url.Values{"env": {c.env}, "app": {c.app}, "namespace": {namespace}} query := url.Values{"env": {c.env}, "app": {c.app}, "namespace": {namespace}}
if c.ip != "" { if c.ip != "" {

View File

@@ -1,4 +1,4 @@
"""Thread-safe Config Center client using only Python's standard library.""" """Thread-safe Config Center client with HTTP/SSE and optional gRPC transports."""
from __future__ import annotations from __future__ import annotations
@@ -24,20 +24,38 @@ class ConfigClient:
token: str | None = None, token: str | None = None,
ip: str | None = None, ip: str | None = None,
instance: str | None = None, instance: str | None = None,
transport: str = "http",
grpc_secure: bool = True,
): ):
if not base_url or not env or not app: if not base_url or not env or not app:
raise ValueError("base_url, env and app are required") raise ValueError("base_url, env and app are required")
if transport not in {"http", "grpc"}:
raise ValueError("transport must be 'http' or 'grpc'")
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._env = env self._env = env
self._app = app self._app = app
self._token = token or "" self._token = token or ""
self._ip = ip or "" self._ip = ip or ""
self._instance = instance or "" self._instance = instance or ""
self._transport = transport
self._grpc_secure = grpc_secure
self._grpc_channel: Any = None
self._grpc_stub: Any = None
self._grpc_pb2: Any = None
self._cache_file = Path(cache_file) if cache_file else None self._cache_file = Path(cache_file) if cache_file else None
self._cache: dict[str, dict[str, str]] = {} self._cache: dict[str, dict[str, str]] = {}
self._revisions: dict[str, int] = {} self._revisions: dict[str, int] = {}
self._lock = threading.RLock() self._lock = threading.RLock()
self._load_disk_cache() self._load_disk_cache()
if self._transport == "grpc":
self._init_grpc()
def close(self) -> None:
channel = self._grpc_channel
if channel is not None:
channel.close()
self._grpc_channel = None
self._grpc_stub = None
def get(self, namespace: str, key: str, default: Any = None) -> Any: def get(self, namespace: str, key: str, default: Any = None) -> Any:
with self._lock: with self._lock:
@@ -48,6 +66,9 @@ class ConfigClient:
return dict(self._cache.get(namespace, {})) return dict(self._cache.get(namespace, {}))
def load(self, namespace: str, timeout: float = 10.0) -> None: def load(self, namespace: str, timeout: float = 10.0) -> None:
if self._transport == "grpc":
self._load_grpc(namespace, timeout)
return
with urllib.request.urlopen(self._request("/v1/config", namespace), timeout=timeout) as response: with urllib.request.urlopen(self._request("/v1/config", namespace), timeout=timeout) as response:
payload = json.load(response)["data"] payload = json.load(response)["data"]
self._apply(namespace, payload.get("items", {}), int(payload.get("revision", 0))) self._apply(namespace, payload.get("items", {}), int(payload.get("revision", 0)))
@@ -61,24 +82,88 @@ class ConfigClient:
backoff = 1.0 backoff = 1.0
while True: while True:
try: try:
request = self._request("/v1/watch", namespace, {"Accept": "text/event-stream"}) if self._transport == "grpc":
with urllib.request.urlopen(request, timeout=None) as response: self._watch_grpc_once(namespace)
backoff = 1.0 else:
data_lines: list[str] = [] self._watch_http_once(namespace)
for raw_line in response: backoff = 1.0
line = raw_line.decode("utf-8").rstrip("\r\n")
if not line:
if data_lines:
event = json.loads("\n".join(data_lines))
self._apply(namespace, event.get("items", {}), int(event.get("revision", 0)))
data_lines.clear()
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
except Exception: except Exception:
time.sleep(backoff) time.sleep(backoff)
backoff = min(backoff * 2, 30.0) backoff = min(backoff * 2, 30.0)
def _watch_http_once(self, namespace: str) -> None:
request = self._request("/v1/watch", namespace, {"Accept": "text/event-stream"})
with urllib.request.urlopen(request, timeout=None) as response:
data_lines: list[str] = []
for raw_line in response:
line = raw_line.decode("utf-8").rstrip("\r\n")
if not line:
if data_lines:
event = json.loads("\n".join(data_lines))
self._apply(namespace, event.get("items", {}), int(event.get("revision", 0)))
data_lines.clear()
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
def _init_grpc(self) -> None:
try:
import grpc
from .v1 import config_pb2, config_pb2_grpc
except ImportError as exc:
raise RuntimeError(
"gRPC transport requires the 'grpc' extra: pip install 'configcenter-client[grpc]'"
) from exc
if self._grpc_secure:
credentials = grpc.ssl_channel_credentials()
channel = grpc.secure_channel(self._base_url, credentials)
else:
channel = grpc.insecure_channel(self._base_url)
self._grpc_channel = channel
self._grpc_stub = config_pb2_grpc.ConfigServiceStub(channel)
self._grpc_pb2 = config_pb2
def _load_grpc(self, namespace: str, timeout: float) -> None:
request = self._grpc_pb2.GetConfigRequest(
env=self._env,
app=self._app,
namespace=namespace,
ip=self._ip,
instance=self._instance,
)
response = self._grpc_stub.GetConfig(request, timeout=timeout, metadata=self._grpc_metadata())
self._apply(
namespace,
{item.key: item.value for item in response.items},
int(response.revision),
)
def _watch_grpc_once(self, namespace: str) -> None:
with self._lock:
last_revision = int(self._revisions.get(namespace, 0))
start_revision = last_revision + 1 if last_revision > 0 else 0
request = self._grpc_pb2.WatchConfigRequest(
env=self._env,
app=self._app,
namespace=namespace,
start_revision=start_revision,
ip=self._ip,
instance=self._instance,
)
stream = self._grpc_stub.WatchConfig(request, metadata=self._grpc_metadata())
for event in stream:
self._apply(
namespace,
{item.key: item.value for item in event.items},
int(event.revision),
)
def _grpc_metadata(self) -> tuple[tuple[str, str], ...]:
if not self._token:
return ()
return (("authorization", f"Bearer {self._token}"),)
def _url(self, path: str, namespace: str) -> str: def _url(self, path: str, namespace: str) -> str:
values = {"env": self._env, "app": self._app, "namespace": namespace} values = {"env": self._env, "app": self._app, "namespace": namespace}
if self._ip: if self._ip:
@@ -109,7 +194,7 @@ class ConfigClient:
return return
payload = json.loads(self._cache_file.read_text(encoding="utf-8")) payload = json.loads(self._cache_file.read_text(encoding="utf-8"))
self._cache = payload.get("namespaces", {}) self._cache = payload.get("namespaces", {})
self._revisions = payload.get("revisions", {}) self._revisions = {key: int(value) for key, value in payload.get("revisions", {}).items()}
def _save_disk_cache(self) -> None: def _save_disk_cache(self) -> None:
if not self._cache_file: if not self._cache_file:

View File

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: configcenter/v1/config.proto
# Protobuf Python Version: 5.29.0
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
29,
0,
'',
'configcenter/v1/config.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1c\x63onfigcenter/v1/config.proto\x12\x0f\x63onfigcenter.v1\"(\n\nConfigItem\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"]\n\x10GetConfigRequest\x12\x0b\n\x03\x65nv\x18\x01 \x01(\t\x12\x0b\n\x03\x61pp\x18\x02 \x01(\t\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\n\n\x02ip\x18\x04 \x01(\t\x12\x10\n\x08instance\x18\x05 \x01(\t\"j\n\x11GetConfigResponse\x12*\n\x05items\x18\x01 \x03(\x0b\x32\x1b.configcenter.v1.ConfigItem\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x17\n\x0frelease_version\x18\x03 \x01(\x03\"w\n\x12WatchConfigRequest\x12\x0b\n\x03\x65nv\x18\x01 \x01(\t\x12\x0b\n\x03\x61pp\x18\x02 \x01(\t\x12\x11\n\tnamespace\x18\x03 \x01(\t\x12\x16\n\x0estart_revision\x18\x04 \x01(\x03\x12\n\n\x02ip\x18\x05 \x01(\t\x12\x10\n\x08instance\x18\x06 \x01(\t\"\xaa\x01\n\x0b\x43onfigEvent\x12\x34\n\x04type\x18\x01 \x01(\x0e\x32&.configcenter.v1.ConfigEvent.EventType\x12*\n\x05items\x18\x02 \x03(\x0b\x32\x1b.configcenter.v1.ConfigItem\x12\x10\n\x08revision\x18\x03 \x01(\x03\"\'\n\tEventType\x12\r\n\tFULL_SYNC\x10\x00\x12\x0b\n\x07UPDATED\x10\x01\"i\n\x0ePublishRequest\x12\x0e\n\x06\x65nv_id\x18\x01 \x01(\x03\x12\x0e\n\x06\x61pp_id\x18\x02 \x01(\x03\x12\x14\n\x0cnamespace_id\x18\x03 \x01(\x03\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x10\n\x08operator\x18\x05 \x01(\t\"N\n\x0fPublishResponse\x12\x12\n\nrelease_id\x18\x01 \x01(\x03\x12\x17\n\x0frelease_version\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\"q\n\x0fRollbackRequest\x12\x0e\n\x06\x65nv_id\x18\x01 \x01(\x03\x12\x0e\n\x06\x61pp_id\x18\x02 \x01(\x03\x12\x14\n\x0cnamespace_id\x18\x03 \x01(\x03\x12\x16\n\x0etarget_version\x18\x04 \x01(\x03\x12\x10\n\x08operator\x18\x05 \x01(\t\"S\n\x10RollbackResponse\x12\x12\n\nrelease_id\x18\x01 \x01(\x03\x12\x1b\n\x13new_release_version\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t2\xb7\x01\n\rConfigService\x12R\n\tGetConfig\x12!.configcenter.v1.GetConfigRequest\x1a\".configcenter.v1.GetConfigResponse\x12R\n\x0bWatchConfig\x12#.configcenter.v1.WatchConfigRequest\x1a\x1c.configcenter.v1.ConfigEvent0\x01\x32\xb9\x01\n\x0c\x41\x64minService\x12R\n\rPublishConfig\x12\x1f.configcenter.v1.PublishRequest\x1a .configcenter.v1.PublishResponse\x12U\n\x0eRollbackConfig\x12 .configcenter.v1.RollbackRequest\x1a!.configcenter.v1.RollbackResponseB>Z<github.com/longpeng/configcenter/pkg/proto/v1;configcenterv1b\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'configcenter.v1.config_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'Z<github.com/longpeng/configcenter/pkg/proto/v1;configcenterv1'
_globals['_CONFIGITEM']._serialized_start=49
_globals['_CONFIGITEM']._serialized_end=89
_globals['_GETCONFIGREQUEST']._serialized_start=91
_globals['_GETCONFIGREQUEST']._serialized_end=184
_globals['_GETCONFIGRESPONSE']._serialized_start=186
_globals['_GETCONFIGRESPONSE']._serialized_end=292
_globals['_WATCHCONFIGREQUEST']._serialized_start=294
_globals['_WATCHCONFIGREQUEST']._serialized_end=413
_globals['_CONFIGEVENT']._serialized_start=416
_globals['_CONFIGEVENT']._serialized_end=586
_globals['_CONFIGEVENT_EVENTTYPE']._serialized_start=547
_globals['_CONFIGEVENT_EVENTTYPE']._serialized_end=586
_globals['_PUBLISHREQUEST']._serialized_start=588
_globals['_PUBLISHREQUEST']._serialized_end=693
_globals['_PUBLISHRESPONSE']._serialized_start=695
_globals['_PUBLISHRESPONSE']._serialized_end=773
_globals['_ROLLBACKREQUEST']._serialized_start=775
_globals['_ROLLBACKREQUEST']._serialized_end=888
_globals['_ROLLBACKRESPONSE']._serialized_start=890
_globals['_ROLLBACKRESPONSE']._serialized_end=973
_globals['_CONFIGSERVICE']._serialized_start=976
_globals['_CONFIGSERVICE']._serialized_end=1159
_globals['_ADMINSERVICE']._serialized_start=1162
_globals['_ADMINSERVICE']._serialized_end=1347
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,255 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from configcenter.v1 import config_pb2 as configcenter_dot_v1_dot_config__pb2
GRPC_GENERATED_VERSION = '1.71.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ f' but the generated code in configcenter/v1/config_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class ConfigServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.GetConfig = channel.unary_unary(
'/configcenter.v1.ConfigService/GetConfig',
request_serializer=configcenter_dot_v1_dot_config__pb2.GetConfigRequest.SerializeToString,
response_deserializer=configcenter_dot_v1_dot_config__pb2.GetConfigResponse.FromString,
_registered_method=True)
self.WatchConfig = channel.unary_stream(
'/configcenter.v1.ConfigService/WatchConfig',
request_serializer=configcenter_dot_v1_dot_config__pb2.WatchConfigRequest.SerializeToString,
response_deserializer=configcenter_dot_v1_dot_config__pb2.ConfigEvent.FromString,
_registered_method=True)
class ConfigServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def GetConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def WatchConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ConfigServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'GetConfig': grpc.unary_unary_rpc_method_handler(
servicer.GetConfig,
request_deserializer=configcenter_dot_v1_dot_config__pb2.GetConfigRequest.FromString,
response_serializer=configcenter_dot_v1_dot_config__pb2.GetConfigResponse.SerializeToString,
),
'WatchConfig': grpc.unary_stream_rpc_method_handler(
servicer.WatchConfig,
request_deserializer=configcenter_dot_v1_dot_config__pb2.WatchConfigRequest.FromString,
response_serializer=configcenter_dot_v1_dot_config__pb2.ConfigEvent.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'configcenter.v1.ConfigService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('configcenter.v1.ConfigService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class ConfigService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def GetConfig(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/configcenter.v1.ConfigService/GetConfig',
configcenter_dot_v1_dot_config__pb2.GetConfigRequest.SerializeToString,
configcenter_dot_v1_dot_config__pb2.GetConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def WatchConfig(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/configcenter.v1.ConfigService/WatchConfig',
configcenter_dot_v1_dot_config__pb2.WatchConfigRequest.SerializeToString,
configcenter_dot_v1_dot_config__pb2.ConfigEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class AdminServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.PublishConfig = channel.unary_unary(
'/configcenter.v1.AdminService/PublishConfig',
request_serializer=configcenter_dot_v1_dot_config__pb2.PublishRequest.SerializeToString,
response_deserializer=configcenter_dot_v1_dot_config__pb2.PublishResponse.FromString,
_registered_method=True)
self.RollbackConfig = channel.unary_unary(
'/configcenter.v1.AdminService/RollbackConfig',
request_serializer=configcenter_dot_v1_dot_config__pb2.RollbackRequest.SerializeToString,
response_deserializer=configcenter_dot_v1_dot_config__pb2.RollbackResponse.FromString,
_registered_method=True)
class AdminServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def PublishConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def RollbackConfig(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AdminServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'PublishConfig': grpc.unary_unary_rpc_method_handler(
servicer.PublishConfig,
request_deserializer=configcenter_dot_v1_dot_config__pb2.PublishRequest.FromString,
response_serializer=configcenter_dot_v1_dot_config__pb2.PublishResponse.SerializeToString,
),
'RollbackConfig': grpc.unary_unary_rpc_method_handler(
servicer.RollbackConfig,
request_deserializer=configcenter_dot_v1_dot_config__pb2.RollbackRequest.FromString,
response_serializer=configcenter_dot_v1_dot_config__pb2.RollbackResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'configcenter.v1.AdminService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('configcenter.v1.AdminService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class AdminService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def PublishConfig(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/configcenter.v1.AdminService/PublishConfig',
configcenter_dot_v1_dot_config__pb2.PublishRequest.SerializeToString,
configcenter_dot_v1_dot_config__pb2.PublishResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def RollbackConfig(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/configcenter.v1.AdminService/RollbackConfig',
configcenter_dot_v1_dot_config__pb2.RollbackRequest.SerializeToString,
configcenter_dot_v1_dot_config__pb2.RollbackResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

View File

@@ -8,6 +8,12 @@ version = "0.1.0"
description = "Config Center Python client with SSE watch and local cache fallback" description = "Config Center Python client with SSE watch and local cache fallback"
requires-python = ">=3.10" requires-python = ">=3.10"
[project.optional-dependencies]
grpc = [
"grpcio>=1.71,<2",
"protobuf>=5.29,<7",
]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["configcenter*"] include = ["configcenter*"]