Compare commits
23 Commits
63bc56be27
...
61dd03d207
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61dd03d207 | ||
|
|
6bfef0fcf5 | ||
|
|
48682f0bfc | ||
|
|
2be4eb23e3 | ||
|
|
dacf48ea29 | ||
|
|
b86a729792 | ||
|
|
02097596fa | ||
|
|
2341296271 | ||
|
|
c5311aa9c0 | ||
|
|
2dbfe2f742 | ||
|
|
35a201cab1 | ||
|
|
125740f58d | ||
|
|
f46d79511f | ||
|
|
80c6ba1ea8 | ||
|
|
5b053f0e65 | ||
|
|
0108b9e813 | ||
|
|
7951c292d3 | ||
|
|
beaf4c9c38 | ||
|
|
363d217c35 | ||
|
|
912db2aa18 | ||
|
|
b53b9f1adc | ||
|
|
801712ff24 | ||
|
|
08800cb7a5 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@ -32,3 +32,19 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: go test -race -timeout 60s ./internal/...
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Redis activity contract
|
||||
shell: pwsh
|
||||
run: ./scripts/test-redis.ps1
|
||||
- name: PostgreSQL admin-state contract
|
||||
shell: pwsh
|
||||
run: ./scripts/test-postgres.ps1
|
||||
|
||||
169
api/openapi/validation_test.go
Normal file
169
api/openapi/validation_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
var httpOperationMethods = map[string]struct{}{
|
||||
"delete": {}, "get": {}, "head": {}, "options": {},
|
||||
"patch": {}, "post": {}, "put": {}, "trace": {},
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsHaveClosedContracts(t *testing.T) {
|
||||
for _, name := range []string{"proxy-pool.yaml", "admin.yaml"} {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := readOpenAPIRoot(t, name)
|
||||
validateLocalReferences(t, root)
|
||||
validateOperations(t, root)
|
||||
validateSecurityRequirements(t, root)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateLocalReferences(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
walkOpenAPI(root, func(path string, value any) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reference, exists := object["$ref"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
text, ok := reference.(string)
|
||||
if !ok || !strings.HasPrefix(text, "#/") {
|
||||
t.Errorf("%s has unsupported reference %v", path, reference)
|
||||
return
|
||||
}
|
||||
if _, ok := resolveLocalReference(root, text); !ok {
|
||||
t.Errorf("%s points to missing local reference %q", path, text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func validateOperations(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
paths, ok := root["paths"].(map[string]any)
|
||||
if !ok || len(paths) == 0 {
|
||||
t.Fatal("paths must be a non-empty object")
|
||||
}
|
||||
operationIDs := make(map[string]string)
|
||||
for path, rawItem := range paths {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("path %q has type %T, want object", path, rawItem)
|
||||
continue
|
||||
}
|
||||
for method, rawOperation := range item {
|
||||
if _, ok := httpOperationMethods[strings.ToLower(method)]; !ok {
|
||||
continue
|
||||
}
|
||||
operation, ok := rawOperation.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s %s has type %T, want object", method, path, rawOperation)
|
||||
continue
|
||||
}
|
||||
operationID, _ := operation["operationId"].(string)
|
||||
if strings.TrimSpace(operationID) == "" {
|
||||
t.Errorf("%s %s has no operationId", method, path)
|
||||
} else if previous, duplicate := operationIDs[operationID]; duplicate {
|
||||
t.Errorf("operationId %q is shared by %s and %s %s", operationID, previous, method, path)
|
||||
} else {
|
||||
operationIDs[operationID] = method + " " + path
|
||||
}
|
||||
responses, ok := operation["responses"].(map[string]any)
|
||||
if !ok || len(responses) == 0 {
|
||||
t.Errorf("%s %s has no responses", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateSecurityRequirements(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
components, _ := root["components"].(map[string]any)
|
||||
schemes, _ := components["securitySchemes"].(map[string]any)
|
||||
walkOpenAPI(root, func(path string, value any) {
|
||||
if !strings.HasSuffix(path, ".security") {
|
||||
return
|
||||
}
|
||||
requirements, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Errorf("%s has type %T, want array", path, value)
|
||||
return
|
||||
}
|
||||
for _, rawRequirement := range requirements {
|
||||
requirement, ok := rawRequirement.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s contains %T, want object", path, rawRequirement)
|
||||
continue
|
||||
}
|
||||
for name := range requirement {
|
||||
if _, exists := schemes[name]; !exists {
|
||||
t.Errorf("%s references unknown security scheme %q", path, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func readOpenAPIRoot(t *testing.T, name string) map[string]any {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var root map[string]any
|
||||
if err := yaml.Unmarshal(payload, &root); err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func walkOpenAPI(value any, visit func(string, any)) {
|
||||
var walk func(string, any)
|
||||
walk = func(path string, current any) {
|
||||
visit(path, current)
|
||||
switch item := current.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range item {
|
||||
walk(joinOpenAPIPath(path, key), child)
|
||||
}
|
||||
case []any:
|
||||
for index, child := range item {
|
||||
walk(fmt.Sprintf("%s[%d]", path, index), child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("$", value)
|
||||
}
|
||||
|
||||
func resolveLocalReference(root map[string]any, reference string) (any, bool) {
|
||||
var current any = root
|
||||
for _, token := range strings.Split(strings.TrimPrefix(reference, "#/"), "/") {
|
||||
token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[token]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func joinOpenAPIPath(parent, child string) string {
|
||||
if parent == "" {
|
||||
return child
|
||||
}
|
||||
return parent + "." + child
|
||||
}
|
||||
@ -8,10 +8,15 @@ Grafana 与 Kubernetes。配置已通过静态展开,但当前仓库的 `cmd/p
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/docker-compose.yml config --quiet
|
||||
docker compose -f deploy/docker-compose.test.yml config --quiet
|
||||
kubectl kustomize deploy/kubernetes/base | Out-Null
|
||||
go run ./deploy/tools/configcheck deploy/config/local.yaml
|
||||
```
|
||||
|
||||
`docker-compose.test.yml` 为 Redis 与 PostgreSQL 18 提供互相独立的本地集成测试
|
||||
服务。两者只绑定回环地址;Redis 禁止持久化,PostgreSQL 使用
|
||||
`/var/lib/postgresql` tmpfs。`test-redis.ps1` 通过专属 Compose 项目只启动 Redis;
|
||||
PostgreSQL Adapter 和对应执行脚本完成前,不把数据库契约记为通过。
|
||||
|
||||
运行时完成后,还必须通过 `production-readiness.md` 中的一致性、安全、恢复、
|
||||
竞态与容量门禁,才能构建镜像并发布。
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
@ -13,11 +15,67 @@ type composeDocument struct {
|
||||
}
|
||||
|
||||
type composeService struct {
|
||||
Image string `yaml:"image"`
|
||||
Command []string `yaml:"command"`
|
||||
Ports []string `yaml:"ports"`
|
||||
Volumes []string `yaml:"volumes"`
|
||||
Tmpfs []string `yaml:"tmpfs"`
|
||||
DependsOn any `yaml:"depends_on"`
|
||||
}
|
||||
|
||||
func TestPostgresIntegrationFixtureIsIsolatedAndEphemeral(t *testing.T) {
|
||||
document := loadComposeFile(t, "docker-compose.test.yml")
|
||||
postgres, ok := document.Services["postgres"]
|
||||
if !ok {
|
||||
t.Fatal("docker-compose.test.yml has no postgres service")
|
||||
}
|
||||
if postgres.Image != "postgres:18-alpine" {
|
||||
t.Fatalf("postgres image = %q, want postgres:18-alpine", postgres.Image)
|
||||
}
|
||||
if !slices.Equal(postgres.Ports, []string{"127.0.0.1:15432:5432"}) {
|
||||
t.Fatalf("postgres ports = %v, want loopback test port", postgres.Ports)
|
||||
}
|
||||
if !slices.Contains(postgres.Tmpfs, "/var/lib/postgresql") || len(postgres.Volumes) != 0 {
|
||||
t.Fatalf("postgres tmpfs = %v, volumes = %v; want ephemeral PG18 data root", postgres.Tmpfs, postgres.Volumes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisFixtureScriptUsesDedicatedComposeProject(t *testing.T) {
|
||||
payload, err := os.ReadFile("../scripts/test-redis.ps1")
|
||||
if err != nil {
|
||||
t.Fatalf("read test-redis.ps1: %v", err)
|
||||
}
|
||||
script := string(payload)
|
||||
for _, required := range []string{
|
||||
`-p $composeProject`,
|
||||
`up -d --wait --wait-timeout 60 redis`,
|
||||
`down --volumes --remove-orphans`,
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("test-redis.ps1 missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresFixtureScriptUsesDedicatedComposeProject(t *testing.T) {
|
||||
payload, err := os.ReadFile("../scripts/test-postgres.ps1")
|
||||
if err != nil {
|
||||
t.Fatalf("read test-postgres.ps1: %v", err)
|
||||
}
|
||||
script := string(payload)
|
||||
for _, required := range []string{
|
||||
`-p $composeProject`,
|
||||
`up -d --wait --wait-timeout 60 postgres`,
|
||||
`PROXY_POOL_TEST_POSTGRES_URL`,
|
||||
`go test -count=1 -tags=integration -timeout 60s ./internal/adapters/postgresadmin/...`,
|
||||
`down --volumes --remove-orphans`,
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("test-postgres.ps1 missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRedisIsExplicitlyEphemeral(t *testing.T) {
|
||||
document := loadComposeDocument(t)
|
||||
redis, ok := document.Services["redis"]
|
||||
@ -55,13 +113,18 @@ func TestLocalGatewaysDoNotDependOnControlPlaneStorage(t *testing.T) {
|
||||
|
||||
func loadComposeDocument(t *testing.T) composeDocument {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile("docker-compose.yml")
|
||||
return loadComposeFile(t, "docker-compose.yml")
|
||||
}
|
||||
|
||||
func loadComposeFile(t *testing.T, name string) composeDocument {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read docker-compose.yml: %v", err)
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var document composeDocument
|
||||
if err := yaml.Unmarshal(payload, &document); err != nil {
|
||||
t.Fatalf("parse docker-compose.yml: %v", err)
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
@ -1,6 +1,23 @@
|
||||
name: proxy-pool-test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: proxy_pool_test
|
||||
POSTGRES_USER: proxy_pool_test
|
||||
POSTGRES_PASSWORD: proxy-pool-test
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U proxy_pool_test -d proxy_pool_test"]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 30
|
||||
start_period: 1s
|
||||
|
||||
redis:
|
||||
image: redis:8.2-alpine
|
||||
command: ["redis-server", "--appendonly", "no", "--save", ""]
|
||||
|
||||
152
docs/adr/006-postgresql-admin-state.md
Normal file
152
docs/adr/006-postgresql-admin-state.md
Normal file
@ -0,0 +1,152 @@
|
||||
# ADR-006:PostgreSQL 管理面采用事务深模块
|
||||
|
||||
## 状态
|
||||
|
||||
接受,2026-07-29。
|
||||
|
||||
## 背景
|
||||
|
||||
Admin HTTP Handler 已定义 Upstream 启停、Routing 手工切换、配置重载和状态查询,
|
||||
但权威管理状态、审计与 Outbox 还没有生产存储实现。PostgreSQL 只属于控制面;
|
||||
Proxy 地址、凭据、生命周期、Worker 所有权、逐次提取记录和短期幂等结果都不能
|
||||
进入 PostgreSQL。
|
||||
|
||||
管理写入存在一个不可拆分的不变量:业务状态、Admin 审计和 Outbox 必须在同一
|
||||
PostgreSQL 事务内提交。若把它们暴露成多个 Repository,调用方容易漏写审计、
|
||||
漏发事件或在失败时留下部分状态。
|
||||
|
||||
## 决策
|
||||
|
||||
### 模块与 seam
|
||||
|
||||
新增 `domain/adminstate` 公用契约。调用方按用途依赖窄接口,一个 Adapter 可以
|
||||
同时实现全部接口:
|
||||
|
||||
```go
|
||||
type Mutator interface {
|
||||
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
|
||||
SwitchRouting(context.Context, SwitchRoutingCommand) (MutationResult, error)
|
||||
CommitConfig(context.Context, CommitConfigCommand) (MutationResult, error)
|
||||
}
|
||||
|
||||
type SnapshotReader interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
type AuditReader interface {
|
||||
ReadAudit(context.Context, AuditQuery) ([]AuditRecord, error)
|
||||
}
|
||||
|
||||
type Outbox interface {
|
||||
Claim(context.Context, ClaimCommand) ([]Event, error)
|
||||
Acknowledge(context.Context, AcknowledgeCommand) error
|
||||
}
|
||||
```
|
||||
|
||||
三个 mutation 方法保留现有 Admin 语义,事务、修订号、CAS、审计和 Outbox 都
|
||||
隐藏在模块实现内部。不会暴露 `BeginTx`、SQL executor 或五个可被错误组合的浅
|
||||
Repository。
|
||||
|
||||
`MemoryStore` 是并发安全参考 Adapter;PostgreSQL Adapter 必须运行同一套公用
|
||||
行为契约。Controller Admin 应用层只负责 DTO 映射、配置解析/校验和运行态发布,
|
||||
不自行拼装数据库事务。
|
||||
|
||||
所有命令和查询在领域类型上提供公用 `Validate()`;Memory 与 PostgreSQL
|
||||
Adapter 必须在访问存储前调用同一方法,不复制名称、引用、分页或租约校验。
|
||||
|
||||
### 全局修订
|
||||
|
||||
每次产生管理状态变化时分配单调递增的全局 `revision`:
|
||||
|
||||
- Upstream 目标状态已满足时返回 `changed=false`,不增加修订。
|
||||
- Routing 的 `expectedCurrent` 不匹配时返回冲突,不写任何记录。
|
||||
- 相同配置版本和校验和再次提交时返回 `changed=false`。
|
||||
- 相同配置版本对应不同校验和时返回冲突。
|
||||
- `MutationResult.version` 是已提交的全局管理修订,不是 YAML 格式版本或 Worker
|
||||
Snapshot 版本。
|
||||
|
||||
每次合法 Admin mutation 都写审计,包括 `changed=false`;只有真实状态变化写
|
||||
Outbox。状态变化、审计和 Outbox 在同一事务提交。
|
||||
|
||||
### 配置修订
|
||||
|
||||
配置提交只保存管理面恢复所需的非敏感事实:配置版本、SHA-256 校验和、来源、
|
||||
Upstream 启用状态和 Routing 候选/当前选择。已解析 Secret、Provider Token、
|
||||
Proxy 凭据和完整运行时对象不进入 PostgreSQL。
|
||||
|
||||
配置重载以一个事务替换管理快照。新 Routing 的当前 Upstream 必须属于其候选集,
|
||||
所有引用的 Upstream 必须存在,名称与列表必须非空且唯一。校验失败发生在事务前,
|
||||
旧修订继续生效。
|
||||
|
||||
### Routing CAS
|
||||
|
||||
`SwitchRouting` 在单条事务中锁定目标 Routing,并同时校验:
|
||||
|
||||
1. Routing 存在且启用;
|
||||
2. `expectedCurrent` 等于权威当前值;
|
||||
3. `target` 属于该 Routing 的候选 Upstream;
|
||||
4. 目标与当前值不同。
|
||||
|
||||
并发使用同一 expected 值时最多一个请求成功。目标等于当前值且 expected 匹配时
|
||||
返回 `changed=false`,仍写审计但不写 Outbox。
|
||||
|
||||
### Outbox 消费
|
||||
|
||||
Outbox 使用有界 claim/ack,而不是无界全表扫描:
|
||||
|
||||
- `Claim` 要求稳定 consumer ID、当前时间、正租期和有界 limit。
|
||||
- 未发布且未被有效租约占用的事件按序 claim。
|
||||
- `Acknowledge` 只允许当前 consumer 在租期内确认自己 claim 的事件。
|
||||
- 发布失败不 ACK,租期过后可由其他 consumer 重试。
|
||||
|
||||
事件 payload 只包含管理资源名、目标状态、修订和必要原因,不包含 Secret、
|
||||
Proxy、Client 或完整配置正文。
|
||||
|
||||
### Schema 边界
|
||||
|
||||
首个迁移只创建:
|
||||
|
||||
```text
|
||||
control_revisions
|
||||
config_revisions
|
||||
upstream_admin_state
|
||||
routing_admin_state
|
||||
admin_audit_log
|
||||
admin_outbox
|
||||
```
|
||||
|
||||
迁移和集成测试必须断言不存在 Proxy 明细、Worker ownership、提取记录或短期
|
||||
幂等表。PostgreSQL 故障只使管理写入失败关闭,不改变 Redis Extract 的可用性。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
MemoryStore 与 PostgreSQL Adapter 运行相同契约,至少覆盖:
|
||||
|
||||
- 配置首次提交、相同重放、校验和冲突和非法引用零写入。
|
||||
- Upstream enable/disable 幂等、修订单调、审计必写、Outbox 仅在变更时写。
|
||||
- Routing CAS、目标校验和 100 个并发请求最多一个成功。
|
||||
- 任一审计/Outbox 写故障导致状态完全回滚。
|
||||
- Outbox 有界 claim、租约到期重试、错误 consumer ACK 拒绝和顺序稳定。
|
||||
- 审计按 ID 稳定分页并保留 Actor、资源、动作、修订和 UTC 时间;Routing no-op
|
||||
写审计但不写 Outbox。
|
||||
- 批量 ACK 先完整校验所有事件再提交,任一未知或冲突 ID 不得部分发布。
|
||||
- 上下文取消、错误脱敏和 Snapshot 防止调用方修改内部状态。
|
||||
- 真实 PostgreSQL 重复迁移、事务回滚和禁止数据表边界。
|
||||
|
||||
所有测试命令最长 60 秒。100,000 QPS 属于 Gateway 集群目标,不以管理面数据库
|
||||
测试推导吞吐结论。
|
||||
|
||||
## 后果
|
||||
|
||||
收益:调用方无法绕过事务不变量;Memory/PostgreSQL 行为一致;管理数据边界可
|
||||
通过 Schema 自动验证;Outbox 重试有界且可观测。
|
||||
|
||||
代价:PostgreSQL Adapter 内部实现较深;配置提交需要完整管理快照;Outbox
|
||||
dispatcher 需要租约续期或确保单批发布时间小于 claim TTL。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 五个公开 Repository 加公开事务管理器:接口浅,调用方容易产生部分提交。
|
||||
- 将所有命令塞入一个弱类型 `Command`:入口最少,但 Go 调用方需要运行时判断
|
||||
联合字段,错误更晚暴露。
|
||||
- 把 Proxy 或提取事实写入 PostgreSQL:违反短效活动池与数据最小化边界。
|
||||
@ -38,7 +38,7 @@ PostgreSQL 只持久化配置版本、Upstream/Routing 管理状态、Admin 审
|
||||
|
||||
## ADR-005:Redis 活动池采用单实例原子深模块
|
||||
|
||||
**状态:** 接受。
|
||||
**状态:** 接受并已实现。
|
||||
|
||||
首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现
|
||||
Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和
|
||||
@ -47,3 +47,15 @@ Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、
|
||||
|
||||
完整决策、键空间、原子操作和测试门禁见
|
||||
[ADR-005](005-redis-activity-pool.md)。
|
||||
|
||||
## ADR-006:PostgreSQL 管理面采用事务深模块
|
||||
|
||||
**状态:** 接受。
|
||||
|
||||
Upstream/Routing/配置修订、Admin 审计和 Outbox 由一个深 Adapter 在同一事务
|
||||
提交。调用方只依赖 mutation、snapshot 和有界 outbox 接口,不接触数据库事务
|
||||
或细粒度 Repository。PostgreSQL Schema 明确排除 Proxy、Worker ownership、
|
||||
逐次提取记录和短期幂等结果。
|
||||
|
||||
完整接口、事务不变量、Schema 与公用契约见
|
||||
[ADR-006](006-postgresql-admin-state.md)。
|
||||
|
||||
@ -25,12 +25,28 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
|
||||
不能混用配置格式版本或单 Worker Snapshot 版本。
|
||||
|
||||
严格 JSON、请求体上限、Request ID、JSON/Problem 响应由
|
||||
`platform/httpapi` 公用实现提供。Admin Handler 必须注入 `Authorizer`,标准
|
||||
装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。网关使用的
|
||||
`platform/httpapi` 公用实现提供。Admin Handler 必须注入 `IdentityResolver`,
|
||||
标准装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。解析出的
|
||||
Actor ID 与可信 SourceIP 会进入所有 mutation 命令,但认证材料不会进入审计。
|
||||
网关使用的
|
||||
`Proxy-Authorization`/407 语义不得复用到 Admin 的 `Authorization`/401 语义。
|
||||
`controller/runtime` 将 Admin 与 Distribution 放在不同 `net.Listener`,任一
|
||||
监听器异常会触发同组端点的有界优雅停机。
|
||||
|
||||
`admin.ApplicationService` 把 Handler DTO 映射到 `adminstate` 公用事务命令。
|
||||
Status 以一个权威管理快照决定 Upstream 集合和 Enabled 状态,只从注入的运行态
|
||||
读取器补充低基数计数、Worker 与已发布 Snapshot 版本。配置重载顺序固定为:
|
||||
|
||||
1. `FileConfigurationLoader` 通过 `config.LoadResolved` 严格解析、解析 Secret 引用
|
||||
并完成全量校验。
|
||||
2. 从脱敏管理投影计算版本与校验和;Secret 值及其可验证摘要不进入管理状态。
|
||||
3. 在同一 `adminstate` mutation 中提交配置修订、管理状态、审计和 Outbox。
|
||||
4. 提交成功后由 `config.Store` 一次原子指针交换发布完整运行配置;提交失败时旧
|
||||
配置保持不变。幂等重放仍执行发布,以修复进程本地状态。
|
||||
|
||||
主配置或 Secret 文件 I/O 故障归类为 503;语法、未知字段、引用和语义校验失败
|
||||
归类为 422。请求取消和截止时间保持原始上下文错误,不误报为配置错误。
|
||||
|
||||
除契约中的 401/403/404/409/422 外,运行时还明确返回:
|
||||
|
||||
- `400`:Request ID 或 JSON 无效。
|
||||
|
||||
@ -3,12 +3,16 @@
|
||||
## 1. 加载规则
|
||||
|
||||
主配置格式为 YAML,根字段 `version` 当前固定为 `1`。加载器启用严格字段
|
||||
检查,拼写错误或未来版本字段不会被静默忽略。推荐启动命令显式传入配置路径:
|
||||
检查,拼写错误或未来版本字段不会被静默忽略。当前仓库可使用与未来进程启动
|
||||
相同的严格加载器校验本地部署配置:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/proxy-controller -config configs/proxy-pool.yaml
|
||||
go run ./deploy/tools/configcheck deploy/config/local.yaml
|
||||
```
|
||||
|
||||
规划中的生产入口为 `proxy-controller -config CONFIG_FILE`;该命令完成实现和
|
||||
进程级测试前,不作为当前可执行能力。
|
||||
|
||||
所有时间值使用 Go duration,例如 `500ms`、`30s`、`5m`。示例中的
|
||||
`${TOKEN}`、`${PASSWORD}`、`${POSTGRES_URL}` 等由加载器从同名环境变量
|
||||
展开;这些值不得写入日志、指标、配置转储或错误响应。生产配置优先使用
|
||||
@ -188,7 +192,7 @@ routing:
|
||||
strategy:
|
||||
type: sequential
|
||||
switchAfterEmptyFetch: 5
|
||||
endBehavior: stayLast
|
||||
endBehavior: stop
|
||||
onUnavailable:
|
||||
action: reject
|
||||
waitTimeout: 0s
|
||||
@ -199,7 +203,9 @@ routing:
|
||||
- `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、
|
||||
`leastConnections`。
|
||||
- `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。
|
||||
- `sequential` 必须设置大于零的 `switchAfterEmptyFetch`。
|
||||
- `sequential` 至少引用两个 Upstream,并设置大于零的
|
||||
`switchAfterEmptyFetch`;`endBehavior` 省略时默认为 `stop`,也可显式设置
|
||||
`loop` 或 `stayLast`。
|
||||
- `onUnavailable.action` 为 `reject`、`wait` 或 `direct`;默认建议 `reject`。
|
||||
|
||||
Sequential 的空计数属于 Upstream,当前索引属于 Routing。只有 Provider 响应
|
||||
@ -346,7 +352,7 @@ PostgreSQL 故障本身不应使 Redis 中可完成的 Extract 返回 `503`。Me
|
||||
2. 所有启用监听器具有合法 `host:port`。
|
||||
3. 非回环监听器满足认证或来源 CIDR 保护。
|
||||
4. Routing 名称唯一,正则可编译,引用的 Upstream 存在。
|
||||
5. Sequential 阈值大于零,`onUnavailable.action` 明确。
|
||||
5. Sequential 至少引用两个 Upstream、阈值大于零,`onUnavailable.action` 明确。
|
||||
6. 启用的 Upstream 有正数 `pool.maxSize`、并发和 Fetch 限制。
|
||||
7. `allocationSafetyMargin < ttl`。
|
||||
8. `fetch.maxTotal == 0` 或 `fetch.maxTotal >= pool.maxSize`。
|
||||
|
||||
@ -272,6 +272,9 @@ sequenceDiagram
|
||||
|
||||
Upstream 的 Empty 事实全局共享;每条 Routing 独立 CAS 当前索引。多个并发
|
||||
协程只能有一个成功从 A 切到 B,其他协程读取新版本,不会再切到 C。
|
||||
Sequential 至少配置两个 Upstream;列表耗尽后的默认行为是 `stop`,`loop` 和
|
||||
`stayLast` 必须显式配置。disabled Upstream 不参与新分配,其运行时跳过与权威
|
||||
游标持久化仍由后续 Routing Runtime 完成。
|
||||
|
||||
## 10. Exclusive Extraction
|
||||
|
||||
|
||||
@ -58,6 +58,9 @@ Gateway Worker 只读取本地不可变快照并维护本地容量计数。供
|
||||
|
||||
### 4.3 Sequential 切换
|
||||
|
||||
Sequential 至少配置两个 Upstream,初始使用列表第一项。列表耗尽时默认 `stop`,
|
||||
也可显式选择 `loop` 或 `stayLast`。
|
||||
|
||||
1. Provider 响应成功且解析成功,但合法候选为零,才累计 Empty。
|
||||
2. 网络、认证、HTTP、模板或解析失败只计 Error。
|
||||
3. 全部候选均重复时计 DuplicateOnly,并重置连续 Empty。
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
- PostgreSQL 与 Redis 仅用于适配器集成测试,领域单测不依赖外部服务。
|
||||
- 运行 `go test -race` 需要 CGO 和 C 编译器。
|
||||
- Docker Compose 用于本地完整拓扑,Docker 不应成为普通单测前置条件。
|
||||
- Protobuf descriptor 验证需要 `protoc`;非标准安装可通过 `PROTOC_INCLUDE`
|
||||
指定 Google well-known types 的 include 目录。
|
||||
|
||||
## 2. 开发循环
|
||||
|
||||
@ -59,6 +61,7 @@ go build ./...
|
||||
|
||||
```powershell
|
||||
./scripts/verify.ps1
|
||||
./scripts/verify-proto.ps1
|
||||
```
|
||||
|
||||
审查还要确认:无 Secret 日志、无 Proxy IP 高基数标签、无默认 direct、无
|
||||
|
||||
@ -56,9 +56,9 @@ test/{fixtures,integration,e2e,load}/
|
||||
|
||||
- [ ] Create module `github.com/proxy-pool/proxy-pool` with Go 1.26.
|
||||
- [ ] Pin YAML v4, pgx/v5, go-redis/v9, gRPC, protobuf, Prometheus, and x/sync.
|
||||
- [ ] Add `scripts/verify.ps1` that runs format check, `go vet`, unit tests, race tests,
|
||||
- [x] Add `scripts/verify.ps1` that runs format check, `go vet`, unit tests, race tests,
|
||||
and builds all commands, each test command bounded to 60 seconds.
|
||||
- [ ] Add CI for Windows and Linux with unit/race/build jobs.
|
||||
- [x] Add CI for Windows and Linux with unit/race/build jobs.
|
||||
- [ ] Verify `go mod tidy`, `go test ./...`, and `go build ./cmd/...` succeed.
|
||||
|
||||
## Task 2: Strict Configuration
|
||||
@ -84,6 +84,10 @@ test/{fixtures,integration,e2e,load}/
|
||||
- [x] Prove with 1,000 concurrent goroutines that effective capacity is never exceeded.
|
||||
- [ ] Add race coverage and duplicate-release invariant metrics hook.
|
||||
|
||||
当前进度(2026-07-29):固定 Max 下的每 Proxy 打包 CAS、Cancel/Commit/Release
|
||||
生命周期、重复终结、错误顺序和同一 Reservation 并发终结已通过领域测试;动态
|
||||
降容契约、低基数不变量指标、Linux race 证据及短 TTL runtime 排空回收待完成。
|
||||
|
||||
## Task 4: Routing and Sequential Switching
|
||||
|
||||
**Files:** `internal/domain/routing/*.go`, corresponding tests
|
||||
@ -95,6 +99,10 @@ test/{fixtures,integration,e2e,load}/
|
||||
- [ ] Cover four-empty-then-success, five-empty, A-to-B-only, disabled references,
|
||||
end behavior, and explicit onUnavailable.
|
||||
|
||||
当前进度(2026-07-29):领域构造器与严格配置已统一 Sequential 至少两个
|
||||
Upstream、`endBehavior` 默认 `stop`,并覆盖列表末端停止;disabled candidate、
|
||||
跨实例恢复和 `onUnavailable` 运行链仍待完成。
|
||||
|
||||
## Task 5: Provider Fetch Classification and Scheduling
|
||||
|
||||
**Files:** `internal/controller/provider/*.go`, `internal/domain/upstream/*.go`, tests
|
||||
@ -158,9 +166,10 @@ test/{fixtures,integration,e2e,load}/
|
||||
**Files:** `internal/controller/distribution/*.go`, `admin/*.go`,
|
||||
`internal/adapters/postgres/*.go`, `internal/adapters/redis/*.go`, migrations, tests
|
||||
|
||||
- [ ] Define PostgreSQL ports for ConfigVersion, Upstream/Routing management state,
|
||||
AdminAudit, Outbox, and optional aggregate metrics; never persist Proxy details or
|
||||
per-extraction records.
|
||||
- [x] Define the PostgreSQL management seam for ConfigVersion, Upstream/Routing state,
|
||||
AdminAudit and leased Outbox; the public contract has no Proxy or extraction detail types.
|
||||
- [x] Add the six-table PostgreSQL management migration with static data-boundary checks.
|
||||
- [x] Implement the pgx PostgreSQL Adapter and run the public contract against PostgreSQL 18.
|
||||
- [x] Implement the Redis TTL activity pool and one atomic extraction operation covering
|
||||
candidate eligibility, Gateway reserve, ownership, removal, and short-lived idempotency.
|
||||
- [x] Implement Redis Worker ownership, drain/ACK, expiry reclaim, inventory and bounded
|
||||
@ -172,17 +181,22 @@ test/{fixtures,integration,e2e,load}/
|
||||
- [x] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload
|
||||
HTTP handlers and contracts.
|
||||
- [x] Add Compose-backed Redis 8.2 integration and shared Adapter contract tests.
|
||||
- [ ] Add PostgreSQL management Adapter and Compose-backed integration tests.
|
||||
- [x] Add PostgreSQL management Adapter and Compose-backed integration tests.
|
||||
|
||||
当前进度(2026-07-29):已实现共享 `platform/httpapi`、Distribution
|
||||
当前进度(2026-07-30):已实现共享 `platform/httpapi`、Distribution
|
||||
extract/live/ready Handler 与 Admin status/enable/disable/switch/reload Handler;
|
||||
定向契约测试已覆盖严格 JSON、Body 上限、Request ID、幂等 Header、DTO 映射、
|
||||
404/405 及业务错误映射。共享 `platform/httpsecurity` 已补齐 Basic/API Key/
|
||||
Bearer/CIDR、可信代理、Client ID、本地准入和 API 401/Gateway 407 差异,并作为
|
||||
Admin/Distribution 必需依赖。共享 `platform/httpserver` 与
|
||||
`controller/runtime` 已完成 Distribution/Admin 独立监听器、首错联动关闭和
|
||||
有界优雅停机。生产命令入口、PostgreSQL 管理面 Adapter 及其 Compose 集成测试
|
||||
仍待实现。
|
||||
有界优雅停机。Admin mutation 已携带认证 Actor/SourceIP;公用 `adminstate`
|
||||
事务契约、MemoryStore、100 并发 Routing CAS、租约 Outbox 和六表管理 Schema
|
||||
已完成。pgx Adapter 已在真实 PostgreSQL 18 上运行同一公用契约,并验证
|
||||
Repeatable Read 快照、`SKIP LOCKED`、原子 ACK、审计/Outbox 故障回滚和数据边界。
|
||||
Admin `ApplicationService` 已将 mutation、权威管理快照、低基数运行态
|
||||
聚合与配置重载接到同一公用 seam;严格文件加载、脱敏管理摘要及原子配置发布
|
||||
已通过失败路径和并发测试。生产命令入口及其连接池/迁移启动装配仍待实现。
|
||||
|
||||
已新增公用 `domain/activitypool` 契约及并发安全内存参考实现,Provider
|
||||
Reconciler 通过 `UpsertFetched` 写入带供应商 TTL 和分配安全余量的批次;已覆盖
|
||||
@ -205,12 +219,18 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
|
||||
**Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`,
|
||||
`docs/api/*.md`
|
||||
|
||||
- [ ] Specify Distribution/Admin REST schemas, status codes, authentication, examples,
|
||||
- [x] Specify Distribution/Admin REST schemas, status codes, authentication, examples,
|
||||
and idempotency behavior.
|
||||
- [ ] Specify Worker register, snapshot, delta, ACK, report, heartbeat, ownership drain,
|
||||
- [x] Specify Worker register, snapshot, delta, ACK, report, heartbeat, ownership drain,
|
||||
and resync messages.
|
||||
- [ ] Validate OpenAPI and compile protobuf descriptors in CI.
|
||||
|
||||
当前进度(2026-07-29):Distribution/Admin OpenAPI 已由 Go 测试在双平台 CI
|
||||
校验本地 `$ref` 闭合、operationId 唯一、响应存在及 security scheme 引用;
|
||||
`scripts/verify-proto.ps1` 已可复现编译包含 imports/source info 的 descriptor,
|
||||
并在本地存在 `protoc` 时进入完整验证;完整 OAS 工具验证与 CI 强制安装/执行
|
||||
`protoc` 仍待完成。
|
||||
|
||||
## Task 13: Deployment and Observability
|
||||
|
||||
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`
|
||||
@ -227,10 +247,10 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
|
||||
|
||||
**Files:** `docs/**`, `examples/**`, `diagrams/**`
|
||||
|
||||
- [ ] Complete README navigation, design document, developer guide, configuration
|
||||
- [x] Complete README navigation, design document, developer guide, configuration
|
||||
reference, API guide, deployment guide, security model, testing guide, and roadmap.
|
||||
- [ ] Provide at least 20 validated configuration examples.
|
||||
- [ ] Provide at least 30 Mermaid architecture, flow, sequence, state, and failure diagrams.
|
||||
- [x] Provide at least 20 validated configuration examples.
|
||||
- [x] Provide at least 30 Mermaid architecture, flow, sequence, state, and failure diagrams.
|
||||
- [ ] Generate `proxy-pool-docs-v1.0.zip` from versioned documentation assets.
|
||||
|
||||
## Task 15: Completion Audit
|
||||
|
||||
135
docs/docs_test.go
Normal file
135
docs/docs_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package docs_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`)
|
||||
goCommandPattern = regexp.MustCompile(`\bgo\s+(?:run|build)\s+(\./[^\s` + "`" + `]+)`)
|
||||
)
|
||||
|
||||
func TestMarkdownRelativeLinksResolve(t *testing.T) {
|
||||
repositoryRoot := repositoryRoot(t)
|
||||
for _, document := range markdownDocuments(t, repositoryRoot) {
|
||||
document := document
|
||||
t.Run(relativeTestName(repositoryRoot, document), func(t *testing.T) {
|
||||
content := readDocument(t, document)
|
||||
for _, match := range markdownLinkPattern.FindAllStringSubmatch(content, -1) {
|
||||
target := strings.TrimSpace(match[1])
|
||||
if target == "" || strings.HasPrefix(target, "#") || hasExternalScheme(target) {
|
||||
continue
|
||||
}
|
||||
if title := strings.Index(target, ` "`); title >= 0 {
|
||||
target = target[:title]
|
||||
}
|
||||
target = strings.Trim(target, "<>")
|
||||
if fragment := strings.IndexByte(target, '#'); fragment >= 0 {
|
||||
target = target[:fragment]
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
unescaped, err := url.PathUnescape(target)
|
||||
if err != nil {
|
||||
t.Errorf("link %q is not path-encoded correctly: %v", match[1], err)
|
||||
continue
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(document), filepath.FromSlash(unescaped)))
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
t.Errorf("link %q resolves to missing path %s: %v", match[1], resolved, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedGoCommandsReferenceExistingTargets(t *testing.T) {
|
||||
repositoryRoot := repositoryRoot(t)
|
||||
documents := []string{
|
||||
"README.md",
|
||||
"deploy/README.md",
|
||||
"docs/configuration/reference.md",
|
||||
"docs/development/guide.md",
|
||||
"docs/testing/strategy.md",
|
||||
"docs/testing/test-strategy.md",
|
||||
}
|
||||
for _, relative := range documents {
|
||||
document := filepath.Join(repositoryRoot, filepath.FromSlash(relative))
|
||||
content := readDocument(t, document)
|
||||
for _, match := range goCommandPattern.FindAllStringSubmatch(content, -1) {
|
||||
target := strings.TrimRight(match[1], ",.;:")
|
||||
if strings.Contains(target, "...") {
|
||||
continue
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(repositoryRoot, filepath.FromSlash(strings.TrimPrefix(target, "./"))))
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
t.Errorf("%s documents missing Go target %q: %v", relative, target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markdownDocuments(t *testing.T, repositoryRoot string) []string {
|
||||
t.Helper()
|
||||
documents := []string{filepath.Join(repositoryRoot, "README.md")}
|
||||
for _, directory := range []string{"docs", "deploy", "diagrams"} {
|
||||
root := filepath.Join(repositoryRoot, directory)
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
|
||||
documents = append(documents, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", directory, err)
|
||||
}
|
||||
}
|
||||
slices.Sort(documents)
|
||||
return documents
|
||||
}
|
||||
|
||||
func repositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
workingDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working directory: %v", err)
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(workingDirectory, ".."))
|
||||
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
|
||||
t.Fatalf("locate repository root from %s: %v", workingDirectory, err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func readDocument(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func hasExternalScheme(target string) bool {
|
||||
parsed, err := url.Parse(target)
|
||||
return err == nil && parsed.Scheme != ""
|
||||
}
|
||||
|
||||
func relativeTestName(repositoryRoot, document string) string {
|
||||
relative, err := filepath.Rel(repositoryRoot, document)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("document-%x", document)
|
||||
}
|
||||
return filepath.ToSlash(relative)
|
||||
}
|
||||
@ -54,6 +54,12 @@ kubectl kustomize deploy/kubernetes/base > rendered.yaml
|
||||
不执行 `FLUSHDB`。生产环境的 Redis 高可用与持久化策略必须独立评审,不能照搬
|
||||
本地 fixture。
|
||||
|
||||
PostgreSQL 管理面集成测试通过 `.\scripts\test-postgres.ps1` 启动
|
||||
`postgres:18-alpine`,仅绑定 `127.0.0.1:15432`,并将 PG18 数据根目录挂载为
|
||||
tmpfs。`ApplyMigrations` 在同一物理连接上执行仓库内嵌的幂等前向迁移;测试为
|
||||
每个契约创建唯一 Schema,结束时只删除该 Schema 和临时 Compose 项目。该脚本
|
||||
禁止指向开发或生产数据库。
|
||||
|
||||
目标拓扑入口:
|
||||
|
||||
- Gateway:`127.0.0.1:8080`
|
||||
@ -112,6 +118,9 @@ kubectl -n proxy-pool rollout status deployment/proxy-gateway --timeout=10m
|
||||
4. 逐批发布 Gateway;每次至少保留 PDB 要求的健康副本。
|
||||
5. 观察 30 分钟,再清理已经无人读取的旧字段或旧迁移。
|
||||
|
||||
迁移失败时停止 Controller 发布,不自动重试结果不确定的管理 mutation。恢复后
|
||||
先确认 Schema 版本、最近 revision、审计和 outbox 一致,再开放 Admin 写入口。
|
||||
|
||||
回滚只能回到仍兼容当前 Schema 和 Snapshot 版本的镜像。涉及不可逆数据迁移时,
|
||||
必须使用前向修复。
|
||||
|
||||
@ -173,6 +182,11 @@ Prometheus 标签禁止包含 Proxy IP、Client ID、Session、完整 URL、requ
|
||||
3. 等待已提交 Redis 原子操作返回;未确认请求的客户端必须使用相同幂等键重试。
|
||||
4. 刷新 Admin outbox、审计和 Worker ACK,再关闭 PostgreSQL/Redis 连接池。
|
||||
|
||||
Outbox 发布器必须以稳定 consumer ID 有界领取;发布成功后原子 ACK。进程在发布
|
||||
成功但 ACK 结果不确定时,不得伪造确认;等待租约到期后重领,并由下游事件消费者
|
||||
按事件 ID/revision 去重。持续积压时先暂停新的管理变更,检查发布目标、租约和
|
||||
最老未发布事件,不删除未发布行。
|
||||
|
||||
### Checker
|
||||
|
||||
1. 停止领取新任务。
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
||||
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
||||
- Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。
|
||||
- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和
|
||||
security scheme;完整标准工具验证仍待补齐。
|
||||
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
||||
结果上报、Checker 任务与 Observation。
|
||||
|
||||
@ -25,8 +27,10 @@
|
||||
- `CFG-*`:YAML v4 未知字段拒绝、监听保护、引用/上限/认证边界校验,21 份
|
||||
配置持续测试。
|
||||
- `PROXY-* / CAP-*`:唯一键、TTL 优先级、状态迁移与 Active/Reserved 打包
|
||||
原子计数;1,000 goroutine 不超卖测试。
|
||||
- `ROUTE-001 / ROUTE-004`:首条命中规则与 Concurrent Sequential 单次切换。
|
||||
原子计数;1,000 goroutine 不超卖,以及 Cancel、重复终结、错误顺序、并发
|
||||
Commit/Cancel/Release 计数守恒测试。
|
||||
- `ROUTE-001 / ROUTE-004`:首条命中规则与进程内 Concurrent Sequential 单次
|
||||
切换;策略运行时接线、持久化恢复和跨实例 CAS 尚未完成。
|
||||
- `FETCH-005 / FETCH-006`:Valid、Empty、DuplicateOnly、Error 分类。
|
||||
- `DIST-001..003 / DIST-006..007`:内存活动池参考实现验证独占提取、满足模式、
|
||||
TTL、健康时效与 Gateway 保留量;1,000 并发不重复。
|
||||
@ -39,6 +43,16 @@
|
||||
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
|
||||
Handler 已实现;共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入,
|
||||
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
|
||||
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
|
||||
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理,Memory/Redis
|
||||
运行同一公用契约。
|
||||
- `PostgreSQL 管理面`:已定义 `adminstate` 事务 seam、并发安全 MemoryStore、
|
||||
公用契约、100 并发 Routing CAS、租约 Outbox 和只含六张管理表的 Schema;pgx
|
||||
Adapter 已在真实 PostgreSQL 18 上通过同一契约、迁移幂等、审计/Outbox
|
||||
故障回滚、原子批量 ACK 和 `information_schema` 数据边界验证。
|
||||
Admin Handler 已向 mutation 传播 Actor/SourceIP。Admin ApplicationService 已
|
||||
完成管理 mutation 映射、权威/运行态 Status 聚合、严格配置加载和持久化成功后
|
||||
的原子发布;边界测试禁止其依赖 Redis Extract 或 Proxy 明细包。
|
||||
|
||||
## 2. 已执行验证
|
||||
|
||||
@ -46,7 +60,8 @@
|
||||
go test ./... PASS
|
||||
go vet ./... PASS
|
||||
go build ./... PASS
|
||||
protoc descriptor compilation PASS
|
||||
PostgreSQL 18 shared contract PASS
|
||||
protoc descriptor compilation PASS (local script; CI enforcement pending)
|
||||
docker compose config PASS
|
||||
kubectl kustomize PASS
|
||||
configuration examples 21/21 PASS
|
||||
@ -54,8 +69,8 @@ Mermaid blocks 35
|
||||
```
|
||||
|
||||
Windows 环境为 `CGO_ENABLED=0` 且没有 C 编译器,`go test -race` 在本机未执行;
|
||||
CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有把目标拓扑
|
||||
作为已运行系统。
|
||||
CI 已配置 Linux race job。PostgreSQL 18 和 Redis 8.2 的隔离 Adapter fixture
|
||||
已经运行;完整 Controller/Gateway/Checker Docker/Kubernetes 拓扑仍只有静态验证。
|
||||
|
||||
## 3. 后续实现范围
|
||||
|
||||
@ -64,16 +79,19 @@ CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有
|
||||
1. `cmd/proxy-gateway/controller/checker/loadgen` 进程装配。
|
||||
2. Gateway 进程装配、生产连接池调优与代表性流量压测。
|
||||
3. Provider 分布式 singleflight/Leader、长期凭据回收和累计额度执行器。
|
||||
4. PostgreSQL 配置版本、Upstream/Routing 管理状态、Admin 审计/Outbox
|
||||
repository 和迁移;可选聚合指标不包含 Proxy 明细。
|
||||
5. Redis TTL 活动池、原子独占提取、短期幂等结果、Leader、速率限制、心跳与
|
||||
所有权适配器;活动池可由 Provider 重建。接线时必须验证进入
|
||||
`allocationSafetyMargin` 的不可分配条目不会长期占用补池额度,同时不得
|
||||
突破 `pool.maxSize` 硬上限。
|
||||
6. Worker ownership drain/ACK/过期回收和网络快照流。
|
||||
4. PostgreSQL 连接池、迁移和 pgx Adapter 的生产命令启动装配,以及可选聚合指标;
|
||||
Schema、领域 seam、Memory/pgx Adapter、真实 PostgreSQL 18 契约和 Admin
|
||||
应用层接线已经完成。
|
||||
5. Redis Provider Leader、分布式速率与 Client 限制、Worker 心跳和自动重建;
|
||||
TTL 活动池、原子提取和 Worker ownership 已完成。
|
||||
6. Worker 网络快照流;Redis ownership drain/ACK/过期回收已完成。
|
||||
7. Checker 调度、探测器和健康 reducer。
|
||||
8. Admin/Distribution 细粒度授权、分布式限流和审计查询。
|
||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||
10. 将五种 Routing 策略和 `onUnavailable` 接入 Gateway/Distribution 运行链,
|
||||
补齐 Sequential 持久化恢复、跨实例 CAS 和 disabled candidate 语义。
|
||||
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测及短 TTL
|
||||
Proxy 运行态排空回收。
|
||||
|
||||
## 4. 容量结论
|
||||
|
||||
|
||||
@ -7,10 +7,10 @@
|
||||
|
||||
| ID | 最终需求 | 来源 | 验证证据 |
|
||||
|---|---|---|---|
|
||||
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 进程结构、架构图、构建产物 |
|
||||
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | 依赖规则、测试、性能剖析 |
|
||||
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | 配置、监听装配、端口测试 |
|
||||
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Leader、singleflight、集成测试 |
|
||||
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离;四个 `cmd/proxy-*` 构建产物待实现 |
|
||||
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Snapshot/Dispatch 及依赖边界已验证;完整 Gateway 进程与代表性性能剖析待完成 |
|
||||
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Distribution/Admin 独立监听已测试;Gateway/Metrics 生产入口待装配 |
|
||||
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | 单进程 Reconciler、合并通知和切换领域契约已完成;分布式 Leader 与运行装配待完成 |
|
||||
| ARCH-005 | 100k QPS 峰值使用多 Worker 集群 | 当前会话 | 未验证设计目标;待代表性集群负载报告 |
|
||||
|
||||
## Routing 与 Upstream
|
||||
@ -19,11 +19,11 @@
|
||||
|---|---|---|---|
|
||||
| ROUTE-001 | Routing 自上而下匹配,首条命中停止 | 3534-3798, 5825-6467 | `rule.go` 与不可变/首命中单测 |
|
||||
| ROUTE-002 | Routing 与 Upstream 生命周期解耦 | 3534-3798 | 包依赖与配置模型 |
|
||||
| ROUTE-003 | 支持 sequential、random、roundRobin、weighted、leastConnections | 5825-6467 | `strategy_test.go`、`routing_test.go` |
|
||||
| ROUTE-004 | Sequential 连续空结果达到阈值后原子切换一次 | 5295-5824, 6520-6617 | `RoutingCursor` 版本 CAS 与 100 并发测试 |
|
||||
| ROUTE-003 | 支持 sequential、random、roundRobin、weighted、leastConnections | 5825-6467 | 五种领域策略及单测已完成;配置到 Gateway/Distribution 运行链的接线待完成 |
|
||||
| ROUTE-004 | Sequential 连续空结果达到阈值后原子切换一次 | 5295-5824, 6520-6617 | 进程内 `RoutingCursor` 版本 CAS 与 100 并发测试已完成;持久化恢复和跨实例 CAS 待完成 |
|
||||
| ROUTE-005 | 空计数属于 Upstream,当前选择属于 Routing | 8442-8529 | 共享 `UpstreamEmptyState` 双 Routing 测试 |
|
||||
| ROUTE-006 | 旧 Upstream 已有 Proxy 继续耗尽,不因切换直接丢弃 | 6618-6641 | Drain 测试 |
|
||||
| ROUTE-007 | 无可用 Upstream 时显式 reject、wait 或 direct,默认 reject | 5075-5294, 6743-6760 | 配置默认值与端到端测试 |
|
||||
| ROUTE-006 | 旧 Upstream 已有 Proxy 继续耗尽,不因切换直接丢弃 | 6618-6641 | 通用 ownership Drain/ACK 原语已测试;Routing 切换到 Drain 的编排待完成 |
|
||||
| ROUTE-007 | 无可用 Upstream 时显式 reject、wait 或 direct,默认 reject | 5075-5294, 6743-6760 | 三种动作的配置校验已完成;Gateway 当前仅返回 503,默认化及 wait/direct 运行时待完成 |
|
||||
|
||||
## Provider 与补池
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
| FETCH-001 | 每个 Provider 有独立 requestInterval、maxInFlight、timeout 和 retry | 968-2394 | `provider/reconciler_test.go` |
|
||||
| FETCH-002 | 大量缺池信号合并为 singleflight/容量 1 通知 | 2067-2136, 8808-8849 | `coalesce.Signal` 与 100 并发通知测试 |
|
||||
| FETCH-003 | 错误使用指数退避和抖动,429 尊重 Retry-After | 1601-1831, 8808-8856 | `provider/reconciler_test.go` 与 `providerapi/http_adapter_test.go` |
|
||||
| FETCH-004 | Provider 获取由单逻辑 Leader 执行 | 1403-1580 | 多实例锁测试 |
|
||||
| FETCH-004 | Provider 获取由单逻辑 Leader 执行 | 1403-1580 | 单进程 Reconciler 已完成;Redis Leader 租约及多实例互斥测试待完成 |
|
||||
| FETCH-005 | Empty 与 Error 分开;只有合法候选为零时 Empty++ | 8442-8529 | `fetch_result_test.go` 分类矩阵 |
|
||||
| FETCH-006 | 重复候选不当作 Empty,记录独立指标 | 8442-8480 | DuplicateOnly 分类与 Provider 测试 |
|
||||
| FETCH-007 | 模板限制响应大小、执行时间、函数集和外部访问 | 8808-8856 | `providerapi/template_parser_test.go` 输入、输出、候选、超时、递归与函数白名单测试 |
|
||||
@ -45,8 +45,8 @@
|
||||
| PROXY-001 | Proxy 保存协议、地址、凭据引用、来源、TTL、健康、容量和标签 | 71-105, 8605-8678 | Domain 类型与序列化测试 |
|
||||
| PROXY-002 | 唯一键包含 scheme、host、port、username、credentialVersion | 6655-6727, 8605-8678 | 去重单测 |
|
||||
| PROXY-003 | TTL 来源优先级明确并统一 UTC | 681-747, 8655-8678 | TTL 表驱动测试 |
|
||||
| CAP-001 | Gateway 分配使用 Reserved -> Active 原子转换 | 1203-1467, 8530-8597 | 高并发竞态测试 |
|
||||
| CAP-002 | 补池依据 Available Slots,不只看 Proxy 数量 | 1203-1402, 8530-8597 | `Inventory.AvailableSlots` 与 Pool Reconciler 测试 |
|
||||
| CAP-001 | Gateway 分配使用 Reserved -> Active 原子转换 | 1203-1467, 8530-8597 | 固定 Max 下打包 CAS 与 1,000 并发不超卖已完成;动态降容和完整生命周期证据待完成 |
|
||||
| CAP-002 | 补池依据 Available Slots,不只看 Proxy 数量 | 1203-1402, 8530-8597 | TTL/状态/Active/Reserved 的 `AvailableSlots` 与 Reconciler 已测试;ownership、目标健康及 Gateway reserve 聚合待完成 |
|
||||
| CAP-003 | pool.maxSize 包括 FETCHED/CHECKING/AVAILABLE/SUSPECT/DRAINING 与 pending expected | 3001-3533, 6642-6680 | `FetchBudget` 100 并发额度预占测试 |
|
||||
| CAP-004 | TTL safety margin 内禁止新分配 | 173-220, 6728-6741 | 时钟测试 |
|
||||
| CAP-005 | 多 Worker 不在热路径访问 Redis 计数 | 1403-1467 | Gateway 包依赖审计、Snapshot/Dispatch 测试 |
|
||||
@ -78,12 +78,13 @@
|
||||
|
||||
| ID | 最终需求 | 来源 | 验证证据 |
|
||||
|---|---|---|---|
|
||||
| HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | 健康 reducer 测试 |
|
||||
| HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 调度测试 |
|
||||
| HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | 状态机测试 |
|
||||
| HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | Protobuf/配置契约已定义;目标 Profile 与健康 Reducer 待实现 |
|
||||
| HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 配置校验已完成;有界调度器、抖动和分级频率测试待实现 |
|
||||
| HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | Proxy 状态迁移骨架已完成;连续失败 Reducer 与活动池原子提交待实现 |
|
||||
| SEC-001 | API 认证与 Proxy 认证分离,Secret 统一脱敏 | 7528-8111, 8904-8945 | Config 脱敏、Provider Store -> SecretRef -> Gateway Resolver 跨包测试与格式化泄漏回归测试 |
|
||||
| SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 |
|
||||
| OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 |
|
||||
| OPS-002 | 优雅停机停止新请求/Fetch,等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 |
|
||||
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | 指标描述符测试 |
|
||||
| OPS-003 | PostgreSQL 只保存管理修订、Upstream/Routing 状态、Admin 审计与 Outbox | 当前会话 | ADR-006、`adminstate` 公用契约和六表 Schema 边界测试;真实 PostgreSQL 契约待完成 |
|
||||
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | 文档和配置已约束;Prometheus 指标模块及描述符测试待实现 |
|
||||
| TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单;Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 |
|
||||
|
||||
150
docs/superpowers/plans/2026-07-29-postgresql-admin-state.md
Normal file
150
docs/superpowers/plans/2026-07-29-postgresql-admin-state.md
Normal file
@ -0,0 +1,150 @@
|
||||
# PostgreSQL Admin State Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||||
> superpowers:subagent-driven-development (recommended) or superpowers:executing-plans
|
||||
> to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the authoritative PostgreSQL management-state module for config revisions,
|
||||
Upstream/Routing mutations, Admin audit and reliable Outbox without storing Proxy or
|
||||
per-extraction data.
|
||||
|
||||
**Architecture:** `domain/adminstate` defines narrow capability interfaces implemented by a
|
||||
concurrency-safe MemoryStore and one deep PostgreSQL Adapter. Every mutation hides a single
|
||||
transaction that commits state, audit and Outbox together. Both adapters run the same public
|
||||
contract; Controller Admin maps its existing typed commands onto this seam.
|
||||
|
||||
**Tech Stack:** Go 1.26, standard library, pgx/v5, PostgreSQL 18, Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Domain Contracts and Immutable Values
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/domain/adminstate/adminstate.go`
|
||||
- Create: `internal/domain/adminstate/validation_test.go`
|
||||
|
||||
- [x] Define `Mutator`, `SnapshotReader`, `AuditReader` and `Outbox` interfaces from ADR-006.
|
||||
- [x] Define typed config, Upstream, Routing, actor, audit, event and mutation values.
|
||||
- [x] Define stable errors for invalid input, missing resources, CAS conflict and unavailable
|
||||
storage.
|
||||
- [x] Validate non-empty bounded identifiers, UTC timestamps, unique lists, config checksum,
|
||||
Routing references, claim limits and claim TTL.
|
||||
- [x] Clone every slice/map/JSON value at the seam so callers cannot mutate stored state.
|
||||
- [x] Run `go test -count=1 -timeout 60s ./internal/domain/adminstate` and verify the tests
|
||||
fail before implementation, then pass after implementation.
|
||||
- [x] Commit with `feat: define admin state transaction contracts`.
|
||||
|
||||
### Task 2: Memory Reference Adapter and Shared Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/domain/adminstate/memory.go`
|
||||
- Create: `internal/domain/adminstate/contracttest/contract.go`
|
||||
- Create: `internal/domain/adminstate/contract_external_test.go`
|
||||
|
||||
- [x] Write a public contract factory that can create an isolated `adminstate.Store`
|
||||
implementation over the four narrow capability interfaces.
|
||||
- [x] Cover config commit/replay/conflict/invalid references and zero-write rollback.
|
||||
- [x] Cover Upstream idempotency, monotonic revisions, mandatory audit and changed-only events.
|
||||
- [x] Cover Routing CAS and 100 concurrent switches with at most one success.
|
||||
- [x] Cover bounded claim, exclusive claim, lease expiry, ACK ownership and stable ordering.
|
||||
- [x] Cover context cancellation and immutable Snapshot/output values.
|
||||
- [x] Implement MemoryStore behind the seam with one mutex per atomic management state.
|
||||
- [x] Run `go test -count=1 -timeout 60s ./internal/domain/adminstate/...`.
|
||||
- [x] Commit with `feat: add transactional admin state reference store`.
|
||||
|
||||
### Task 3: Schema Migration and Static Data-Boundary Test
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/adapters/postgresadmin/migrations/0001_admin_state.sql`
|
||||
- Create: `internal/adapters/postgresadmin/migrations.go`
|
||||
- Create: `internal/adapters/postgresadmin/migrations_test.go`
|
||||
|
||||
- [x] Embed ordered migrations and expose one `Migrations() []Migration` read-only accessor.
|
||||
- [x] Create only the six ADR-006 tables with primary/foreign keys, UTC timestamps, indexes,
|
||||
outbox claim fields and bounded checks.
|
||||
- [x] Add a static structure test that asserts required tables/columns are present and
|
||||
forbidden Proxy/extraction/ownership/idempotency tables or columns are absent.
|
||||
- [x] Test migration IDs are unique, strictly ordered and statements are transactional.
|
||||
- [x] Run `go test -count=1 -timeout 60s ./internal/adapters/postgresadmin`.
|
||||
- [x] Commit with `feat: add postgres admin state schema`.
|
||||
|
||||
### Task 4: PostgreSQL Deep Adapter
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/adapters/postgresadmin/adapter.go`
|
||||
- Create: `internal/adapters/postgresadmin/mutate.go`
|
||||
- Create: `internal/adapters/postgresadmin/snapshot.go`
|
||||
- Create: `internal/adapters/postgresadmin/outbox.go`
|
||||
- Create: `internal/adapters/postgresadmin/codec.go`
|
||||
- Modify: `go.mod`
|
||||
- Modify: `go.sum`
|
||||
|
||||
- [x] Add the approved pinned pgx/v5 dependency without changing unrelated modules.
|
||||
- [x] Accept a narrow pgx pool interface and reject nil dependencies.
|
||||
- [x] Implement Config/Upstream/Routing mutations with SQL validation, row locks/CAS and one
|
||||
transaction for revision, state, audit and Outbox.
|
||||
- [x] Map PostgreSQL constraint/CAS/connection errors to domain errors without leaking DSNs,
|
||||
SQL or values.
|
||||
- [x] Implement immutable Snapshot reads from one repeatable-read transaction.
|
||||
- [x] Implement bounded Outbox claim using `FOR UPDATE SKIP LOCKED` and guarded ACK.
|
||||
- [x] Keep SQL, tx retries, codecs and driver types private to the Adapter.
|
||||
- [x] Run focused unit tests and `go vet ./internal/adapters/postgresadmin/...`.
|
||||
- [x] Commit with `feat: implement postgres admin state adapter`.
|
||||
|
||||
### Task 5: Real PostgreSQL Contract Fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/docker-compose.postgres-test.yml`
|
||||
- Create: `scripts/test-postgres.ps1`
|
||||
- Create: `internal/adapters/postgresadmin/testpostgres_test.go`
|
||||
- Create: `internal/adapters/postgresadmin/contract_integration_test.go`
|
||||
- Create: `internal/adapters/postgresadmin/rollback_integration_test.go`
|
||||
|
||||
- [x] Start an isolated PostgreSQL 18 fixture on a dedicated loopback port and database.
|
||||
- [x] Apply migrations through the same migration runner used by the production Adapter.
|
||||
- [x] Run the public adminstate contract against a unique schema per test.
|
||||
- [x] Inject audit and Outbox constraint failures and prove state/revision rollback.
|
||||
- [x] Query `information_schema` and prove no Proxy, extraction, ownership or idempotency
|
||||
detail tables/columns exist.
|
||||
- [x] Clean only the unique test schema; do not drop shared databases or use broad cleanup.
|
||||
- [x] Run `.\scripts\test-postgres.ps1` with every Go test timeout set to 60 seconds.
|
||||
- [x] Commit with `test: add postgres admin state contract fixture`.
|
||||
|
||||
### Task 6: Admin Application Integration
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/controller/admin/service.go`
|
||||
- Create: `internal/controller/admin/service_test.go`
|
||||
- Modify: `internal/controller/admin/handler.go`
|
||||
- Modify: `internal/controller/admin/handler_test.go`
|
||||
|
||||
- [x] Change Admin protection to resolve `httpsecurity.Identity` once and add actor/source IP
|
||||
to mutation commands without exposing credentials.
|
||||
- [x] Map typed Handler commands to `adminstate.Mutator`; map domain conflict/not-found/
|
||||
invalid/unavailable errors to the existing HTTP contract.
|
||||
- [x] Build Status from one adminstate Snapshot plus injected activity/worker aggregate readers.
|
||||
- [x] Reload configuration with existing strict loader/validator, submit a secret-free management
|
||||
snapshot, then atomically publish runtime config only after persistence succeeds.
|
||||
- [x] Test persistence failure leaves the current runtime config unchanged and Redis Extract is
|
||||
not referenced by the Admin module.
|
||||
- [x] Run `go test -count=1 -timeout 60s ./internal/controller/admin/...`.
|
||||
- [x] Commit with `feat: connect admin API to management state`.
|
||||
|
||||
### Task 7: Documentation and Delivery Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/development/implementation-plan.md`
|
||||
- Modify: `docs/requirements/completion-audit.md`
|
||||
- Modify: `docs/testing/test-strategy.md`
|
||||
- Modify: `docs/operations/runbook.md`
|
||||
- Modify: `progress.md`
|
||||
|
||||
- [x] Mark only verified PostgreSQL capabilities complete and retain command/runtime/load gaps.
|
||||
- [x] Document migration, backup, Outbox backlog/replay and data-boundary checks.
|
||||
- [x] Run `.\scripts\verify.ps1`, `.\scripts\test-redis.ps1`,
|
||||
`.\scripts\test-postgres.ps1` and `git diff --check`.
|
||||
- [x] Audit that Gateway has no PostgreSQL/Redis dependency and Distribution has no PostgreSQL
|
||||
dependency.
|
||||
- [x] Confirm the user-owned deletion of `proxy-pool-docs-v1.0.zip` is not staged.
|
||||
- [x] Commit with `docs: record postgres admin state delivery`.
|
||||
- [ ] Push the feature branch after working Git credentials are available.
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
- **领域单测**:状态机、TTL、路由、容量、Fetch 分类和 Extraction 原子性。
|
||||
- **契约测试**:配置、OpenAPI、Protobuf 和 Provider Adapter fixture。
|
||||
- **文档契约**:相对链接必须可解析,公开 Go 命令必须指向仓库内现有目标。
|
||||
- **集成测试**:Redis 活动池原子契约、PostgreSQL 管理事务、Leader/限流、
|
||||
Outbox 与重建。
|
||||
- **端到端测试**:HTTP、CONNECT、Admin、Distribution 和优雅停机。
|
||||
@ -37,6 +38,9 @@ go test -race ./internal/...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
`go test ./docs` 自动扫描 README、设计/部署文档和公开执行指南,拒绝失效的
|
||||
相对链接以及指向尚不存在 Go 入口的运行命令。
|
||||
|
||||
真实 Redis 8.2 活动池契约使用独立 Compose fixture:
|
||||
|
||||
```powershell
|
||||
|
||||
@ -33,6 +33,8 @@
|
||||
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
|
||||
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
|
||||
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
|
||||
- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。
|
||||
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
|
||||
|
||||
### 集成与端到端
|
||||
|
||||
@ -48,7 +50,8 @@
|
||||
1. **并发容量**:1000 协程争用同一 Proxy,始终满足
|
||||
`active + reserved <= effectiveMaxConcurrency`。
|
||||
2. **Reservation 生命周期**:Dial 成功/失败、超时、取消和重复 Release 均不
|
||||
泄漏或产生负计数。
|
||||
泄漏或产生负计数。领域层已覆盖 Cancel、重复终结、错误顺序和同一
|
||||
Reservation 并发终结;Gateway Handler 覆盖建连失败、重试和请求取消。
|
||||
3. **singleflight**:100 个缺池信号只产生一个有效 Fetch 调度。
|
||||
4. **Provider 限流**:requestInterval、maxInFlight、timeout、重试和 429
|
||||
`Retry-After` 在虚拟时钟下准确。
|
||||
@ -76,6 +79,9 @@ go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
`go test ./docs` 是文档契约门禁:递归校验相对链接,并确认用户指南中的
|
||||
`go run`/`go build` 具体目标真实存在;通配包命令继续由 Go 工具链验证。
|
||||
|
||||
Redis 活动池 Adapter 与内存参考实现共享同一套公用行为契约。真实 Redis 8.2
|
||||
fixture 的执行命令是:
|
||||
|
||||
@ -88,6 +94,25 @@ fixture 的执行命令是:
|
||||
100 轮并发提取和 100 轮所有权竞争。fixture 使用唯一命名空间,不执行
|
||||
`FLUSHDB`;本地 Redis 关闭 AOF、RDB 和数据卷,避免短效 Proxy 与凭据落盘。
|
||||
|
||||
PostgreSQL 管理面使用 `adminstate/contracttest` 作为 Memory/PostgreSQL 公用
|
||||
行为契约,执行命令是:
|
||||
|
||||
```powershell
|
||||
.\scripts\test-postgres.ps1
|
||||
```
|
||||
|
||||
pgx Adapter 已在真实 PostgreSQL 18 上覆盖配置事务、Upstream 幂等、100 并发
|
||||
Routing CAS、Repeatable Read 快照、审计分页、Routing no-op、`SKIP LOCKED`
|
||||
租约、原子批量 ACK,以及审计/Outbox 写入失败时的完整回滚。fixture 为每个测试
|
||||
创建唯一 Schema,只删除该 Schema;数据库使用回环端口和 tmpfs,测试后不保留
|
||||
数据卷。静态与 `information_schema` 双重检查证明只存在六张管理表,且没有
|
||||
Proxy、凭据、逐次提取、Worker ownership 或幂等明细列。
|
||||
|
||||
Admin 应用层测试覆盖 typed-nil 依赖、Actor/SourceIP 映射、Routing CAS 错误、
|
||||
权威管理快照与低基数运行态聚合、未知字段拒绝、主配置/Secret 文件 I/O 分类、
|
||||
持久化失败不发布、幂等重放发布、脱敏管理摘要和原子配置 Store 并发读写。静态
|
||||
导入边界测试禁止 Admin 引用 Redis Activity/Extract 与 Proxy 明细包。
|
||||
|
||||
需要 PostgreSQL/Redis 的测试使用独立实例和短生命周期容器,不复用开发数据。
|
||||
测试结束后验证没有残留 Worker ownership、Leader 租约、活动池条目或幂等键,
|
||||
并检查 PostgreSQL 中不存在 Proxy 明细和逐次提取记录。
|
||||
|
||||
101
findings.md
101
findings.md
@ -82,3 +82,104 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两
|
||||
- Provider 超时不得计入 Empty Fetch。
|
||||
- 重复代理不得重复入池,也不得触发空结果切换。
|
||||
- 所有 Upstream 不可用时按显式策略执行。
|
||||
|
||||
## Checker 与健康归并事实(2026-07-29)
|
||||
|
||||
- Checker 是可水平扩展的事实采集进程,只执行有截止时间的探测并上报
|
||||
Observation;只有 Controller Reducer 能改变权威 Proxy/目标健康状态。
|
||||
- 健康层级为 BASIC、EGRESS、TARGET。新 Proxy 必须先完成全局基础检查;目标级
|
||||
失败只影响对应 Routing/Target Profile,不得把全局仍健康的 Proxy 淘汰。
|
||||
- 全局状态规则固定为:首次有意义失败进入 SUSPECT,连续失败达到
|
||||
`maxConsecutiveFailures` 后进入 UNHEALTHY,复检成功恢复 AVAILABLE。
|
||||
- 调度不能为每个 Proxy 建立常驻 goroutine 或无界队列;必须稳定分散任务、加入
|
||||
jitter、限制 `maxInFlight`,并按 FETCHED、SUSPECT、AVAILABLE 的顺序优先。
|
||||
- 现有 `activitypool.HealthStore.ApplyHealth` 只能提交最终状态、时间和延迟,不能
|
||||
原子维护连续失败计数或目标级 Profile;新模块需要将“纯 Reducer 决策”和
|
||||
“活动池原子提交”分离,通过公用窄接口复用 Memory/Redis 行为契约。
|
||||
- `api/proto/controlplane/v1/controlplane.proto` 已定义 CheckTask、CheckLevel 和
|
||||
HealthObservation,后续 Go 领域类型必须保持字段语义一致,但不直接依赖生成的
|
||||
transport 类型。
|
||||
|
||||
## Git 同步事实(2026-07-29)
|
||||
|
||||
- PostgreSQL 管理面基础文档已提交为 `7951c29`。
|
||||
- 推送远端时返回 `Authentication failed`;没有重复相同失败操作,本地提交保持
|
||||
完整,待 Git 凭据恢复后同步。
|
||||
|
||||
## Routing/Sequential 验收审计(2026-07-29)
|
||||
|
||||
- 五种 Routing 策略已有独立领域实现和单测,但 `Rule` 未携带 Strategy/
|
||||
OnUnavailable,Gateway 仍把全部 Upstream 交给一个全局轮询 Dispatcher;配置
|
||||
策略尚未影响真实请求链。
|
||||
- `onUnavailable` 已严格校验 reject/wait/direct,但 Gateway 无候选时统一返回
|
||||
503;wait/direct 和默认 reject 尚未形成运行时闭环。
|
||||
- Sequential 当前版本 CAS 只在单进程内生效,构造时总从第一个 Upstream 开始;
|
||||
PostgreSQL 管理态尚未接入游标恢复和跨实例 CAS。
|
||||
- 对话最终语义已确认并落实:单 Upstream Sequential 启动校验失败,
|
||||
`endBehavior` 省略时默认 `stop`;disabled candidate 的运行时推进仍待实现。
|
||||
|
||||
## Proxy Capacity 验收审计(2026-07-29)
|
||||
|
||||
- 每个 Proxy ID 已有独立打包原子计数,固定 Max 下 1,000 并发不会超卖;这满足
|
||||
当前 Gateway 热路径的基本预留不变量。
|
||||
- Reservation 已补齐 Cancel、重复终结、错误顺序和并发 Commit/Cancel/Release
|
||||
的领域测试;Gateway 仍会忽略 Release/Cancel 错误,尚无低基数不变量观测 seam。
|
||||
- `SetMax` 与 counters 分离更新;降到当前占用以下时会出现 overcommitted 状态,
|
||||
需要先确定“拒绝降容”或“允许排空”的正式契约。
|
||||
- Snapshot Store 永久保留见过的 Proxy ID 对应 Capacity;短 TTL、高换 IP 场景下
|
||||
需要排空后回收,避免运行态注册表长期增长。
|
||||
|
||||
## Routing Runtime 设计输入(2026-07-29)
|
||||
|
||||
- `对话内容.md` 最后一个明确结论将 Sequential `endBehavior` 默认设为 `stop`;
|
||||
领域构造器、配置校验与配置参考现已统一,且拒绝单 Upstream Sequential。
|
||||
- 产品安全默认已确定为 `onUnavailable=reject`,但当前严格配置要求字段必填;
|
||||
需要确认省略时自动补 reject,还是继续拒绝启动。
|
||||
- disabled Upstream 的确定语义是停止 Fetch、健康检查和新分配,已有 Proxy/连接
|
||||
自然排空;Sequential 是临时跳过还是永久推进尚需固化。
|
||||
- `direct` 对 Gateway 表示经过目标安全策略后绕过代理直连;对独占提取没有可返回
|
||||
的 Proxy,推荐在 `purpose=extract` 时拒绝 `direct` 配置。
|
||||
- 推荐采用混合 Routing Runtime:Controller 维护 Sequential 权威游标和跨实例
|
||||
CAS;Gateway 消费不可变路由快照并在本地执行 random/roundRobin/weighted/
|
||||
leastConnections,热路径不访问 Redis/PostgreSQL;Distribution 在 Controller
|
||||
内复用同一编译策略并交给 Redis 原子提取执行。
|
||||
- Gateway 当前没有完整进程装配,`RulesRouter` 仅存在于领域/Handler 测试;
|
||||
Distribution 只接受调用方 `allowedUpstreams`,尚未把 `purpose=extract` 路由与
|
||||
Client 许可集求交。
|
||||
|
||||
## 架构证据一致性审计(2026-07-29)
|
||||
|
||||
- `.github/workflows/ci.yml` 已覆盖 Windows/Linux vet、unit、build 及 Linux race;
|
||||
`scripts/verify.ps1` 也覆盖格式、vet、60 秒单测、条件 race 和 build,因此两项
|
||||
基础工程验收应计为完成。
|
||||
- OpenAPI 通过 Go 测试执行结构/路径/响应码检查,Protobuf descriptor 曾手工编译
|
||||
通过,但 CI 没有安装/调用 `protoc`;“在 CI 编译 descriptor”仍未完成。
|
||||
- `cmd/proxy-gateway`、`proxy-controller`、`proxy-checker`、`proxy-loadgen` 均不存在;
|
||||
Docker/Kubernetes 当前只是目标拓扑,不能作为构建产物或运行验证证据。
|
||||
- Provider Reconciler 的合并通知和请求约束已完成,但没有 Redis Leader Adapter
|
||||
或多实例锁测试;FETCH-004 仍是未完成要求。
|
||||
- Checker/Health 当前只有配置、Protobuf 和 Proxy 状态迁移骨架,没有 Scheduler、
|
||||
Reducer 或目标 Profile;HEALTH-001..003 不能记为已实现。
|
||||
- 当前没有 Prometheus 指标模块或描述符测试;OBS-001 只有文档约束,运行时证据
|
||||
缺失。
|
||||
- Available Slots 当前纳入 AVAILABLE、TTL、安全余量、Max、Active、Reserved;
|
||||
ownership、route/target health 与 Gateway reserve 尚未进入同一聚合模型。
|
||||
|
||||
## 文档可执行性审计(2026-07-29)
|
||||
|
||||
- 文档相对链接人工扫描通过,但原先缺少持续门禁;新增 `docs` 包契约测试,统一
|
||||
扫描 README、docs、deploy 与 diagrams,防止链接随文件调整后失效。
|
||||
- 配置参考曾把尚不存在的 `cmd/proxy-controller` 作为推荐启动命令;现改为真实
|
||||
可执行的 `deploy/tools/configcheck`,并明确生产 Controller 入口仍是计划能力。
|
||||
- 文档中的具体 `go run`/`go build` 目标现在必须存在;包含 `...` 的通配包命令
|
||||
由 Go 工具链自身解析并在完整验证中执行。
|
||||
|
||||
## PostgreSQL 18 Fixture 审计(2026-07-29)
|
||||
|
||||
- 集成 Compose 已新增仅绑定 `127.0.0.1:15432` 的 `postgres:18-alpine` 服务,
|
||||
数据目录挂载为 `/var/lib/postgresql` tmpfs,不声明测试持久卷。
|
||||
- Redis 测试脚本现使用专属 Compose 项目并只启动 Redis,防止未来加入的
|
||||
PostgreSQL fixture 被无关测试启动或清理。
|
||||
- 生产 `docker-compose.yml` 仍把 PostgreSQL 18 命名卷挂在旧路径
|
||||
`/var/lib/postgresql/data`;直接修改可能影响已有本地数据,必须配套迁移步骤后
|
||||
单独处理,当前不能把生产持久化拓扑视为已验证。
|
||||
|
||||
7
go.mod
7
go.mod
@ -3,11 +3,18 @@ module proxy-pool
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
24
go.sum
24
go.sum
@ -4,21 +4,43 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
103
internal/adapters/postgresadmin/adapter.go
Normal file
103
internal/adapters/postgresadmin/adapter.go
Normal file
@ -0,0 +1,103 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
var _ adminstate.Store = (*adapter)(nil)
|
||||
|
||||
type transactionBeginner interface {
|
||||
BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error)
|
||||
}
|
||||
|
||||
// adapter keeps all PostgreSQL transaction and SQL details behind adminstate.Store.
|
||||
type adapter struct {
|
||||
pool transactionBeginner
|
||||
}
|
||||
|
||||
// New constructs the PostgreSQL management-state store.
|
||||
func New(pool transactionBeginner) (adminstate.Store, error) {
|
||||
if isNil(pool) {
|
||||
return nil, adminstate.ErrInvalidCommand
|
||||
}
|
||||
return &adapter{pool: pool}, nil
|
||||
}
|
||||
|
||||
func isNil(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
kind := reflect.ValueOf(value).Kind()
|
||||
return (kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface ||
|
||||
kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice) &&
|
||||
reflect.ValueOf(value).IsNil()
|
||||
}
|
||||
|
||||
func contextError(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return adminstate.ErrInvalidCommand
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (adapter *adapter) valid() bool {
|
||||
return adapter != nil && !isNil(adapter.pool)
|
||||
}
|
||||
|
||||
func (adapter *adapter) begin(ctx context.Context, options pgx.TxOptions, operation string) (pgx.Tx, error) {
|
||||
tx, err := adapter.pool.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return nil, databaseError(ctx, operation, err)
|
||||
}
|
||||
if isNil(tx) {
|
||||
return nil, unavailable(operation)
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func rollback(tx pgx.Tx) {
|
||||
if !isNil(tx) {
|
||||
_ = tx.Rollback(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
func commit(ctx context.Context, tx pgx.Tx, operation string) error {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return databaseError(ctx, operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseError(ctx context.Context, operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
|
||||
sentinel := adminstate.ErrUnavailable
|
||||
var postgresError *pgconn.PgError
|
||||
if errors.As(err, &postgresError) {
|
||||
switch postgresError.Code {
|
||||
case "23505":
|
||||
sentinel = adminstate.ErrConflict
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("postgresadmin: %s: %w", operation, sentinel)
|
||||
}
|
||||
|
||||
func unavailable(operation string) error {
|
||||
return fmt.Errorf("postgresadmin: %s: %w", operation, adminstate.ErrUnavailable)
|
||||
}
|
||||
240
internal/adapters/postgresadmin/adapter_external_test.go
Normal file
240
internal/adapters/postgresadmin/adapter_external_test.go
Normal file
@ -0,0 +1,240 @@
|
||||
package postgresadmin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"proxy-pool/internal/adapters/postgresadmin"
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func TestNewRejectsNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, err := postgresadmin.New(nil)
|
||||
if store != nil {
|
||||
t.Fatalf("New(nil) store = %T, want nil", store)
|
||||
}
|
||||
if !errors.Is(err, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("New(nil) error = %v, want ErrInvalidCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsTypedNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var pool *pgxpool.Pool
|
||||
store, err := postgresadmin.New(pool)
|
||||
if store != nil || !errors.Is(err, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("New(typed nil) = %T, %v, want nil, ErrInvalidCommand", store, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsPrioritizeCanceledContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pool := &countingPool{}
|
||||
store, err := postgresadmin.New(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("New(): %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
operations := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{name: "commit config", run: func() error {
|
||||
_, operationErr := store.CommitConfig(ctx, adminstate.CommitConfigCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "set upstream", run: func() error {
|
||||
_, operationErr := store.SetUpstreamEnabled(ctx, adminstate.SetUpstreamCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "switch routing", run: func() error {
|
||||
_, operationErr := store.SwitchRouting(ctx, adminstate.SwitchRoutingCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "snapshot", run: func() error {
|
||||
_, operationErr := store.Snapshot(ctx)
|
||||
return operationErr
|
||||
}},
|
||||
{name: "read audit", run: func() error {
|
||||
_, operationErr := store.ReadAudit(ctx, adminstate.AuditQuery{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "claim", run: func() error {
|
||||
_, operationErr := store.Claim(ctx, adminstate.ClaimCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "acknowledge", run: func() error {
|
||||
return store.Acknowledge(ctx, adminstate.AcknowledgeCommand{})
|
||||
}},
|
||||
}
|
||||
|
||||
for _, operation := range operations {
|
||||
operation := operation
|
||||
t.Run(operation.name, func(t *testing.T) {
|
||||
if operationErr := operation.run(); !errors.Is(operationErr, context.Canceled) {
|
||||
t.Fatalf("operation error = %v, want context.Canceled", operationErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if begins := pool.begins.Load(); begins != 0 {
|
||||
t.Fatalf("BeginTx calls = %d, want 0", begins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidOperationsDoNotBeginTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pool := &countingPool{}
|
||||
store, err := postgresadmin.New(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("New(): %v", err)
|
||||
}
|
||||
operations := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{name: "commit config", run: func() error {
|
||||
_, operationErr := store.CommitConfig(context.Background(), adminstate.CommitConfigCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "set upstream", run: func() error {
|
||||
_, operationErr := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "switch routing", run: func() error {
|
||||
_, operationErr := store.SwitchRouting(context.Background(), adminstate.SwitchRoutingCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "read audit", run: func() error {
|
||||
_, operationErr := store.ReadAudit(context.Background(), adminstate.AuditQuery{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "claim", run: func() error {
|
||||
_, operationErr := store.Claim(context.Background(), adminstate.ClaimCommand{})
|
||||
return operationErr
|
||||
}},
|
||||
{name: "acknowledge", run: func() error {
|
||||
return store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{})
|
||||
}},
|
||||
}
|
||||
for _, operation := range operations {
|
||||
operation := operation
|
||||
t.Run(operation.name, func(t *testing.T) {
|
||||
if operationErr := operation.run(); !errors.Is(operationErr, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("operation error = %v, want ErrInvalidCommand", operationErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if begins := pool.begins.Load(); begins != 0 {
|
||||
t.Fatalf("BeginTx calls = %d, want 0", begins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseErrorsAreMappedAndRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "connection",
|
||||
err: errors.New("postgres://admin:secret@database/private SQL payload"),
|
||||
want: adminstate.ErrUnavailable,
|
||||
},
|
||||
{
|
||||
name: "unique constraint",
|
||||
err: &pgconn.PgError{
|
||||
Code: "23505",
|
||||
Message: "duplicate cfg-sensitive at postgres://admin:secret@database",
|
||||
},
|
||||
want: adminstate.ErrConflict,
|
||||
},
|
||||
{
|
||||
name: "foreign key constraint",
|
||||
err: &pgconn.PgError{
|
||||
Code: "23503",
|
||||
Message: "backend revision reference failed with secret details",
|
||||
},
|
||||
want: adminstate.ErrUnavailable,
|
||||
},
|
||||
{
|
||||
name: "check constraint",
|
||||
err: &pgconn.PgError{
|
||||
Code: "23514",
|
||||
Message: "backend invariant failed with secret details",
|
||||
},
|
||||
want: adminstate.ErrUnavailable,
|
||||
},
|
||||
{
|
||||
name: "value too long",
|
||||
err: &pgconn.PgError{
|
||||
Code: "22001",
|
||||
Message: "backend encoding failed with secret details",
|
||||
},
|
||||
want: adminstate.ErrUnavailable,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, err := postgresadmin.New(&errorPool{err: test.err})
|
||||
if err != nil {
|
||||
t.Fatalf("New(): %v", err)
|
||||
}
|
||||
_, operationErr := store.CommitConfig(context.Background(), validConfigCommand())
|
||||
if !errors.Is(operationErr, test.want) {
|
||||
t.Fatalf("CommitConfig() error = %v, want %v", operationErr, test.want)
|
||||
}
|
||||
for _, secret := range []string{"secret", "private SQL", "cfg-sensitive", "postgres://"} {
|
||||
if strings.Contains(operationErr.Error(), secret) {
|
||||
t.Fatalf("CommitConfig() error leaked %q: %v", secret, operationErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type countingPool struct {
|
||||
begins atomic.Int64
|
||||
}
|
||||
|
||||
func (pool *countingPool) BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) {
|
||||
pool.begins.Add(1)
|
||||
return nil, errors.New("unexpected transaction")
|
||||
}
|
||||
|
||||
type errorPool struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (pool *errorPool) BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) {
|
||||
return nil, pool.err
|
||||
}
|
||||
|
||||
func validConfigCommand() adminstate.CommitConfigCommand {
|
||||
return adminstate.CommitConfigCommand{
|
||||
RequestID: "request-a",
|
||||
Actor: adminstate.Actor{ID: "admin-a", SourceIP: "192.0.2.10"},
|
||||
OccurredAt: time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC),
|
||||
ConfigVersion: "cfg-sensitive",
|
||||
Checksum: strings.Repeat("a", adminstate.SHA256HexBytes),
|
||||
Source: "configs/proxy-pool.yaml",
|
||||
}
|
||||
}
|
||||
72
internal/adapters/postgresadmin/codec.go
Normal file
72
internal/adapters/postgresadmin/codec.go
Normal file
@ -0,0 +1,72 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func cloneConfigCommand(command adminstate.CommitConfigCommand) adminstate.CommitConfigCommand {
|
||||
cloned := command
|
||||
cloned.Checksum = strings.ToLower(command.Checksum)
|
||||
cloned.Upstreams = append([]adminstate.UpstreamDefinition(nil), command.Upstreams...)
|
||||
cloned.Routings = make([]adminstate.RoutingDefinition, len(command.Routings))
|
||||
for index, routing := range command.Routings {
|
||||
cloned.Routings[index] = routing
|
||||
cloned.Routings[index].Upstreams = append([]string(nil), routing.Upstreams...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func sourceIPValue(sourceIP string) any {
|
||||
if sourceIP == "" {
|
||||
return nil
|
||||
}
|
||||
address, err := netip.ParseAddr(sourceIP)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return address.Unmap()
|
||||
}
|
||||
|
||||
func encodePayload(value any) (json.RawMessage, error) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(payload), nil
|
||||
}
|
||||
|
||||
func databaseID(value uint64) (int64, bool) {
|
||||
if value > math.MaxInt64 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(value), true
|
||||
}
|
||||
|
||||
func domainID(value int64) (uint64, bool) {
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(value), true
|
||||
}
|
||||
|
||||
func eventDatabaseIDs(values []uint64) ([]int64, bool) {
|
||||
result := make([]int64, len(values))
|
||||
for index, value := range values {
|
||||
converted, ok := databaseID(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
result[index] = converted
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func utc(value time.Time) time.Time {
|
||||
return value.UTC()
|
||||
}
|
||||
83
internal/adapters/postgresadmin/contract_integration_test.go
Normal file
83
internal/adapters/postgresadmin/contract_integration_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
"proxy-pool/internal/domain/adminstate/contracttest"
|
||||
)
|
||||
|
||||
func TestPostgresAdminStateContract(t *testing.T) {
|
||||
contracttest.Run(t, func(t *testing.T) adminstate.Store {
|
||||
return newPostgresTestFixture(t).Store
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrationsAreIdempotentAndBounded(t *testing.T) {
|
||||
fixture := newPostgresTestFixture(t)
|
||||
if err := ApplyMigrations(t.Context(), fixture.Pool); err != nil {
|
||||
t.Fatalf("ApplyMigrations(second run): %v", err)
|
||||
}
|
||||
|
||||
rows, err := fixture.Pool.Query(context.Background(), `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`, fixture.Schema)
|
||||
if err != nil {
|
||||
t.Fatalf("query migrated tables: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
tables := make([]string, 0, 6)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan migrated table: %v", err)
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate migrated tables: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"admin_audit_log",
|
||||
"admin_outbox",
|
||||
"config_revisions",
|
||||
"control_revisions",
|
||||
"routing_admin_state",
|
||||
"upstream_admin_state",
|
||||
}
|
||||
if !slices.Equal(tables, want) {
|
||||
t.Fatalf("migrated tables = %v, want %v", tables, want)
|
||||
}
|
||||
|
||||
columnRows, err := fixture.Pool.Query(context.Background(), `
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1`, fixture.Schema)
|
||||
if err != nil {
|
||||
t.Fatalf("query migrated columns: %v", err)
|
||||
}
|
||||
defer columnRows.Close()
|
||||
for columnRows.Next() {
|
||||
var table string
|
||||
var column string
|
||||
if err := columnRows.Scan(&table, &column); err != nil {
|
||||
t.Fatalf("scan migrated column: %v", err)
|
||||
}
|
||||
qualified := strings.ToLower(table + "." + column)
|
||||
for _, forbidden := range []string{"proxy", "credential", "extraction", "worker_owner", "idempotency"} {
|
||||
if strings.Contains(qualified, forbidden) {
|
||||
t.Fatalf("PostgreSQL management boundary contains forbidden column %s", qualified)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := columnRows.Err(); err != nil {
|
||||
t.Fatalf("iterate migrated columns: %v", err)
|
||||
}
|
||||
}
|
||||
47
internal/adapters/postgresadmin/migrate.go
Normal file
47
internal/adapters/postgresadmin/migrate.go
Normal file
@ -0,0 +1,47 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
// ApplyMigrations runs every embedded idempotent migration on one physical connection.
|
||||
func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if isNil(pool) {
|
||||
return adminstate.ErrInvalidCommand
|
||||
}
|
||||
connection, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return migrationError(ctx, err)
|
||||
}
|
||||
defer connection.Release()
|
||||
|
||||
for _, migration := range Migrations() {
|
||||
if _, err := connection.Exec(ctx, migration.SQL); err != nil {
|
||||
rollbackContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = connection.Exec(rollbackContext, "ROLLBACK")
|
||||
return migrationError(ctx, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationError(ctx context.Context, err error) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("postgresadmin: apply migrations: %w", adminstate.ErrUnavailable)
|
||||
}
|
||||
69
internal/adapters/postgresadmin/migrations.go
Normal file
69
internal/adapters/postgresadmin/migrations.go
Normal file
@ -0,0 +1,69 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
var loadedMigrations = mustLoadMigrations()
|
||||
|
||||
var migrationNamePattern = regexp.MustCompile(`^(\d{4})_([a-z][a-z0-9_]*)\.sql$`)
|
||||
|
||||
func Migrations() []Migration {
|
||||
return append([]Migration(nil), loadedMigrations...)
|
||||
}
|
||||
|
||||
func mustLoadMigrations() []Migration {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("postgresadmin: read embedded migrations: %v", err))
|
||||
}
|
||||
result := make([]Migration, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
matches := migrationNamePattern.FindStringSubmatch(entry.Name())
|
||||
if len(matches) != 3 {
|
||||
panic("postgresadmin: invalid migration filename " + entry.Name())
|
||||
}
|
||||
version, err := strconv.Atoi(matches[1])
|
||||
if err != nil || version <= 0 {
|
||||
panic("postgresadmin: invalid migration version " + entry.Name())
|
||||
}
|
||||
payload, err := migrationFiles.ReadFile(filepath.ToSlash("migrations/" + entry.Name()))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("postgresadmin: read migration %s: %v", entry.Name(), err))
|
||||
}
|
||||
if strings.TrimSpace(string(payload)) == "" {
|
||||
panic("postgresadmin: empty migration " + entry.Name())
|
||||
}
|
||||
result = append(result, Migration{Version: version, Name: matches[2], SQL: string(payload)})
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
return result[left].Version < result[right].Version
|
||||
})
|
||||
for index := 1; index < len(result); index++ {
|
||||
if result[index-1].Version == result[index].Version {
|
||||
panic(fmt.Sprintf("postgresadmin: duplicate migration version %d", result[index].Version))
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
panic("postgresadmin: no embedded migrations")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS control_revisions (
|
||||
revision BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
CHECK (kind IN ('config', 'upstream', 'routing'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||
revision BIGINT PRIMARY KEY,
|
||||
config_version VARCHAR(128) NOT NULL UNIQUE,
|
||||
checksum CHAR(64) NOT NULL,
|
||||
source VARCHAR(512) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
FOREIGN KEY (revision) REFERENCES control_revisions(revision),
|
||||
CHECK (checksum ~ '^[0-9A-Fa-f]{64}$')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS upstream_admin_state (
|
||||
name VARCHAR(128) PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
FOREIGN KEY (revision) REFERENCES control_revisions(revision)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_admin_state (
|
||||
name VARCHAR(128) PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
upstreams TEXT[] NOT NULL,
|
||||
current_upstream VARCHAR(128) NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
FOREIGN KEY (revision) REFERENCES control_revisions(revision),
|
||||
CHECK (cardinality(upstreams) > 0),
|
||||
CHECK (current_upstream = ANY(upstreams))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
request_id VARCHAR(128) NOT NULL,
|
||||
actor_id VARCHAR(256) NOT NULL,
|
||||
source_ip INET,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_name VARCHAR(128) NOT NULL,
|
||||
changed BOOLEAN NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
FOREIGN KEY (revision) REFERENCES control_revisions(revision),
|
||||
CHECK (action IN ('commit_config', 'set_upstream_enabled', 'switch_routing'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_outbox (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
revision BIGINT NOT NULL,
|
||||
event_type VARCHAR(128) NOT NULL,
|
||||
aggregate_type VARCHAR(64) NOT NULL,
|
||||
aggregate_id VARCHAR(128) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
claim_owner VARCHAR(128),
|
||||
claim_until TIMESTAMPTZ,
|
||||
published_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (revision) REFERENCES control_revisions(revision),
|
||||
CHECK (jsonb_typeof(payload) = 'object'),
|
||||
CHECK ((claim_owner IS NULL) = (claim_until IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS admin_audit_log_revision_idx
|
||||
ON admin_audit_log (revision, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS admin_outbox_pending_idx
|
||||
ON admin_outbox (id)
|
||||
WHERE published_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS admin_outbox_claim_idx
|
||||
ON admin_outbox (claim_until, id)
|
||||
WHERE published_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
112
internal/adapters/postgresadmin/migrations_test.go
Normal file
112
internal/adapters/postgresadmin/migrations_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func TestApplyMigrationsValidatesContextAndPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := ApplyMigrations(context.Background(), nil); !errors.Is(err, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("ApplyMigrations(nil) error = %v, want ErrInvalidCommand", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := ApplyMigrations(ctx, nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ApplyMigrations(canceled) error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsAreOrderedTransactionalAndImmutable(t *testing.T) {
|
||||
t.Parallel()
|
||||
migrations := Migrations()
|
||||
if len(migrations) != 1 {
|
||||
t.Fatalf("len(Migrations()) = %d, want 1", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != 1 || migrations[0].Name != "admin_state" {
|
||||
t.Fatalf("migration metadata = %+v", migrations[0])
|
||||
}
|
||||
normalized := strings.TrimSpace(migrations[0].SQL)
|
||||
if !strings.HasPrefix(normalized, "BEGIN;") || !strings.HasSuffix(normalized, "COMMIT;") {
|
||||
t.Fatalf("migration is not transaction wrapped: %q", normalized)
|
||||
}
|
||||
for index := 1; index < len(migrations); index++ {
|
||||
if migrations[index-1].Version >= migrations[index].Version {
|
||||
t.Fatalf("migration versions are not strictly increasing: %+v", migrations)
|
||||
}
|
||||
}
|
||||
|
||||
migrations[0].SQL = "changed"
|
||||
if Migrations()[0].SQL == "changed" {
|
||||
t.Fatal("Migrations() returned mutable package storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSchemaContainsOnlyManagementTables(t *testing.T) {
|
||||
t.Parallel()
|
||||
sql := Migrations()[0].SQL
|
||||
tablePattern := regexp.MustCompile(`(?im)^CREATE TABLE IF NOT EXISTS ([a-z][a-z0-9_]*)\s*\(`)
|
||||
matches := tablePattern.FindAllStringSubmatch(sql, -1)
|
||||
tables := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
tables = append(tables, match[1])
|
||||
}
|
||||
sort.Strings(tables)
|
||||
want := []string{
|
||||
"admin_audit_log",
|
||||
"admin_outbox",
|
||||
"config_revisions",
|
||||
"control_revisions",
|
||||
"routing_admin_state",
|
||||
"upstream_admin_state",
|
||||
}
|
||||
if strings.Join(tables, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("created tables = %v, want %v", tables, want)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(sql)
|
||||
for _, forbidden := range []string{
|
||||
"proxy_id", "proxy_host", "proxy_port", "credential", "password",
|
||||
"extraction_record", "ownership", "idempotency",
|
||||
} {
|
||||
if strings.Contains(lower, forbidden) {
|
||||
t.Errorf("migration contains forbidden detail identifier %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, destructive := range []string{"drop table", "truncate ", "delete from"} {
|
||||
if strings.Contains(lower, destructive) {
|
||||
t.Errorf("forward migration contains destructive statement %q", destructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSchemaContainsTransactionAndOutboxConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
lower := strings.ToLower(Migrations()[0].SQL)
|
||||
for _, required := range []string{
|
||||
"revision bigint generated by default as identity primary key",
|
||||
"config_version varchar(128) not null unique",
|
||||
"checksum char(64) not null",
|
||||
"upstreams text[] not null",
|
||||
"current_upstream varchar(128) not null",
|
||||
"request_id varchar(128) not null",
|
||||
"source_ip inet",
|
||||
"payload jsonb not null",
|
||||
"claim_owner varchar(128)",
|
||||
"claim_until timestamptz",
|
||||
"published_at timestamptz",
|
||||
"foreign key (revision) references control_revisions(revision)",
|
||||
"where published_at is null",
|
||||
} {
|
||||
if !strings.Contains(lower, required) {
|
||||
t.Errorf("migration missing required schema fragment %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
475
internal/adapters/postgresadmin/mutate.go
Normal file
475
internal/adapters/postgresadmin/mutate.go
Normal file
@ -0,0 +1,475 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
const (
|
||||
lockRevisionSQL = `LOCK TABLE control_revisions IN SHARE ROW EXCLUSIVE MODE`
|
||||
currentRevisionSQL = `SELECT COALESCE(MAX(revision), 0)::bigint FROM control_revisions`
|
||||
insertRevisionSQL = `
|
||||
INSERT INTO control_revisions (revision, kind, created_at)
|
||||
VALUES ($1, $2, $3)`
|
||||
insertAuditSQL = `
|
||||
INSERT INTO admin_audit_log (
|
||||
request_id, actor_id, source_ip, action, resource_type, resource_name,
|
||||
changed, revision, reason, occurred_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
|
||||
insertOutboxSQL = `
|
||||
INSERT INTO admin_outbox (
|
||||
revision, event_type, aggregate_type, aggregate_id, payload, occurred_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
)
|
||||
|
||||
func (adapter *adapter) CommitConfig(
|
||||
ctx context.Context,
|
||||
command adminstate.CommitConfigCommand,
|
||||
) (adminstate.MutationResult, error) {
|
||||
result := adminstate.MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if command.Validate() != nil || !adapter.valid() {
|
||||
return result, adminstate.ErrInvalidCommand
|
||||
}
|
||||
command = cloneConfigCommand(command)
|
||||
|
||||
tx, currentRevision, err := adapter.beginMutation(ctx, "commit config")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
var existingChecksum string
|
||||
var isCurrent bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT checksum::text,
|
||||
revision = (SELECT MAX(revision) FROM config_revisions)
|
||||
FROM config_revisions
|
||||
WHERE config_version = $1`, command.ConfigVersion).Scan(&existingChecksum, &isCurrent)
|
||||
switch {
|
||||
case err == nil:
|
||||
if !isCurrent || !strings.EqualFold(existingChecksum, command.Checksum) {
|
||||
return result, adminstate.ErrConflict
|
||||
}
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionCommitConfig, "config", command.ConfigVersion, false,
|
||||
currentRevision, "", command.OccurredAt, "audit config no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit config no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Revision = uint64(currentRevision)
|
||||
return result, nil
|
||||
case !errors.Is(err, pgx.ErrNoRows):
|
||||
return result, databaseError(ctx, "read config revision", err)
|
||||
}
|
||||
|
||||
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "config", command.OccurredAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO config_revisions (
|
||||
revision, config_version, checksum, source, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5)`,
|
||||
nextRevision, command.ConfigVersion, command.Checksum, command.Source, utc(command.OccurredAt)); err != nil {
|
||||
return result, databaseError(ctx, "insert config revision", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM routing_admin_state`); err != nil {
|
||||
return result, databaseError(ctx, "replace routing state", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM upstream_admin_state`); err != nil {
|
||||
return result, databaseError(ctx, "replace upstream state", err)
|
||||
}
|
||||
if err := copyUpstreams(ctx, tx, command.Upstreams, nextRevision, command.OccurredAt); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := copyRoutings(ctx, tx, command.Routings, nextRevision, command.OccurredAt); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionCommitConfig, "config", command.ConfigVersion, true,
|
||||
nextRevision, "", command.OccurredAt, "audit config change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
payload, err := encodePayload(map[string]any{
|
||||
"configVersion": command.ConfigVersion,
|
||||
"checksum": command.Checksum,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, unavailable("encode config event")
|
||||
}
|
||||
if err := insertOutbox(ctx, tx, nextRevision, "config.committed", "config",
|
||||
command.ConfigVersion, payload, command.OccurredAt, "insert config event"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit config change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return adminstate.MutationResult{
|
||||
RequestID: command.RequestID,
|
||||
Changed: true,
|
||||
Revision: uint64(nextRevision),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *adapter) SetUpstreamEnabled(
|
||||
ctx context.Context,
|
||||
command adminstate.SetUpstreamCommand,
|
||||
) (adminstate.MutationResult, error) {
|
||||
result := adminstate.MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if command.Validate() != nil || !adapter.valid() {
|
||||
return result, adminstate.ErrInvalidCommand
|
||||
}
|
||||
|
||||
tx, currentRevision, err := adapter.beginMutation(ctx, "set upstream")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
var enabled bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT enabled
|
||||
FROM upstream_admin_state
|
||||
WHERE name = $1
|
||||
FOR UPDATE`, command.Name).Scan(&enabled); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, adminstate.ErrNotFound
|
||||
}
|
||||
return result, databaseError(ctx, "read upstream state", err)
|
||||
}
|
||||
if enabled == command.Enabled {
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionSetUpstream, "upstream", command.Name, false,
|
||||
currentRevision, "", command.OccurredAt, "audit upstream no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit upstream no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Revision = uint64(currentRevision)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "upstream", command.OccurredAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE upstream_admin_state
|
||||
SET enabled = $1, revision = $2, updated_at = $3
|
||||
WHERE name = $4`, command.Enabled, nextRevision, utc(command.OccurredAt), command.Name)
|
||||
if err != nil {
|
||||
return result, databaseError(ctx, "update upstream state", err)
|
||||
}
|
||||
if commandTag.RowsAffected() != 1 {
|
||||
return result, unavailable("update upstream state")
|
||||
}
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionSetUpstream, "upstream", command.Name, true,
|
||||
nextRevision, "", command.OccurredAt, "audit upstream change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
payload, err := encodePayload(map[string]any{
|
||||
"enabled": command.Enabled,
|
||||
"name": command.Name,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, unavailable("encode upstream event")
|
||||
}
|
||||
if err := insertOutbox(ctx, tx, nextRevision, "upstream.enabled_changed", "upstream",
|
||||
command.Name, payload, command.OccurredAt, "insert upstream event"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit upstream change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return adminstate.MutationResult{
|
||||
RequestID: command.RequestID,
|
||||
Changed: true,
|
||||
Revision: uint64(nextRevision),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *adapter) SwitchRouting(
|
||||
ctx context.Context,
|
||||
command adminstate.SwitchRoutingCommand,
|
||||
) (adminstate.MutationResult, error) {
|
||||
result := adminstate.MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if command.Validate() != nil || !adapter.valid() {
|
||||
return result, adminstate.ErrInvalidCommand
|
||||
}
|
||||
|
||||
tx, currentRevision, err := adapter.beginMutation(ctx, "switch routing")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
var enabled bool
|
||||
var candidates []string
|
||||
var current string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT enabled, upstreams, current_upstream
|
||||
FROM routing_admin_state
|
||||
WHERE name = $1
|
||||
FOR UPDATE`, command.Name).Scan(&enabled, &candidates, ¤t); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return result, adminstate.ErrNotFound
|
||||
}
|
||||
return result, databaseError(ctx, "read routing state", err)
|
||||
}
|
||||
if !enabled || current != command.ExpectedCurrent {
|
||||
return result, adminstate.ErrConflict
|
||||
}
|
||||
if !contains(candidates, command.Target) {
|
||||
return result, adminstate.ErrInvalidCommand
|
||||
}
|
||||
if current == command.Target {
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionSwitchRoute, "routing", command.Name, false,
|
||||
currentRevision, command.Reason, command.OccurredAt, "audit routing no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit routing no-op"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Revision = uint64(currentRevision)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "routing", command.OccurredAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE routing_admin_state
|
||||
SET current_upstream = $1, revision = $2, updated_at = $3
|
||||
WHERE name = $4`, command.Target, nextRevision, utc(command.OccurredAt), command.Name)
|
||||
if err != nil {
|
||||
return result, databaseError(ctx, "update routing state", err)
|
||||
}
|
||||
if commandTag.RowsAffected() != 1 {
|
||||
return result, unavailable("update routing state")
|
||||
}
|
||||
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
||||
adminstate.ActionSwitchRoute, "routing", command.Name, true,
|
||||
nextRevision, command.Reason, command.OccurredAt, "audit routing change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
payload, err := encodePayload(map[string]any{
|
||||
"current": command.Target,
|
||||
"name": command.Name,
|
||||
"previous": command.ExpectedCurrent,
|
||||
"reason": command.Reason,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, unavailable("encode routing event")
|
||||
}
|
||||
if err := insertOutbox(ctx, tx, nextRevision, "routing.switched", "routing",
|
||||
command.Name, payload, command.OccurredAt, "insert routing event"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := commit(ctx, tx, "commit routing change"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return adminstate.MutationResult{
|
||||
RequestID: command.RequestID,
|
||||
Changed: true,
|
||||
Revision: uint64(nextRevision),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *adapter) beginMutation(ctx context.Context, operation string) (pgx.Tx, int64, error) {
|
||||
tx, err := adapter.begin(ctx, pgx.TxOptions{AccessMode: pgx.ReadWrite}, operation)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, lockRevisionSQL); err != nil {
|
||||
rollback(tx)
|
||||
return nil, 0, databaseError(ctx, "lock revision state", err)
|
||||
}
|
||||
var currentRevision int64
|
||||
if err := tx.QueryRow(ctx, currentRevisionSQL).Scan(¤tRevision); err != nil {
|
||||
rollback(tx)
|
||||
return nil, 0, databaseError(ctx, "read current revision", err)
|
||||
}
|
||||
if currentRevision < 0 {
|
||||
rollback(tx)
|
||||
return nil, 0, unavailable("read current revision")
|
||||
}
|
||||
return tx, currentRevision, nil
|
||||
}
|
||||
|
||||
func allocateRevision(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
currentRevision int64,
|
||||
kind string,
|
||||
occurredAt time.Time,
|
||||
) (int64, error) {
|
||||
if currentRevision == math.MaxInt64 {
|
||||
return 0, adminstate.ErrUnavailable
|
||||
}
|
||||
nextRevision := currentRevision + 1
|
||||
commandTag, err := tx.Exec(ctx, insertRevisionSQL, nextRevision, kind, utc(occurredAt))
|
||||
if err != nil {
|
||||
return 0, databaseError(ctx, "allocate revision", err)
|
||||
}
|
||||
if commandTag.RowsAffected() != 1 {
|
||||
return 0, unavailable("allocate revision")
|
||||
}
|
||||
return nextRevision, nil
|
||||
}
|
||||
|
||||
func copyUpstreams(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
definitions []adminstate.UpstreamDefinition,
|
||||
revision int64,
|
||||
occurredAt time.Time,
|
||||
) error {
|
||||
if len(definitions) == 0 {
|
||||
return nil
|
||||
}
|
||||
updatedAt := utc(occurredAt)
|
||||
count, err := tx.CopyFrom(ctx, pgx.Identifier{"upstream_admin_state"},
|
||||
[]string{"name", "enabled", "revision", "updated_at"},
|
||||
pgx.CopyFromSlice(len(definitions), func(index int) ([]any, error) {
|
||||
definition := definitions[index]
|
||||
return []any{definition.Name, definition.Enabled, revision, updatedAt}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
return databaseError(ctx, "replace upstream state", err)
|
||||
}
|
||||
if count != int64(len(definitions)) {
|
||||
return unavailable("replace upstream state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyRoutings(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
definitions []adminstate.RoutingDefinition,
|
||||
revision int64,
|
||||
occurredAt time.Time,
|
||||
) error {
|
||||
if len(definitions) == 0 {
|
||||
return nil
|
||||
}
|
||||
updatedAt := utc(occurredAt)
|
||||
count, err := tx.CopyFrom(ctx, pgx.Identifier{"routing_admin_state"},
|
||||
[]string{"name", "enabled", "upstreams", "current_upstream", "revision", "updated_at"},
|
||||
pgx.CopyFromSlice(len(definitions), func(index int) ([]any, error) {
|
||||
definition := definitions[index]
|
||||
return []any{
|
||||
definition.Name,
|
||||
definition.Enabled,
|
||||
definition.Upstreams,
|
||||
definition.CurrentUpstream,
|
||||
revision,
|
||||
updatedAt,
|
||||
}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
return databaseError(ctx, "replace routing state", err)
|
||||
}
|
||||
if count != int64(len(definitions)) {
|
||||
return unavailable("replace routing state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertAudit(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
requestID string,
|
||||
actor adminstate.Actor,
|
||||
action adminstate.Action,
|
||||
resourceType string,
|
||||
resourceName string,
|
||||
changed bool,
|
||||
revision int64,
|
||||
reason string,
|
||||
occurredAt time.Time,
|
||||
operation string,
|
||||
) error {
|
||||
commandTag, err := tx.Exec(ctx, insertAuditSQL,
|
||||
requestID,
|
||||
actor.ID,
|
||||
sourceIPValue(actor.SourceIP),
|
||||
string(action),
|
||||
resourceType,
|
||||
resourceName,
|
||||
changed,
|
||||
revision,
|
||||
reason,
|
||||
utc(occurredAt),
|
||||
)
|
||||
if err != nil {
|
||||
return databaseError(ctx, operation, err)
|
||||
}
|
||||
if commandTag.RowsAffected() != 1 {
|
||||
return unavailable(operation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertOutbox(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
revision int64,
|
||||
eventType string,
|
||||
aggregateType string,
|
||||
aggregateID string,
|
||||
payload json.RawMessage,
|
||||
occurredAt time.Time,
|
||||
operation string,
|
||||
) error {
|
||||
commandTag, err := tx.Exec(ctx, insertOutboxSQL,
|
||||
revision,
|
||||
eventType,
|
||||
aggregateType,
|
||||
aggregateID,
|
||||
payload,
|
||||
utc(occurredAt),
|
||||
)
|
||||
if err != nil {
|
||||
return databaseError(ctx, operation, err)
|
||||
}
|
||||
if commandTag.RowsAffected() != 1 {
|
||||
return unavailable(operation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
179
internal/adapters/postgresadmin/outbox.go
Normal file
179
internal/adapters/postgresadmin/outbox.go
Normal file
@ -0,0 +1,179 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func (adapter *adapter) Claim(
|
||||
ctx context.Context,
|
||||
command adminstate.ClaimCommand,
|
||||
) ([]adminstate.Event, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if command.Validate() != nil || !adapter.valid() {
|
||||
return nil, adminstate.ErrInvalidCommand
|
||||
}
|
||||
tx, err := adapter.begin(ctx, pgx.TxOptions{AccessMode: pgx.ReadWrite}, "begin outbox claim")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
now := utc(command.Now)
|
||||
claimUntil := now.Add(command.Lease)
|
||||
rows, err := tx.Query(ctx, `
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM admin_outbox
|
||||
WHERE published_at IS NULL
|
||||
AND (claim_until IS NULL OR claim_until <= $1)
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $2
|
||||
), claimed AS (
|
||||
UPDATE admin_outbox AS outbox
|
||||
SET claim_owner = $3, claim_until = $4
|
||||
FROM candidates
|
||||
WHERE outbox.id = candidates.id
|
||||
RETURNING outbox.id, outbox.revision, outbox.event_type,
|
||||
outbox.aggregate_type, outbox.aggregate_id, outbox.payload,
|
||||
outbox.occurred_at, outbox.claim_owner, outbox.claim_until,
|
||||
outbox.published_at
|
||||
)
|
||||
SELECT id, revision, event_type, aggregate_type, aggregate_id, payload,
|
||||
occurred_at, claim_owner, claim_until, published_at
|
||||
FROM claimed
|
||||
ORDER BY id`, now, command.Limit, command.ConsumerID, claimUntil)
|
||||
if err != nil {
|
||||
return nil, databaseError(ctx, "claim outbox events", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]adminstate.Event, 0, command.Limit)
|
||||
for rows.Next() {
|
||||
event := adminstate.Event{}
|
||||
var id int64
|
||||
var revision int64
|
||||
var payload []byte
|
||||
var claimedBy pgtype.Text
|
||||
var claimedUntil pgtype.Timestamptz
|
||||
var publishedAt pgtype.Timestamptz
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&revision,
|
||||
&event.Type,
|
||||
&event.AggregateType,
|
||||
&event.AggregateID,
|
||||
&payload,
|
||||
&event.OccurredAt,
|
||||
&claimedBy,
|
||||
&claimedUntil,
|
||||
&publishedAt,
|
||||
); err != nil {
|
||||
return nil, databaseError(ctx, "decode claimed outbox events", err)
|
||||
}
|
||||
convertedID, idOK := domainID(id)
|
||||
convertedRevision, revisionOK := domainID(revision)
|
||||
if !idOK || !revisionOK || !claimedBy.Valid || !claimedUntil.Valid {
|
||||
return nil, unavailable("decode claimed outbox events")
|
||||
}
|
||||
event.ID = convertedID
|
||||
event.Revision = convertedRevision
|
||||
event.Payload = append(json.RawMessage(nil), payload...)
|
||||
event.OccurredAt = utc(event.OccurredAt)
|
||||
event.ClaimedBy = claimedBy.String
|
||||
value := utc(claimedUntil.Time)
|
||||
event.ClaimUntil = &value
|
||||
if publishedAt.Valid {
|
||||
value := utc(publishedAt.Time)
|
||||
event.PublishedAt = &value
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, databaseError(ctx, "claim outbox events", err)
|
||||
}
|
||||
if err := commit(ctx, tx, "commit outbox claim"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (adapter *adapter) Acknowledge(
|
||||
ctx context.Context,
|
||||
command adminstate.AcknowledgeCommand,
|
||||
) error {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.Validate() != nil || !adapter.valid() {
|
||||
return adminstate.ErrInvalidCommand
|
||||
}
|
||||
eventIDs, ok := eventDatabaseIDs(command.EventIDs)
|
||||
if !ok {
|
||||
return adminstate.ErrNotFound
|
||||
}
|
||||
tx, err := adapter.begin(ctx, pgx.TxOptions{AccessMode: pgx.ReadWrite}, "begin outbox acknowledge")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, published_at, claim_owner, claim_until
|
||||
FROM admin_outbox
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id
|
||||
FOR UPDATE`, eventIDs)
|
||||
if err != nil {
|
||||
return databaseError(ctx, "lock acknowledged outbox events", err)
|
||||
}
|
||||
count := 0
|
||||
now := utc(command.Now)
|
||||
conflict := false
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var publishedAt pgtype.Timestamptz
|
||||
var claimOwner pgtype.Text
|
||||
var claimUntil pgtype.Timestamptz
|
||||
if err := rows.Scan(&id, &publishedAt, &claimOwner, &claimUntil); err != nil {
|
||||
rows.Close()
|
||||
return databaseError(ctx, "decode acknowledged outbox events", err)
|
||||
}
|
||||
count++
|
||||
if publishedAt.Valid || !claimOwner.Valid || claimOwner.String != command.ConsumerID ||
|
||||
!claimUntil.Valid || !now.Before(claimUntil.Time) {
|
||||
conflict = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return databaseError(ctx, "lock acknowledged outbox events", err)
|
||||
}
|
||||
rows.Close()
|
||||
if count != len(eventIDs) {
|
||||
return adminstate.ErrNotFound
|
||||
}
|
||||
if conflict {
|
||||
return adminstate.ErrConflict
|
||||
}
|
||||
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE admin_outbox
|
||||
SET published_at = $1
|
||||
WHERE id = ANY($2::bigint[])`, now, eventIDs)
|
||||
if err != nil {
|
||||
return databaseError(ctx, "publish outbox events", err)
|
||||
}
|
||||
if commandTag.RowsAffected() != int64(len(eventIDs)) {
|
||||
return unavailable("publish outbox events")
|
||||
}
|
||||
return commit(ctx, tx, "commit outbox acknowledge")
|
||||
}
|
||||
142
internal/adapters/postgresadmin/rollback_integration_test.go
Normal file
142
internal/adapters/postgresadmin/rollback_integration_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func TestMutationRollsBackWhenAuditInsertFails(t *testing.T) {
|
||||
fixture := newPostgresTestFixture(t)
|
||||
now := integrationNow()
|
||||
commitIntegrationConfig(t, fixture.Store, now)
|
||||
installRejectInsertTrigger(t, fixture, "admin_audit_log", "reject_admin_audit")
|
||||
|
||||
_, err := fixture.Store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-audit-failure", Actor: integrationActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if !errors.Is(err, adminstate.ErrUnavailable) {
|
||||
t.Fatalf("SetUpstreamEnabled(audit failure) error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
assertFailedMutationLeftBaseline(t, fixture)
|
||||
|
||||
dropRejectInsertTrigger(t, fixture, "admin_audit_log", "reject_admin_audit")
|
||||
result, err := fixture.Store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-after-audit-failure", Actor: integrationActor(), OccurredAt: now.Add(2 * time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil || !result.Changed || result.Revision != 2 {
|
||||
t.Fatalf("SetUpstreamEnabled(after rollback) = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationRollsBackWhenOutboxInsertFails(t *testing.T) {
|
||||
fixture := newPostgresTestFixture(t)
|
||||
now := integrationNow()
|
||||
commitIntegrationConfig(t, fixture.Store, now)
|
||||
installRejectInsertTrigger(t, fixture, "admin_outbox", "reject_admin_outbox")
|
||||
|
||||
_, err := fixture.Store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-outbox-failure", Actor: integrationActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if !errors.Is(err, adminstate.ErrUnavailable) {
|
||||
t.Fatalf("SetUpstreamEnabled(outbox failure) error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
assertFailedMutationLeftBaseline(t, fixture)
|
||||
|
||||
dropRejectInsertTrigger(t, fixture, "admin_outbox", "reject_admin_outbox")
|
||||
result, err := fixture.Store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-after-outbox-failure", Actor: integrationActor(), OccurredAt: now.Add(2 * time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil || !result.Changed || result.Revision != 2 {
|
||||
t.Fatalf("SetUpstreamEnabled(after rollback) = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFailedMutationLeftBaseline(t *testing.T, fixture postgresTestFixture) {
|
||||
t.Helper()
|
||||
snapshot, err := fixture.Store.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Revision != 1 || !integrationUpstreamEnabled(snapshot, "provider-a") {
|
||||
t.Fatalf("Snapshot(after failed mutation) = %+v, %v", snapshot, err)
|
||||
}
|
||||
audits, err := fixture.Store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||
if err != nil || len(audits) != 1 {
|
||||
t.Fatalf("ReadAudit(after failed mutation) = %+v, %v", audits, err)
|
||||
}
|
||||
for table, want := range map[string]int{"control_revisions": 1, "admin_outbox": 1} {
|
||||
var count int
|
||||
if err := fixture.Pool.QueryRow(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("%s row count = %d, want %d", table, count, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installRejectInsertTrigger(t *testing.T, fixture postgresTestFixture, table, trigger string) {
|
||||
t.Helper()
|
||||
function := trigger + "_fn"
|
||||
statement := "CREATE FUNCTION " + function + `() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'injected management write failure'; END
|
||||
$$;
|
||||
CREATE TRIGGER ` + trigger + " BEFORE INSERT ON " + table +
|
||||
" FOR EACH ROW EXECUTE FUNCTION " + function + "()"
|
||||
if _, err := fixture.Pool.Exec(context.Background(), statement); err != nil {
|
||||
t.Fatalf("install %s trigger: %v", trigger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dropRejectInsertTrigger(t *testing.T, fixture postgresTestFixture, table, trigger string) {
|
||||
t.Helper()
|
||||
statement := "DROP TRIGGER " + trigger + " ON " + table + "; DROP FUNCTION " + trigger + "_fn()"
|
||||
if _, err := fixture.Pool.Exec(context.Background(), statement); err != nil {
|
||||
t.Fatalf("drop %s trigger: %v", trigger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func commitIntegrationConfig(t *testing.T, store adminstate.Store, now time.Time) {
|
||||
t.Helper()
|
||||
result, err := store.CommitConfig(context.Background(), adminstate.CommitConfigCommand{
|
||||
RequestID: "req-config", Actor: integrationActor(), OccurredAt: now,
|
||||
ConfigVersion: "cfg-1", Checksum: strings.Repeat("a", adminstate.SHA256HexBytes),
|
||||
Source: "configs/proxy-pool.yaml",
|
||||
Upstreams: []adminstate.UpstreamDefinition{
|
||||
{Name: "provider-a", Enabled: true},
|
||||
{Name: "provider-b", Enabled: true},
|
||||
},
|
||||
Routings: []adminstate.RoutingDefinition{{
|
||||
Name: "checkout", Enabled: true, Upstreams: []string{"provider-a", "provider-b"},
|
||||
CurrentUpstream: "provider-a",
|
||||
}},
|
||||
})
|
||||
if err != nil || !result.Changed || result.Revision != 1 {
|
||||
t.Fatalf("CommitConfig() = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func integrationActor() adminstate.Actor {
|
||||
return adminstate.Actor{ID: "admin-a", SourceIP: "192.0.2.10"}
|
||||
}
|
||||
|
||||
func integrationNow() time.Time {
|
||||
return time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func integrationUpstreamEnabled(snapshot adminstate.Snapshot, name string) bool {
|
||||
for _, upstream := range snapshot.Upstreams {
|
||||
if upstream.Name == name {
|
||||
return upstream.Enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
222
internal/adapters/postgresadmin/snapshot.go
Normal file
222
internal/adapters/postgresadmin/snapshot.go
Normal file
@ -0,0 +1,222 @@
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func (adapter *adapter) Snapshot(ctx context.Context) (adminstate.Snapshot, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return adminstate.Snapshot{}, err
|
||||
}
|
||||
if !adapter.valid() {
|
||||
return adminstate.Snapshot{}, adminstate.ErrInvalidCommand
|
||||
}
|
||||
tx, err := adapter.begin(ctx, pgx.TxOptions{
|
||||
IsoLevel: pgx.RepeatableRead,
|
||||
AccessMode: pgx.ReadOnly,
|
||||
}, "begin snapshot")
|
||||
if err != nil {
|
||||
return adminstate.Snapshot{}, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
snapshot := adminstate.Snapshot{}
|
||||
var revision int64
|
||||
if err := tx.QueryRow(ctx, currentRevisionSQL).Scan(&revision); err != nil {
|
||||
return adminstate.Snapshot{}, databaseError(ctx, "read snapshot revision", err)
|
||||
}
|
||||
convertedRevision, ok := domainID(revision)
|
||||
if !ok {
|
||||
return adminstate.Snapshot{}, unavailable("decode snapshot revision")
|
||||
}
|
||||
snapshot.Revision = convertedRevision
|
||||
|
||||
var configRevision int64
|
||||
config := adminstate.ConfigRevision{}
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT revision, config_version, checksum::text, source, created_at
|
||||
FROM config_revisions
|
||||
ORDER BY revision DESC
|
||||
LIMIT 1`).Scan(
|
||||
&configRevision,
|
||||
&config.ConfigVersion,
|
||||
&config.Checksum,
|
||||
&config.Source,
|
||||
&config.CreatedAt,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
converted, ok := domainID(configRevision)
|
||||
if !ok {
|
||||
return adminstate.Snapshot{}, unavailable("decode config revision")
|
||||
}
|
||||
config.Revision = converted
|
||||
config.CreatedAt = utc(config.CreatedAt)
|
||||
snapshot.Config = &config
|
||||
case !errors.Is(err, pgx.ErrNoRows):
|
||||
return adminstate.Snapshot{}, databaseError(ctx, "read config snapshot", err)
|
||||
}
|
||||
|
||||
upstreams, err := readUpstreamSnapshot(ctx, tx)
|
||||
if err != nil {
|
||||
return adminstate.Snapshot{}, err
|
||||
}
|
||||
snapshot.Upstreams = upstreams
|
||||
routings, err := readRoutingSnapshot(ctx, tx)
|
||||
if err != nil {
|
||||
return adminstate.Snapshot{}, err
|
||||
}
|
||||
snapshot.Routings = routings
|
||||
if err := commit(ctx, tx, "commit snapshot"); err != nil {
|
||||
return adminstate.Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (adapter *adapter) ReadAudit(
|
||||
ctx context.Context,
|
||||
query adminstate.AuditQuery,
|
||||
) ([]adminstate.AuditRecord, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.Validate() != nil || !adapter.valid() {
|
||||
return nil, adminstate.ErrInvalidCommand
|
||||
}
|
||||
afterID, ok := databaseID(query.AfterID)
|
||||
if !ok {
|
||||
return []adminstate.AuditRecord{}, nil
|
||||
}
|
||||
tx, err := adapter.begin(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}, "begin audit read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rollback(tx)
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, request_id, actor_id, COALESCE(host(source_ip), ''), action,
|
||||
resource_type, resource_name, changed, revision, reason, occurred_at
|
||||
FROM admin_audit_log
|
||||
WHERE id > $1
|
||||
ORDER BY id
|
||||
LIMIT $2`, afterID, query.Limit)
|
||||
if err != nil {
|
||||
return nil, databaseError(ctx, "read audit page", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]adminstate.AuditRecord, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
record := adminstate.AuditRecord{}
|
||||
var id int64
|
||||
var revision int64
|
||||
var action string
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&record.RequestID,
|
||||
&record.Actor.ID,
|
||||
&record.Actor.SourceIP,
|
||||
&action,
|
||||
&record.ResourceType,
|
||||
&record.ResourceName,
|
||||
&record.Changed,
|
||||
&revision,
|
||||
&record.Reason,
|
||||
&record.OccurredAt,
|
||||
); err != nil {
|
||||
return nil, databaseError(ctx, "decode audit page", err)
|
||||
}
|
||||
convertedID, idOK := domainID(id)
|
||||
convertedRevision, revisionOK := domainID(revision)
|
||||
if !idOK || !revisionOK {
|
||||
return nil, unavailable("decode audit page")
|
||||
}
|
||||
record.ID = convertedID
|
||||
record.Revision = convertedRevision
|
||||
record.Action = adminstate.Action(action)
|
||||
record.OccurredAt = utc(record.OccurredAt)
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, databaseError(ctx, "read audit page", err)
|
||||
}
|
||||
if err := commit(ctx, tx, "commit audit read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func readUpstreamSnapshot(ctx context.Context, tx pgx.Tx) ([]adminstate.UpstreamState, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT name, enabled, revision, updated_at
|
||||
FROM upstream_admin_state
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, databaseError(ctx, "read upstream snapshot", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := make([]adminstate.UpstreamState, 0)
|
||||
for rows.Next() {
|
||||
state := adminstate.UpstreamState{}
|
||||
var revision int64
|
||||
if err := rows.Scan(&state.Name, &state.Enabled, &revision, &state.UpdatedAt); err != nil {
|
||||
return nil, databaseError(ctx, "decode upstream snapshot", err)
|
||||
}
|
||||
converted, ok := domainID(revision)
|
||||
if !ok {
|
||||
return nil, unavailable("decode upstream snapshot")
|
||||
}
|
||||
state.Revision = converted
|
||||
state.UpdatedAt = utc(state.UpdatedAt)
|
||||
states = append(states, state)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, databaseError(ctx, "read upstream snapshot", err)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func readRoutingSnapshot(ctx context.Context, tx pgx.Tx) ([]adminstate.RoutingState, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT name, enabled, upstreams, current_upstream, revision, updated_at
|
||||
FROM routing_admin_state
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, databaseError(ctx, "read routing snapshot", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := make([]adminstate.RoutingState, 0)
|
||||
for rows.Next() {
|
||||
state := adminstate.RoutingState{}
|
||||
var revision int64
|
||||
if err := rows.Scan(
|
||||
&state.Name,
|
||||
&state.Enabled,
|
||||
&state.Upstreams,
|
||||
&state.CurrentUpstream,
|
||||
&revision,
|
||||
&state.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, databaseError(ctx, "decode routing snapshot", err)
|
||||
}
|
||||
converted, ok := domainID(revision)
|
||||
if !ok {
|
||||
return nil, unavailable("decode routing snapshot")
|
||||
}
|
||||
state.Revision = converted
|
||||
state.UpdatedAt = utc(state.UpdatedAt)
|
||||
state.Upstreams = append([]string(nil), state.Upstreams...)
|
||||
states = append(states, state)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, databaseError(ctx, "read routing snapshot", err)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
98
internal/adapters/postgresadmin/testpostgres_test.go
Normal file
98
internal/adapters/postgresadmin/testpostgres_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
var postgresTestSchemaSequence atomic.Uint64
|
||||
|
||||
type postgresTestFixture struct {
|
||||
Store adminstate.Store
|
||||
Pool *pgxpool.Pool
|
||||
Schema string
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func newPostgresTestFixture(t *testing.T) postgresTestFixture {
|
||||
t.Helper()
|
||||
postgresURL := os.Getenv("PROXY_POOL_TEST_POSTGRES_URL")
|
||||
if postgresURL == "" {
|
||||
t.Skip("PROXY_POOL_TEST_POSTGRES_URL is not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
adminConfig, err := pgxpool.ParseConfig(postgresURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse PROXY_POOL_TEST_POSTGRES_URL: %v", err)
|
||||
}
|
||||
adminPool, err := pgxpool.NewWithConfig(ctx, adminConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test PostgreSQL: %v", err)
|
||||
}
|
||||
if err := adminPool.Ping(ctx); err != nil {
|
||||
adminPool.Close()
|
||||
t.Fatalf("ping test PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
schema := fmt.Sprintf("it_%d_%d_%d", os.Getpid(), time.Now().UnixNano(), postgresTestSchemaSequence.Add(1))
|
||||
quotedSchema := pgx.Identifier{schema}.Sanitize()
|
||||
if _, err := adminPool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil {
|
||||
adminPool.Close()
|
||||
t.Fatalf("create isolated PostgreSQL schema: %v", err)
|
||||
}
|
||||
|
||||
testConfig, err := pgxpool.ParseConfig(postgresURL)
|
||||
if err != nil {
|
||||
adminPool.Close()
|
||||
t.Fatalf("parse isolated PostgreSQL config: %v", err)
|
||||
}
|
||||
testConfig.ConnConfig.RuntimeParams["search_path"] = schema
|
||||
testPool, err := pgxpool.NewWithConfig(ctx, testConfig)
|
||||
if err != nil {
|
||||
_, _ = adminPool.Exec(context.Background(), "DROP SCHEMA "+quotedSchema+" CASCADE")
|
||||
adminPool.Close()
|
||||
t.Fatalf("connect isolated PostgreSQL schema: %v", err)
|
||||
}
|
||||
if err := ApplyMigrations(ctx, testPool); err != nil {
|
||||
testPool.Close()
|
||||
_, _ = adminPool.Exec(context.Background(), "DROP SCHEMA "+quotedSchema+" CASCADE")
|
||||
adminPool.Close()
|
||||
t.Fatalf("apply PostgreSQL migrations: %v", err)
|
||||
}
|
||||
store, err := New(testPool)
|
||||
if err != nil {
|
||||
testPool.Close()
|
||||
_, _ = adminPool.Exec(context.Background(), "DROP SCHEMA "+quotedSchema+" CASCADE")
|
||||
adminPool.Close()
|
||||
t.Fatalf("construct PostgreSQL adapter: %v", err)
|
||||
}
|
||||
|
||||
var cleanupOnce sync.Once
|
||||
cleanup := func() {
|
||||
cleanupOnce.Do(func() {
|
||||
testPool.Close()
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
if _, err := adminPool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE"); err != nil {
|
||||
t.Errorf("drop isolated PostgreSQL schema: %v", err)
|
||||
}
|
||||
adminPool.Close()
|
||||
})
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
return postgresTestFixture{Store: store, Pool: testPool, Schema: schema, Cleanup: cleanup}
|
||||
}
|
||||
@ -35,7 +35,7 @@ routing:
|
||||
- name: extract
|
||||
enabled: true
|
||||
purpose: extract
|
||||
upstreams: [provider-a]
|
||||
upstreams: [provider-a, provider-b]
|
||||
strategy:
|
||||
type: sequential
|
||||
switchAfterEmptyFetch: 5
|
||||
@ -43,7 +43,7 @@ routing:
|
||||
onUnavailable:
|
||||
action: reject
|
||||
upstreams:
|
||||
provider-a:
|
||||
provider-a: &valid-upstream
|
||||
enabled: true
|
||||
exposure: [gateway, extract]
|
||||
provider:
|
||||
@ -78,6 +78,7 @@ upstreams:
|
||||
maxAttempts: 2
|
||||
maxConsecutiveFailures: 3
|
||||
urls: [http://connect.rom.miui.com/generate_204]
|
||||
provider-b: *valid-upstream
|
||||
`
|
||||
|
||||
func TestLoadStrictValidConfiguration(t *testing.T) {
|
||||
@ -133,7 +134,7 @@ func TestValidateRejectsUnprotectedPublicListener(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRejectsMissingUpstreamReference(t *testing.T) {
|
||||
broken := strings.Replace(validConfig, "upstreams: [provider-a]", "upstreams: [missing]", 1)
|
||||
broken := strings.Replace(validConfig, "upstreams: [provider-a, provider-b]", "upstreams: [provider-a, missing]", 1)
|
||||
_, err := Load(strings.NewReader(broken))
|
||||
if err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("Load() error = %v, want missing upstream error", err)
|
||||
@ -443,6 +444,13 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) {
|
||||
},
|
||||
want: "endBehavior",
|
||||
},
|
||||
{
|
||||
name: "sequential requires two upstreams",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Routing[0].Upstreams = []string{"provider-a"}
|
||||
},
|
||||
want: "at least two upstreams",
|
||||
},
|
||||
{
|
||||
name: "weighted strategy missing weight",
|
||||
mutate: func(cfg *Config) {
|
||||
@ -456,9 +464,10 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) {
|
||||
cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{
|
||||
"provider-a": 1,
|
||||
"provider-b": 1,
|
||||
"provider-c": 1,
|
||||
}}
|
||||
},
|
||||
want: "provider-b",
|
||||
want: "provider-c",
|
||||
},
|
||||
{
|
||||
name: "weighted strategy nonpositive weight",
|
||||
|
||||
43
internal/config/store.go
Normal file
43
internal/config/store.go
Normal file
@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var ErrInvalidStore = errors.New("invalid configuration store")
|
||||
|
||||
// Store publishes complete validated configurations with one atomic pointer swap.
|
||||
type Store struct {
|
||||
current atomic.Pointer[Config]
|
||||
}
|
||||
|
||||
func NewStore(initial *Config) (*Store, error) {
|
||||
if err := Validate(initial); err != nil {
|
||||
return nil, errors.Join(ErrInvalidStore, err)
|
||||
}
|
||||
store := &Store{}
|
||||
store.Publish(initial)
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *Store) Current() *Config {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
current := store.current.Load()
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := cloneConfig(*current)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// Publish accepts a non-nil configuration already validated by the caller.
|
||||
func (store *Store) Publish(configuration *Config) {
|
||||
if store == nil || configuration == nil {
|
||||
return
|
||||
}
|
||||
cloned := cloneConfig(*configuration)
|
||||
store.current.Store(&cloned)
|
||||
}
|
||||
89
internal/config/store_test.go
Normal file
89
internal/config/store_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStorePublishesAndReturnsDetachedConfigurations(t *testing.T) {
|
||||
t.Parallel()
|
||||
initial := storeTestConfig("provider-a")
|
||||
store, err := NewStore(initial)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
|
||||
initial.Upstreams["provider-a"] = Upstream{}
|
||||
current := store.Current()
|
||||
if current == nil || !current.Upstreams["provider-a"].Enabled {
|
||||
t.Fatalf("Current() was changed through constructor input: %+v", current)
|
||||
}
|
||||
current.Routing[0].Upstreams[0] = "mutated"
|
||||
if got := store.Current().Routing[0].Upstreams[0]; got != "provider-a" {
|
||||
t.Fatalf("Current() shared mutable state: %q", got)
|
||||
}
|
||||
|
||||
next := storeTestConfig("provider-b")
|
||||
store.Publish(next)
|
||||
next.Routing[0].Upstreams[0] = "mutated"
|
||||
if got := store.Current().Routing[0].Upstreams[0]; got != "provider-b" {
|
||||
t.Fatalf("Publish() retained caller state: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidInitialConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, configuration := range []*Config{nil, {}} {
|
||||
if _, err := NewStore(configuration); !errors.Is(err, ErrInvalidStore) {
|
||||
t.Fatalf("NewStore(%v) error = %v, want %v", configuration, err, ErrInvalidStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSupportsConcurrentReadersAndPublishers(t *testing.T) {
|
||||
store, err := NewStore(storeTestConfig("provider-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 100; index++ {
|
||||
wait.Add(2)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
name := "provider-a"
|
||||
if index%2 == 1 {
|
||||
name = "provider-b"
|
||||
}
|
||||
store.Publish(storeTestConfig(name))
|
||||
}(index)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
current := store.Current()
|
||||
if current == nil || len(current.Routing) != 1 || len(current.Routing[0].Upstreams) != 1 {
|
||||
t.Errorf("Current() returned partial configuration: %+v", current)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
|
||||
func storeTestConfig(upstreamName string) *Config {
|
||||
return &Config{
|
||||
Version: 1,
|
||||
Upstreams: map[string]Upstream{
|
||||
upstreamName: {
|
||||
Enabled: true, Exposure: []string{"gateway"},
|
||||
API: ProviderAPI{Auth: ProviderAuth{Type: "none"}},
|
||||
ProxyAuth: ProxyAuth{Type: "response"},
|
||||
Pool: Pool{MaxSize: 10}, Capacity: Capacity{MaxConcurrencyPerProxy: 1},
|
||||
Lifecycle: Lifecycle{TTL: Duration(60_000_000_000), AllocationSafetyMargin: Duration(10_000_000_000)},
|
||||
Fetch: Fetch{Timeout: Duration(1_000_000_000), MaxAttempts: 1, MaxInFlight: 1},
|
||||
},
|
||||
},
|
||||
Routing: []Routing{{
|
||||
Name: "default", Enabled: true, Purpose: "gateway", Upstreams: []string{upstreamName},
|
||||
Strategy: Strategy{Type: "random"}, OnUnavailable: OnUnavailable{Action: "reject"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@ -187,6 +187,9 @@ func validateStrategy(scope string, upstreams []string, strategy Strategy) error
|
||||
return err
|
||||
}
|
||||
if strategy.Type == "sequential" {
|
||||
if len(upstreams) < 2 {
|
||||
return fmt.Errorf("validate %s strategy: sequential requires at least two upstreams", scope)
|
||||
}
|
||||
if err := requirePositive(scope+" strategy.switchAfterEmptyFetch", strategy.SwitchAfterEmptyFetch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
84
internal/controller/admin/configuration.go
Normal file
84
internal/controller/admin/configuration.go
Normal file
@ -0,0 +1,84 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"proxy-pool/internal/config"
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
var ErrInvalidConfigurationLoader = errors.New("invalid admin configuration loader")
|
||||
|
||||
type FileConfigurationLoader struct {
|
||||
path string
|
||||
resolver config.Resolver
|
||||
}
|
||||
|
||||
func NewFileConfigurationLoader(path string, resolver config.Resolver) (*FileConfigurationLoader, error) {
|
||||
if strings.TrimSpace(path) != path || path == "" || len(path) > adminstate.MaxSourceBytes || nilInterface(resolver) {
|
||||
return nil, ErrInvalidConfigurationLoader
|
||||
}
|
||||
return &FileConfigurationLoader{path: path, resolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (loader *FileConfigurationLoader) LoadConfiguration(ctx context.Context) (LoadedConfiguration, error) {
|
||||
if loader == nil || ctx == nil {
|
||||
return LoadedConfiguration{}, ErrInvalidConfigurationLoader
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return LoadedConfiguration{}, err
|
||||
}
|
||||
content, err := loader.resolver.ReadFile(loader.path)
|
||||
if err != nil {
|
||||
return LoadedConfiguration{}, errors.Join(ErrUnavailable,
|
||||
fmt.Errorf("read configuration %q: %w", loader.path, err))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return LoadedConfiguration{}, err
|
||||
}
|
||||
resolver := &observedConfigurationResolver{Resolver: loader.resolver}
|
||||
configuration, err := config.LoadResolved(bytes.NewReader(content), resolver)
|
||||
if err != nil {
|
||||
if resolver.readErr != nil {
|
||||
return LoadedConfiguration{}, errors.Join(ErrUnavailable,
|
||||
fmt.Errorf("load configuration %q: %w", loader.path, err))
|
||||
}
|
||||
return LoadedConfiguration{}, errors.Join(ErrInvalidConfiguration,
|
||||
fmt.Errorf("load configuration %q: %w", loader.path, err))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return LoadedConfiguration{}, err
|
||||
}
|
||||
return LoadedConfiguration{Value: configuration, Source: loader.path}, nil
|
||||
}
|
||||
|
||||
type observedConfigurationResolver struct {
|
||||
config.Resolver
|
||||
readErr error
|
||||
}
|
||||
|
||||
func (resolver *observedConfigurationResolver) ReadFile(path string) ([]byte, error) {
|
||||
content, err := resolver.Resolver.ReadFile(path)
|
||||
if err != nil {
|
||||
resolver.readErr = err
|
||||
}
|
||||
return content, err
|
||||
}
|
||||
|
||||
func nilInterface(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return reflected.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
156
internal/controller/admin/configuration_test.go
Normal file
156
internal/controller/admin/configuration_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileConfigurationLoaderUsesStrictResolvedLoader(t *testing.T) {
|
||||
t.Parallel()
|
||||
configuration := strings.Replace(fileLoaderConfiguration, "proxyAuth: {type: response}",
|
||||
"proxyAuth: {type: static, username: alice, passwordFile: /run/secrets/proxy}", 1)
|
||||
resolver := &memoryConfigurationResolver{files: map[string][]byte{
|
||||
"configs/proxy-pool.yaml": []byte(configuration),
|
||||
"/run/secrets/proxy": []byte("resolved-secret\r\n"),
|
||||
}}
|
||||
loader, err := NewFileConfigurationLoader("configs/proxy-pool.yaml", resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileConfigurationLoader() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := loader.LoadConfiguration(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfiguration() error = %v", err)
|
||||
}
|
||||
if loaded.Source != "configs/proxy-pool.yaml" || loaded.Value == nil {
|
||||
t.Fatalf("LoadConfiguration() = %+v", loaded)
|
||||
}
|
||||
auth := loaded.Value.Upstreams["provider-a"].ProxyAuth
|
||||
if auth.Password != "resolved-secret" || auth.PasswordFile != "" {
|
||||
t.Fatalf("resolved proxy auth = %+v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigurationLoaderRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
resolver := &memoryConfigurationResolver{files: map[string][]byte{
|
||||
"config.yaml": []byte(fileLoaderConfiguration + "\nunknownRootField: true\n"),
|
||||
}}
|
||||
loader, err := NewFileConfigurationLoader("config.yaml", resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileConfigurationLoader() error = %v", err)
|
||||
}
|
||||
if _, err := loader.LoadConfiguration(context.Background()); !errors.Is(err, ErrInvalidConfiguration) || !strings.Contains(err.Error(), "unknownRootField") {
|
||||
t.Fatalf("LoadConfiguration() error = %v, want strict unknown field error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigurationLoaderClassifiesReadFailureAsUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
loader, err := NewFileConfigurationLoader("missing.yaml", &memoryConfigurationResolver{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileConfigurationLoader() error = %v", err)
|
||||
}
|
||||
if _, err := loader.LoadConfiguration(context.Background()); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("LoadConfiguration() error = %v, want %v", err, ErrUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigurationLoaderClassifiesSecretReadFailureAsUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
configuration := strings.Replace(fileLoaderConfiguration, "proxyAuth: {type: response}",
|
||||
"proxyAuth: {type: static, username: alice, passwordFile: /run/secrets/missing}", 1)
|
||||
resolver := &memoryConfigurationResolver{files: map[string][]byte{
|
||||
"config.yaml": []byte(configuration),
|
||||
}}
|
||||
loader, err := NewFileConfigurationLoader("config.yaml", resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileConfigurationLoader() error = %v", err)
|
||||
}
|
||||
if _, err := loader.LoadConfiguration(context.Background()); !errors.Is(err, ErrUnavailable) || errors.Is(err, ErrInvalidConfiguration) {
|
||||
t.Fatalf("LoadConfiguration() error = %v, want unavailable secret source", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigurationLoaderHonorsCancellationBeforeIO(t *testing.T) {
|
||||
t.Parallel()
|
||||
resolver := &memoryConfigurationResolver{}
|
||||
loader, err := NewFileConfigurationLoader("config.yaml", resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileConfigurationLoader() error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := loader.LoadConfiguration(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadConfiguration() error = %v, want context cancellation", err)
|
||||
}
|
||||
if resolver.reads != 0 {
|
||||
t.Fatalf("resolver reads = %d, want 0", resolver.reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFileConfigurationLoaderRejectsInvalidDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
resolver := &memoryConfigurationResolver{}
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
resolver *memoryConfigurationResolver
|
||||
}{
|
||||
{resolver: resolver},
|
||||
{path: "config.yaml"},
|
||||
} {
|
||||
if _, err := NewFileConfigurationLoader(test.path, test.resolver); !errors.Is(err, ErrInvalidConfigurationLoader) {
|
||||
t.Fatalf("NewFileConfigurationLoader(%q) error = %v", test.path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type memoryConfigurationResolver struct {
|
||||
files map[string][]byte
|
||||
reads int
|
||||
}
|
||||
|
||||
func (*memoryConfigurationResolver) LookupEnv(string) (string, bool) { return "", false }
|
||||
|
||||
func (resolver *memoryConfigurationResolver) ReadFile(path string) ([]byte, error) {
|
||||
resolver.reads++
|
||||
content, exists := resolver.files[path]
|
||||
if !exists {
|
||||
return nil, errors.New("fixture file not found")
|
||||
}
|
||||
return append([]byte(nil), content...), nil
|
||||
}
|
||||
|
||||
const fileLoaderConfiguration = `
|
||||
version: 1
|
||||
security: {requireProtectionOnPublicListen: true}
|
||||
gateway:
|
||||
enabled: true
|
||||
listen: 127.0.0.1:8080
|
||||
auth: {mode: none}
|
||||
limits: {maxConcurrentConnections: 20000}
|
||||
retry: {maxAttempts: 2, retryMethods: [GET, HEAD]}
|
||||
destinationPolicy: {denyPrivateNetworks: true, denyLoopback: true, denyLinkLocal: true}
|
||||
routing:
|
||||
- name: gateway
|
||||
enabled: true
|
||||
purpose: gateway
|
||||
match: {hostRegex: '.*'}
|
||||
upstreams: [provider-a]
|
||||
strategy: {type: leastConnections}
|
||||
onUnavailable: {action: reject}
|
||||
upstreams:
|
||||
provider-a:
|
||||
enabled: true
|
||||
exposure: [gateway]
|
||||
provider: {billingMode: subscription, protocols: [http, https]}
|
||||
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
|
||||
proxyAuth: {type: response}
|
||||
pool: {maxSize: 2000}
|
||||
capacity: {maxConcurrencyPerProxy: 20}
|
||||
lifecycle: {ttl: 5m, allocationSafetyMargin: 30s}
|
||||
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
|
||||
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}
|
||||
`
|
||||
@ -34,11 +34,11 @@ type Service interface {
|
||||
ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
|
||||
}
|
||||
|
||||
type Authorizer interface {
|
||||
Check(context.Context, *http.Request) error
|
||||
type IdentityResolver interface {
|
||||
Resolve(*http.Request) (httpsecurity.Identity, error)
|
||||
}
|
||||
|
||||
var _ Authorizer = (*httpsecurity.Protection)(nil)
|
||||
var _ IdentityResolver = (*httpsecurity.Protection)(nil)
|
||||
|
||||
type Options struct {
|
||||
MaxBodyBytes int64
|
||||
@ -80,12 +80,16 @@ type MutationResult struct {
|
||||
|
||||
type SetUpstreamCommand struct {
|
||||
RequestID string
|
||||
ActorID string
|
||||
SourceIP string
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type SwitchCommand struct {
|
||||
RequestID string `json:"-"`
|
||||
ActorID string `json:"-"`
|
||||
SourceIP string `json:"-"`
|
||||
Name string `json:"-"`
|
||||
ExpectedCurrent string `json:"expectedCurrent"`
|
||||
Target string `json:"target"`
|
||||
@ -94,19 +98,21 @@ type SwitchCommand struct {
|
||||
|
||||
type ReloadCommand struct {
|
||||
RequestID string
|
||||
ActorID string
|
||||
SourceIP string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
authorizer Authorizer
|
||||
identity IdentityResolver
|
||||
maxBodyBytes int64
|
||||
}
|
||||
|
||||
func NewHandler(service Service, authorizer Authorizer, options Options) (*Handler, error) {
|
||||
if service == nil || authorizer == nil || options.MaxBodyBytes <= 0 {
|
||||
func NewHandler(service Service, identity IdentityResolver, options Options) (*Handler, error) {
|
||||
if service == nil || identity == nil || options.MaxBodyBytes <= 0 {
|
||||
return nil, ErrInvalidHandler
|
||||
}
|
||||
return &Handler{service: service, authorizer: authorizer, maxBodyBytes: options.MaxBodyBytes}, nil
|
||||
return &Handler{service: service, identity: identity, maxBodyBytes: options.MaxBodyBytes}, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
@ -115,7 +121,8 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
||||
writeTransportProblem(writer, http.StatusBadRequest, "INVALID_REQUEST_ID", "Invalid request ID", "X-Request-ID is invalid", requestID)
|
||||
return
|
||||
}
|
||||
if err := handler.authorizer.Check(request.Context(), request); err != nil {
|
||||
identity, err := handler.identity.Resolve(request)
|
||||
if err != nil {
|
||||
if !httpsecurity.WriteProblem(writer, requestID, err) {
|
||||
writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID)
|
||||
}
|
||||
@ -133,7 +140,7 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||
return
|
||||
}
|
||||
handler.reload(writer, request, requestID)
|
||||
handler.reload(writer, request, requestID, identity)
|
||||
return
|
||||
}
|
||||
|
||||
@ -141,14 +148,14 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||
return
|
||||
}
|
||||
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID)
|
||||
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID, identity)
|
||||
return
|
||||
}
|
||||
if name, _, ok := matchNamedAction(request.URL.Path, routingPrefix, "switch"); ok {
|
||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||
return
|
||||
}
|
||||
handler.switchRouting(writer, request, name, requestID)
|
||||
handler.switchRouting(writer, request, name, requestID, identity)
|
||||
return
|
||||
}
|
||||
|
||||
@ -171,9 +178,18 @@ func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Requ
|
||||
_ = httpapi.WriteJSON(writer, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (handler *Handler) setUpstreamEnabled(writer http.ResponseWriter, request *http.Request, name string, enabled bool, requestID string) {
|
||||
func (handler *Handler) setUpstreamEnabled(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
name string,
|
||||
enabled bool,
|
||||
requestID string,
|
||||
identity httpsecurity.Identity,
|
||||
) {
|
||||
result, err := handler.service.SetUpstreamEnabled(request.Context(), SetUpstreamCommand{
|
||||
RequestID: requestID,
|
||||
ActorID: identity.ClientID,
|
||||
SourceIP: identity.SourceIP,
|
||||
Name: name,
|
||||
Enabled: enabled,
|
||||
})
|
||||
@ -184,7 +200,13 @@ func (handler *Handler) setUpstreamEnabled(writer http.ResponseWriter, request *
|
||||
writeMutation(writer, result, requestID)
|
||||
}
|
||||
|
||||
func (handler *Handler) switchRouting(writer http.ResponseWriter, request *http.Request, name, requestID string) {
|
||||
func (handler *Handler) switchRouting(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
name string,
|
||||
requestID string,
|
||||
identity httpsecurity.Identity,
|
||||
) {
|
||||
var command SwitchCommand
|
||||
if err := httpapi.DecodeJSON(writer, request, handler.maxBodyBytes, &command); err != nil {
|
||||
writeDecodeProblem(writer, err, requestID)
|
||||
@ -197,6 +219,8 @@ func (handler *Handler) switchRouting(writer http.ResponseWriter, request *http.
|
||||
return
|
||||
}
|
||||
command.RequestID = requestID
|
||||
command.ActorID = identity.ClientID
|
||||
command.SourceIP = identity.SourceIP
|
||||
command.Name = name
|
||||
result, err := handler.service.SwitchRouting(request.Context(), command)
|
||||
if err != nil {
|
||||
@ -206,8 +230,17 @@ func (handler *Handler) switchRouting(writer http.ResponseWriter, request *http.
|
||||
writeMutation(writer, result, requestID)
|
||||
}
|
||||
|
||||
func (handler *Handler) reload(writer http.ResponseWriter, request *http.Request, requestID string) {
|
||||
result, err := handler.service.ReloadConfiguration(request.Context(), ReloadCommand{RequestID: requestID})
|
||||
func (handler *Handler) reload(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
requestID string,
|
||||
identity httpsecurity.Identity,
|
||||
) {
|
||||
result, err := handler.service.ReloadConfiguration(request.Context(), ReloadCommand{
|
||||
RequestID: requestID,
|
||||
ActorID: identity.ClientID,
|
||||
SourceIP: identity.SourceIP,
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceProblem(writer, err, requestID)
|
||||
return
|
||||
|
||||
@ -128,6 +128,9 @@ func TestHandlerEnablesAndDisablesUpstream(t *testing.T) {
|
||||
if service.lastUpstream.Name != "provider-a" || service.lastUpstream.Enabled != test.enabled || service.lastUpstream.RequestID != "req-admin" {
|
||||
t.Fatalf("unexpected service call: %+v", service.lastUpstream)
|
||||
}
|
||||
if service.lastUpstream.ActorID != "admin:test" || service.lastUpstream.SourceIP != "192.0.2.10" {
|
||||
t.Fatalf("upstream actor = (%q, %q)", service.lastUpstream.ActorID, service.lastUpstream.SourceIP)
|
||||
}
|
||||
if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-admin" {
|
||||
t.Fatalf("response request ID = %q, want req-admin", requestID)
|
||||
}
|
||||
@ -152,6 +155,9 @@ func TestHandlerSwitchesRoutingWithStrictJSON(t *testing.T) {
|
||||
if service.lastSwitch.Name != "checkout" || service.lastSwitch.ExpectedCurrent != "provider-a" || service.lastSwitch.Target != "provider-b" {
|
||||
t.Fatalf("unexpected switch call: command=%+v", service.lastSwitch)
|
||||
}
|
||||
if service.lastSwitch.ActorID != "admin:test" || service.lastSwitch.SourceIP != "192.0.2.10" {
|
||||
t.Fatalf("switch actor = (%q, %q)", service.lastSwitch.ActorID, service.lastSwitch.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReloadsConfigurationWithCommandRequestID(t *testing.T) {
|
||||
@ -170,6 +176,9 @@ func TestHandlerReloadsConfigurationWithCommandRequestID(t *testing.T) {
|
||||
if service.lastReload.RequestID != "req-reload" {
|
||||
t.Fatalf("reload command = %+v", service.lastReload)
|
||||
}
|
||||
if service.lastReload.ActorID != "admin:test" || service.lastReload.SourceIP != "192.0.2.10" {
|
||||
t.Fatalf("reload actor = (%q, %q)", service.lastReload.ActorID, service.lastReload.SourceIP)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"requestId":"req-reload"`) {
|
||||
t.Fatalf("unexpected reload response %q", recorder.Body.String())
|
||||
}
|
||||
@ -298,12 +307,14 @@ func (service *stubService) ReloadConfiguration(_ context.Context, command Reloa
|
||||
|
||||
type allowAuthorizer struct{}
|
||||
|
||||
func (allowAuthorizer) Check(context.Context, *http.Request) error { return nil }
|
||||
func (allowAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
||||
return httpsecurity.Identity{ClientID: "admin:test", SourceIP: "192.0.2.10"}, nil
|
||||
}
|
||||
|
||||
type rejectAuthorizer struct{}
|
||||
|
||||
func (rejectAuthorizer) Check(context.Context, *http.Request) error {
|
||||
return &httpsecurity.HTTPError{
|
||||
func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
||||
return httpsecurity.Identity{}, &httpsecurity.HTTPError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Code: "UNAUTHORIZED",
|
||||
Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}},
|
||||
|
||||
281
internal/controller/admin/service.go
Normal file
281
internal/controller/admin/service.go
Normal file
@ -0,0 +1,281 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/config"
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
var ErrInvalidApplicationService = errors.New("invalid admin application service")
|
||||
|
||||
type StateRepository interface {
|
||||
adminstate.Mutator
|
||||
adminstate.SnapshotReader
|
||||
}
|
||||
|
||||
type OperationalStatusReader interface {
|
||||
ReadOperationalStatus(context.Context) (OperationalStatus, error)
|
||||
}
|
||||
|
||||
type ConfigurationLoader interface {
|
||||
LoadConfiguration(context.Context) (LoadedConfiguration, error)
|
||||
}
|
||||
|
||||
// ConfigurationPublisher must atomically publish an already validated configuration.
|
||||
type ConfigurationPublisher interface {
|
||||
Publish(*config.Config)
|
||||
}
|
||||
|
||||
var _ ConfigurationPublisher = (*config.Store)(nil)
|
||||
|
||||
type ApplicationDependencies struct {
|
||||
State StateRepository
|
||||
Operations OperationalStatusReader
|
||||
Configuration ConfigurationLoader
|
||||
Publisher ConfigurationPublisher
|
||||
}
|
||||
|
||||
type ApplicationOptions struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type OperationalStatus struct {
|
||||
SnapshotVersion uint64
|
||||
Upstreams []UpstreamActivity
|
||||
Workers []WorkerStatus
|
||||
}
|
||||
|
||||
type UpstreamActivity struct {
|
||||
Name string
|
||||
Available int64
|
||||
Checking int64
|
||||
Suspect int64
|
||||
Draining int64
|
||||
Extracted int64
|
||||
ConsecutiveEmptyFetch int64
|
||||
FetchErrorCount int64
|
||||
}
|
||||
|
||||
type LoadedConfiguration struct {
|
||||
Value *config.Config
|
||||
Source string
|
||||
}
|
||||
|
||||
type ApplicationService struct {
|
||||
state StateRepository
|
||||
operations OperationalStatusReader
|
||||
configuration ConfigurationLoader
|
||||
publisher ConfigurationPublisher
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var _ Service = (*ApplicationService)(nil)
|
||||
|
||||
func NewApplicationService(dependencies ApplicationDependencies, options ApplicationOptions) (*ApplicationService, error) {
|
||||
if nilInterface(dependencies.State) || nilInterface(dependencies.Operations) || nilInterface(dependencies.Configuration) ||
|
||||
nilInterface(dependencies.Publisher) || options.Now == nil {
|
||||
return nil, ErrInvalidApplicationService
|
||||
}
|
||||
return &ApplicationService{
|
||||
state: dependencies.State,
|
||||
operations: dependencies.Operations,
|
||||
configuration: dependencies.Configuration,
|
||||
publisher: dependencies.Publisher,
|
||||
now: options.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *ApplicationService) SetUpstreamEnabled(ctx context.Context, command SetUpstreamCommand) (MutationResult, error) {
|
||||
result, err := service.state.SetUpstreamEnabled(ctx, adminstate.SetUpstreamCommand{
|
||||
RequestID: command.RequestID,
|
||||
Actor: adminstate.Actor{ID: command.ActorID, SourceIP: command.SourceIP},
|
||||
OccurredAt: service.now().UTC(),
|
||||
Name: command.Name,
|
||||
Enabled: command.Enabled,
|
||||
})
|
||||
return mutationResult(result), mapAdminStateError(err)
|
||||
}
|
||||
|
||||
func (service *ApplicationService) SwitchRouting(ctx context.Context, command SwitchCommand) (MutationResult, error) {
|
||||
result, err := service.state.SwitchRouting(ctx, adminstate.SwitchRoutingCommand{
|
||||
RequestID: command.RequestID,
|
||||
Actor: adminstate.Actor{ID: command.ActorID, SourceIP: command.SourceIP},
|
||||
OccurredAt: service.now().UTC(),
|
||||
Name: command.Name,
|
||||
ExpectedCurrent: command.ExpectedCurrent,
|
||||
Target: command.Target,
|
||||
Reason: command.Reason,
|
||||
})
|
||||
return mutationResult(result), mapAdminStateError(err)
|
||||
}
|
||||
|
||||
func (service *ApplicationService) Status(ctx context.Context) (Status, error) {
|
||||
snapshot, err := service.state.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return Status{}, mapAdminStateError(err)
|
||||
}
|
||||
operations, err := service.operations.ReadOperationalStatus(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{}, errors.Join(ErrUnavailable, err)
|
||||
}
|
||||
|
||||
activityByName := make(map[string]UpstreamActivity, len(operations.Upstreams))
|
||||
for _, activity := range operations.Upstreams {
|
||||
activityByName[activity.Name] = activity
|
||||
}
|
||||
status := Status{SnapshotVersion: operations.SnapshotVersion}
|
||||
if snapshot.Config != nil {
|
||||
status.ConfigVersion = snapshot.Config.ConfigVersion
|
||||
}
|
||||
status.Upstreams = make([]UpstreamStatus, 0, len(snapshot.Upstreams))
|
||||
for _, authoritative := range snapshot.Upstreams {
|
||||
activity := activityByName[authoritative.Name]
|
||||
status.Upstreams = append(status.Upstreams, UpstreamStatus{
|
||||
Name: authoritative.Name,
|
||||
Enabled: authoritative.Enabled,
|
||||
Available: activity.Available,
|
||||
Checking: activity.Checking,
|
||||
Suspect: activity.Suspect,
|
||||
Draining: activity.Draining,
|
||||
Extracted: activity.Extracted,
|
||||
ConsecutiveEmptyFetch: activity.ConsecutiveEmptyFetch,
|
||||
FetchErrorCount: activity.FetchErrorCount,
|
||||
})
|
||||
}
|
||||
sort.Slice(status.Upstreams, func(left, right int) bool {
|
||||
return status.Upstreams[left].Name < status.Upstreams[right].Name
|
||||
})
|
||||
status.Workers = append([]WorkerStatus(nil), operations.Workers...)
|
||||
sort.Slice(status.Workers, func(left, right int) bool {
|
||||
if status.Workers[left].ID == status.Workers[right].ID {
|
||||
return status.Workers[left].Zone < status.Workers[right].Zone
|
||||
}
|
||||
return status.Workers[left].ID < status.Workers[right].ID
|
||||
})
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (service *ApplicationService) ReloadConfiguration(ctx context.Context, command ReloadCommand) (MutationResult, error) {
|
||||
loaded, err := service.configuration.LoadConfiguration(ctx)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
return MutationResult{RequestID: command.RequestID}, err
|
||||
case errors.Is(err, ErrUnavailable), errors.Is(err, ErrInvalidConfiguration):
|
||||
return MutationResult{RequestID: command.RequestID}, err
|
||||
default:
|
||||
return MutationResult{RequestID: command.RequestID}, errors.Join(ErrInvalidConfiguration, err)
|
||||
}
|
||||
}
|
||||
if loaded.Value == nil || strings.TrimSpace(loaded.Source) != loaded.Source || loaded.Source == "" ||
|
||||
len(loaded.Source) > adminstate.MaxSourceBytes {
|
||||
return MutationResult{RequestID: command.RequestID}, ErrInvalidConfiguration
|
||||
}
|
||||
if err := config.Validate(loaded.Value); err != nil {
|
||||
return MutationResult{RequestID: command.RequestID}, errors.Join(ErrInvalidConfiguration, err)
|
||||
}
|
||||
|
||||
managementView := loaded.Value.Redacted()
|
||||
encoded, err := json.Marshal(managementView)
|
||||
if err != nil {
|
||||
return MutationResult{RequestID: command.RequestID}, errors.Join(ErrInvalidConfiguration, err)
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
checksum := hex.EncodeToString(digest[:])
|
||||
|
||||
current, err := service.state.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return MutationResult{RequestID: command.RequestID}, mapAdminStateError(err)
|
||||
}
|
||||
upstreams, routings := managementDefinitions(loaded.Value, current)
|
||||
result, err := service.state.CommitConfig(ctx, adminstate.CommitConfigCommand{
|
||||
RequestID: command.RequestID,
|
||||
Actor: adminstate.Actor{ID: command.ActorID, SourceIP: command.SourceIP},
|
||||
OccurredAt: service.now().UTC(),
|
||||
ConfigVersion: "cfg-" + checksum,
|
||||
Checksum: checksum,
|
||||
Source: loaded.Source,
|
||||
Upstreams: upstreams,
|
||||
Routings: routings,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult(result), mapAdminStateError(err)
|
||||
}
|
||||
service.publisher.Publish(loaded.Value)
|
||||
return mutationResult(result), nil
|
||||
}
|
||||
|
||||
func managementDefinitions(configuration *config.Config, current adminstate.Snapshot) ([]adminstate.UpstreamDefinition, []adminstate.RoutingDefinition) {
|
||||
upstreams := make([]adminstate.UpstreamDefinition, 0, len(configuration.Upstreams))
|
||||
for name, upstream := range configuration.Upstreams {
|
||||
upstreams = append(upstreams, adminstate.UpstreamDefinition{Name: name, Enabled: upstream.Enabled})
|
||||
}
|
||||
sort.Slice(upstreams, func(left, right int) bool { return upstreams[left].Name < upstreams[right].Name })
|
||||
|
||||
currentByName := make(map[string]string, len(current.Routings))
|
||||
for _, routing := range current.Routings {
|
||||
currentByName[routing.Name] = routing.CurrentUpstream
|
||||
}
|
||||
routings := make([]adminstate.RoutingDefinition, 0, len(configuration.Routing))
|
||||
for _, routing := range configuration.Routing {
|
||||
selected := ""
|
||||
if existing := currentByName[routing.Name]; containsString(routing.Upstreams, existing) {
|
||||
selected = existing
|
||||
} else if len(routing.Upstreams) > 0 {
|
||||
selected = routing.Upstreams[0]
|
||||
}
|
||||
routings = append(routings, adminstate.RoutingDefinition{
|
||||
Name: routing.Name, Enabled: routing.Enabled,
|
||||
Upstreams: append([]string(nil), routing.Upstreams...), CurrentUpstream: selected,
|
||||
})
|
||||
}
|
||||
sort.Slice(routings, func(left, right int) bool { return routings[left].Name < routings[right].Name })
|
||||
return upstreams, routings
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mutationResult(result adminstate.MutationResult) MutationResult {
|
||||
return MutationResult{
|
||||
RequestID: result.RequestID,
|
||||
Changed: result.Changed,
|
||||
Version: result.Revision,
|
||||
Message: result.Message,
|
||||
}
|
||||
}
|
||||
|
||||
func mapAdminStateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, adminstate.ErrNotFound):
|
||||
return errors.Join(ErrNotFound, err)
|
||||
case errors.Is(err, adminstate.ErrConflict):
|
||||
return errors.Join(ErrConflict, err)
|
||||
case errors.Is(err, adminstate.ErrInvalidCommand):
|
||||
return errors.Join(ErrInvalidConfiguration, err)
|
||||
case errors.Is(err, adminstate.ErrUnavailable):
|
||||
return errors.Join(ErrUnavailable, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
43
internal/controller/admin/service_boundary_test.go
Normal file
43
internal/controller/admin/service_boundary_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminApplicationHasNoProxyDetailOrRedisExtractionDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
packages, err := parser.ParseDir(token.NewFileSet(), ".", nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir(): %v", err)
|
||||
}
|
||||
for _, parsedPackage := range packages {
|
||||
for filename, file := range parsedPackage.Files {
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
importSpec, ok := node.(*ast.ImportSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
path, err := strconv.Unquote(importSpec.Path.Value)
|
||||
if err != nil {
|
||||
t.Errorf("unquote import in %s: %v", filename, err)
|
||||
return false
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"internal/adapters/redisactivity",
|
||||
"internal/domain/activitypool",
|
||||
"internal/domain/extraction",
|
||||
} {
|
||||
if strings.Contains(path, forbidden) {
|
||||
t.Errorf("%s imports forbidden data-plane package %q", filename, path)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
513
internal/controller/admin/service_test.go
Normal file
513
internal/controller/admin/service_test.go
Normal file
@ -0,0 +1,513 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/config"
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
func TestApplicationServiceMapsUpstreamMutationToAdminState(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
state := &recordingAdminState{
|
||||
mutation: adminstate.MutationResult{
|
||||
RequestID: "req-enable",
|
||||
Changed: true,
|
||||
Revision: 12,
|
||||
Message: "enabled",
|
||||
},
|
||||
}
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state,
|
||||
Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := service.SetUpstreamEnabled(context.Background(), SetUpstreamCommand{
|
||||
RequestID: "req-enable",
|
||||
ActorID: "admin:alice",
|
||||
SourceIP: "192.0.2.10",
|
||||
Name: "provider-a",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetUpstreamEnabled() error = %v", err)
|
||||
}
|
||||
if result != (MutationResult{RequestID: "req-enable", Changed: true, Version: 12, Message: "enabled"}) {
|
||||
t.Fatalf("SetUpstreamEnabled() result = %+v", result)
|
||||
}
|
||||
if state.lastUpstream != (adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-enable",
|
||||
Actor: adminstate.Actor{ID: "admin:alice", SourceIP: "192.0.2.10"},
|
||||
OccurredAt: now,
|
||||
Name: "provider-a",
|
||||
Enabled: true,
|
||||
}) {
|
||||
t.Fatalf("admin state command = %+v", state.lastUpstream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationServiceRejectsMissingDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := ApplicationDependencies{
|
||||
State: &recordingAdminState{},
|
||||
Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
dependencies ApplicationDependencies
|
||||
options ApplicationOptions
|
||||
}{
|
||||
{name: "state", dependencies: func() ApplicationDependencies { value := valid; value.State = nil; return value }(), options: ApplicationOptions{Now: time.Now}},
|
||||
{name: "operations", dependencies: func() ApplicationDependencies { value := valid; value.Operations = nil; return value }(), options: ApplicationOptions{Now: time.Now}},
|
||||
{name: "configuration", dependencies: func() ApplicationDependencies { value := valid; value.Configuration = nil; return value }(), options: ApplicationOptions{Now: time.Now}},
|
||||
{name: "publisher", dependencies: func() ApplicationDependencies { value := valid; value.Publisher = nil; return value }(), options: ApplicationOptions{Now: time.Now}},
|
||||
{name: "clock", dependencies: valid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewApplicationService(test.dependencies, test.options); !errors.Is(err, ErrInvalidApplicationService) {
|
||||
t.Fatalf("NewApplicationService() error = %v, want %v", err, ErrInvalidApplicationService)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationServiceRejectsTypedNilDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
var state *recordingAdminState
|
||||
var publisher *recordingConfigurationPublisher
|
||||
valid := ApplicationDependencies{
|
||||
State: &recordingAdminState{},
|
||||
Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}
|
||||
for _, dependencies := range []ApplicationDependencies{
|
||||
func() ApplicationDependencies { value := valid; value.State = state; return value }(),
|
||||
func() ApplicationDependencies { value := valid; value.Publisher = publisher; return value }(),
|
||||
} {
|
||||
if _, err := NewApplicationService(dependencies, ApplicationOptions{Now: time.Now}); !errors.Is(err, ErrInvalidApplicationService) {
|
||||
t.Fatalf("NewApplicationService(typed nil) error = %v, want %v", err, ErrInvalidApplicationService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceMapsRoutingSwitchAndDomainErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 11, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||
state := &recordingAdminState{mutation: adminstate.MutationResult{RequestID: "req-switch", Changed: true, Revision: 21}}
|
||||
service := mustApplicationService(t, state, ApplicationOptions{Now: func() time.Time { return now }})
|
||||
|
||||
result, err := service.SwitchRouting(context.Background(), SwitchCommand{
|
||||
RequestID: "req-switch", ActorID: "admin:bob", SourceIP: "198.51.100.7",
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b", Reason: "capacity",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SwitchRouting() error = %v", err)
|
||||
}
|
||||
if result.Version != 21 || !result.Changed {
|
||||
t.Fatalf("SwitchRouting() result = %+v", result)
|
||||
}
|
||||
wantCommand := adminstate.SwitchRoutingCommand{
|
||||
RequestID: "req-switch", Actor: adminstate.Actor{ID: "admin:bob", SourceIP: "198.51.100.7"},
|
||||
OccurredAt: now.UTC(), Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b", Reason: "capacity",
|
||||
}
|
||||
if state.lastSwitch != wantCommand {
|
||||
t.Fatalf("admin state command = %+v, want %+v", state.lastSwitch, wantCommand)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
domain error
|
||||
want error
|
||||
}{
|
||||
{domain: adminstate.ErrNotFound, want: ErrNotFound},
|
||||
{domain: adminstate.ErrConflict, want: ErrConflict},
|
||||
{domain: adminstate.ErrInvalidCommand, want: ErrInvalidConfiguration},
|
||||
{domain: adminstate.ErrUnavailable, want: ErrUnavailable},
|
||||
}
|
||||
for _, test := range tests {
|
||||
state.err = test.domain
|
||||
_, err := service.SwitchRouting(context.Background(), SwitchCommand{
|
||||
RequestID: "req-switch", ActorID: "admin:bob", SourceIP: "198.51.100.7",
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b",
|
||||
})
|
||||
if !errors.Is(err, test.want) || !errors.Is(err, test.domain) {
|
||||
t.Fatalf("SwitchRouting(%v) error = %v, want mapped %v preserving cause", test.domain, err, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceBuildsStatusFromAuthoritativeAndOperationalSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
state := &recordingAdminState{snapshot: adminstate.Snapshot{
|
||||
Revision: 31,
|
||||
Config: &adminstate.ConfigRevision{Revision: 31, ConfigVersion: "cfg-31"},
|
||||
Upstreams: []adminstate.UpstreamState{
|
||||
{Name: "provider-b", Enabled: false, Revision: 31},
|
||||
{Name: "provider-a", Enabled: true, Revision: 31},
|
||||
},
|
||||
}}
|
||||
operations := staticOperationalStatusReader{status: OperationalStatus{
|
||||
SnapshotVersion: 88,
|
||||
Upstreams: []UpstreamActivity{
|
||||
{Name: "provider-a", Available: 10, Checking: 2, Suspect: 1, ConsecutiveEmptyFetch: 3},
|
||||
{Name: "unknown", Available: 999},
|
||||
},
|
||||
Workers: []WorkerStatus{
|
||||
{ID: "worker-b", Zone: "zone-b", Connected: false, SnapshotVersion: 87, StaleSeconds: 4},
|
||||
{ID: "worker-a", Zone: "zone-a", Connected: true, SnapshotVersion: 88},
|
||||
},
|
||||
}}
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: operations, Configuration: staticConfigurationLoader{},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
|
||||
status, err := service.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
if status.ConfigVersion != "cfg-31" || status.SnapshotVersion != 88 {
|
||||
t.Fatalf("Status() versions = (%q, %d)", status.ConfigVersion, status.SnapshotVersion)
|
||||
}
|
||||
if len(status.Upstreams) != 2 || status.Upstreams[0].Name != "provider-a" || !status.Upstreams[0].Enabled ||
|
||||
status.Upstreams[0].Available != 10 || status.Upstreams[0].Checking != 2 ||
|
||||
status.Upstreams[0].ConsecutiveEmptyFetch != 3 {
|
||||
t.Fatalf("Status() upstreams = %+v", status.Upstreams)
|
||||
}
|
||||
if status.Upstreams[1].Name != "provider-b" || status.Upstreams[1].Enabled || status.Upstreams[1].Available != 0 {
|
||||
t.Fatalf("Status() disabled upstream = %+v", status.Upstreams[1])
|
||||
}
|
||||
if len(status.Workers) != 2 || status.Workers[0].ID != "worker-a" || status.Workers[1].ID != "worker-b" {
|
||||
t.Fatalf("Status() workers = %+v", status.Workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceMapsStatusDependencyFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
stateFailure := errors.Join(adminstate.ErrUnavailable, errors.New("postgres down"))
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: &recordingAdminState{err: stateFailure}, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{}, Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
if _, err := service.Status(context.Background()); !errors.Is(err, ErrUnavailable) || !errors.Is(err, stateFailure) {
|
||||
t.Fatalf("Status(state failure) error = %v", err)
|
||||
}
|
||||
|
||||
operationsFailure := errors.New("redis aggregate unavailable")
|
||||
service, err = NewApplicationService(ApplicationDependencies{
|
||||
State: &recordingAdminState{}, Operations: staticOperationalStatusReader{err: operationsFailure},
|
||||
Configuration: staticConfigurationLoader{}, Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
if _, err := service.Status(context.Background()); !errors.Is(err, ErrUnavailable) || !errors.Is(err, operationsFailure) {
|
||||
t.Fatalf("Status(operations failure) error = %v", err)
|
||||
}
|
||||
|
||||
service, err = NewApplicationService(ApplicationDependencies{
|
||||
State: &recordingAdminState{}, Operations: staticOperationalStatusReader{err: context.Canceled},
|
||||
Configuration: staticConfigurationLoader{}, Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
if _, err := service.Status(context.Background()); !errors.Is(err, context.Canceled) || errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("Status(cancellation) error = %v, want unclassified cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceReloadPersistsManagementViewBeforePublishing(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
||||
configuration := validReloadConfiguration()
|
||||
publisher := &recordingConfigurationPublisher{}
|
||||
state := &recordingAdminState{
|
||||
mutation: adminstate.MutationResult{RequestID: "req-reload", Changed: true, Revision: 42},
|
||||
snapshot: adminstate.Snapshot{Routings: []adminstate.RoutingState{
|
||||
{Name: "checkout", Enabled: true, Upstreams: []string{"provider-a", "provider-b"}, CurrentUpstream: "provider-b"},
|
||||
}},
|
||||
}
|
||||
state.onCommit = func(adminstate.CommitConfigCommand) {
|
||||
if len(publisher.published) != 0 {
|
||||
t.Fatal("configuration was published before management state committed")
|
||||
}
|
||||
}
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{loaded: LoadedConfiguration{
|
||||
Value: configuration, Source: "configs/proxy-pool.yaml",
|
||||
}},
|
||||
Publisher: publisher,
|
||||
}, ApplicationOptions{Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := service.ReloadConfiguration(context.Background(), ReloadCommand{
|
||||
RequestID: "req-reload", ActorID: "admin:alice", SourceIP: "192.0.2.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReloadConfiguration() error = %v", err)
|
||||
}
|
||||
if result.Version != 42 || !result.Changed {
|
||||
t.Fatalf("ReloadConfiguration() result = %+v", result)
|
||||
}
|
||||
if len(publisher.published) != 1 || publisher.published[0] != configuration {
|
||||
t.Fatalf("published configurations = %+v", publisher.published)
|
||||
}
|
||||
command := state.lastConfig
|
||||
if command.RequestID != "req-reload" || command.Actor != (adminstate.Actor{ID: "admin:alice", SourceIP: "192.0.2.10"}) ||
|
||||
!command.OccurredAt.Equal(now) || command.Source != "configs/proxy-pool.yaml" {
|
||||
t.Fatalf("CommitConfig() metadata = %+v", command)
|
||||
}
|
||||
if len(command.Checksum) != adminstate.SHA256HexBytes || command.ConfigVersion != "cfg-"+command.Checksum {
|
||||
t.Fatalf("CommitConfig() version/checksum = (%q, %q)", command.ConfigVersion, command.Checksum)
|
||||
}
|
||||
if len(command.Upstreams) != 2 || command.Upstreams[0].Name != "provider-a" || command.Upstreams[1].Name != "provider-b" {
|
||||
t.Fatalf("CommitConfig() upstreams = %+v", command.Upstreams)
|
||||
}
|
||||
if len(command.Routings) != 2 || command.Routings[0].Name != "checkout" || command.Routings[0].CurrentUpstream != "provider-b" ||
|
||||
command.Routings[1].Name != "new-route" || command.Routings[1].CurrentUpstream != "provider-b" {
|
||||
t.Fatalf("CommitConfig() routings = %+v", command.Routings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceReloadDoesNotPublishInvalidOrUncommittedConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
loaded LoadedConfiguration
|
||||
stateError error
|
||||
wantCause error
|
||||
}{
|
||||
{
|
||||
name: "invalid configuration", loaded: LoadedConfiguration{Value: &config.Config{}, Source: "invalid.yaml"},
|
||||
wantCause: ErrInvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "persistence unavailable", loaded: LoadedConfiguration{Value: validReloadConfiguration(), Source: "valid.yaml"},
|
||||
stateError: adminstate.ErrUnavailable, wantCause: ErrUnavailable,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
publisher := &recordingConfigurationPublisher{}
|
||||
state := &recordingAdminState{err: test.stateError}
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{loaded: test.loaded}, Publisher: publisher,
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
_, err = service.ReloadConfiguration(context.Background(), ReloadCommand{
|
||||
RequestID: "req-reload", ActorID: "admin:alice", SourceIP: "192.0.2.10",
|
||||
})
|
||||
if !errors.Is(err, test.wantCause) {
|
||||
t.Fatalf("ReloadConfiguration() error = %v, want %v", err, test.wantCause)
|
||||
}
|
||||
if len(publisher.published) != 0 {
|
||||
t.Fatalf("published %d configurations after failure", len(publisher.published))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceReloadPublishesSuccessfulReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
publisher := &recordingConfigurationPublisher{}
|
||||
state := &recordingAdminState{mutation: adminstate.MutationResult{RequestID: "req-replay", Revision: 7}}
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{loaded: LoadedConfiguration{
|
||||
Value: validReloadConfiguration(), Source: "config.yaml",
|
||||
}}, Publisher: publisher,
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
result, err := service.ReloadConfiguration(context.Background(), ReloadCommand{
|
||||
RequestID: "req-replay", ActorID: "admin:alice", SourceIP: "192.0.2.10",
|
||||
})
|
||||
if err != nil || result.Changed || result.Version != 7 || len(publisher.published) != 1 {
|
||||
t.Fatalf("ReloadConfiguration() = %+v, %v; publishes=%d", result, err, len(publisher.published))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceReloadPreservesCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: &recordingAdminState{}, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{err: context.Canceled},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
_, err = service.ReloadConfiguration(context.Background(), ReloadCommand{RequestID: "req-cancel"})
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, ErrInvalidConfiguration) {
|
||||
t.Fatalf("ReloadConfiguration() error = %v, want unclassified cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServiceUsesSecretFreeManagementChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
state := &recordingAdminState{}
|
||||
publisher := &recordingConfigurationPublisher{}
|
||||
var commands []adminstate.CommitConfigCommand
|
||||
state.onCommit = func(command adminstate.CommitConfigCommand) {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
for _, secret := range []string{"secret-a", "secret-b"} {
|
||||
configuration := validReloadConfiguration()
|
||||
upstream := configuration.Upstreams["provider-a"]
|
||||
upstream.ProxyAuth.Password = secret
|
||||
configuration.Upstreams["provider-a"] = upstream
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: staticOperationalStatusReader{},
|
||||
Configuration: staticConfigurationLoader{loaded: LoadedConfiguration{
|
||||
Value: configuration, Source: "config.yaml",
|
||||
}}, Publisher: publisher,
|
||||
}, ApplicationOptions{Now: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
if _, err := service.ReloadConfiguration(context.Background(), ReloadCommand{
|
||||
RequestID: "req-secret", ActorID: "admin:alice", SourceIP: "192.0.2.10",
|
||||
}); err != nil {
|
||||
t.Fatalf("ReloadConfiguration() error = %v", err)
|
||||
}
|
||||
}
|
||||
if len(commands) != 2 || commands[0].Checksum != commands[1].Checksum || commands[0].ConfigVersion != commands[1].ConfigVersion {
|
||||
t.Fatalf("secret rotation changed public management digest: %+v", commands)
|
||||
}
|
||||
if len(publisher.published) != 2 || publisher.published[1].Upstreams["provider-a"].ProxyAuth.Password != "secret-b" {
|
||||
t.Fatalf("secret rotation was not published: %+v", publisher.published)
|
||||
}
|
||||
}
|
||||
|
||||
func validReloadConfiguration() *config.Config {
|
||||
return &config.Config{
|
||||
Version: 1,
|
||||
Upstreams: map[string]config.Upstream{
|
||||
"provider-b": validReloadUpstream("secret-b"),
|
||||
"provider-a": validReloadUpstream("secret-a"),
|
||||
},
|
||||
Routing: []config.Routing{
|
||||
{
|
||||
Name: "new-route", Enabled: true, Purpose: "gateway",
|
||||
Upstreams: []string{"provider-b", "provider-a"}, Strategy: config.Strategy{Type: "random"},
|
||||
OnUnavailable: config.OnUnavailable{Action: "reject"},
|
||||
},
|
||||
{
|
||||
Name: "checkout", Enabled: true, Purpose: "gateway",
|
||||
Upstreams: []string{"provider-a", "provider-b"}, Strategy: config.Strategy{Type: "sequential", SwitchAfterEmptyFetch: 5},
|
||||
OnUnavailable: config.OnUnavailable{Action: "reject"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validReloadUpstream(secret string) config.Upstream {
|
||||
return config.Upstream{
|
||||
Enabled: true, Exposure: []string{"gateway"},
|
||||
API: config.ProviderAPI{Auth: config.ProviderAuth{Type: "none"}},
|
||||
ProxyAuth: config.ProxyAuth{Type: "static", Username: "user", Password: secret},
|
||||
Pool: config.Pool{MaxSize: 10}, Capacity: config.Capacity{MaxConcurrencyPerProxy: 2},
|
||||
Lifecycle: config.Lifecycle{TTL: config.Duration(time.Minute), AllocationSafetyMargin: config.Duration(10 * time.Second)},
|
||||
Fetch: config.Fetch{Timeout: config.Duration(time.Second), MaxAttempts: 2, MaxInFlight: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func mustApplicationService(t *testing.T, state StateRepository, options ApplicationOptions) *ApplicationService {
|
||||
t.Helper()
|
||||
service, err := NewApplicationService(ApplicationDependencies{
|
||||
State: state, Operations: staticOperationalStatusReader{}, Configuration: staticConfigurationLoader{},
|
||||
Publisher: &recordingConfigurationPublisher{},
|
||||
}, options)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplicationService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
type recordingAdminState struct {
|
||||
mutation adminstate.MutationResult
|
||||
err error
|
||||
snapshot adminstate.Snapshot
|
||||
lastUpstream adminstate.SetUpstreamCommand
|
||||
lastSwitch adminstate.SwitchRoutingCommand
|
||||
lastConfig adminstate.CommitConfigCommand
|
||||
onCommit func(adminstate.CommitConfigCommand)
|
||||
}
|
||||
|
||||
func (state *recordingAdminState) SetUpstreamEnabled(_ context.Context, command adminstate.SetUpstreamCommand) (adminstate.MutationResult, error) {
|
||||
state.lastUpstream = command
|
||||
return state.mutation, state.err
|
||||
}
|
||||
|
||||
func (state *recordingAdminState) SwitchRouting(_ context.Context, command adminstate.SwitchRoutingCommand) (adminstate.MutationResult, error) {
|
||||
state.lastSwitch = command
|
||||
return state.mutation, state.err
|
||||
}
|
||||
|
||||
func (state *recordingAdminState) CommitConfig(_ context.Context, command adminstate.CommitConfigCommand) (adminstate.MutationResult, error) {
|
||||
state.lastConfig = command
|
||||
if state.onCommit != nil {
|
||||
state.onCommit(command)
|
||||
}
|
||||
return state.mutation, state.err
|
||||
}
|
||||
|
||||
func (state *recordingAdminState) Snapshot(context.Context) (adminstate.Snapshot, error) {
|
||||
return state.snapshot, state.err
|
||||
}
|
||||
|
||||
type staticOperationalStatusReader struct {
|
||||
status OperationalStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (reader staticOperationalStatusReader) ReadOperationalStatus(context.Context) (OperationalStatus, error) {
|
||||
return reader.status, reader.err
|
||||
}
|
||||
|
||||
type staticConfigurationLoader struct {
|
||||
loaded LoadedConfiguration
|
||||
err error
|
||||
}
|
||||
|
||||
func (loader staticConfigurationLoader) LoadConfiguration(context.Context) (LoadedConfiguration, error) {
|
||||
return loader.loaded, loader.err
|
||||
}
|
||||
|
||||
type recordingConfigurationPublisher struct {
|
||||
published []*config.Config
|
||||
}
|
||||
|
||||
func (publisher *recordingConfigurationPublisher) Publish(configuration *config.Config) {
|
||||
publisher.published = append(publisher.published, configuration)
|
||||
}
|
||||
394
internal/domain/adminstate/adminstate.go
Normal file
394
internal/domain/adminstate/adminstate.go
Normal file
@ -0,0 +1,394 @@
|
||||
package adminstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxIdentifierBytes = 128
|
||||
MaxActorIDBytes = 256
|
||||
MaxReasonBytes = 512
|
||||
MaxSourceBytes = 512
|
||||
MaxDefinitions = 10_000
|
||||
MaxPageSize = 1_000
|
||||
SHA256HexBytes = 64
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCommand = errors.New("invalid admin state command")
|
||||
ErrNotFound = errors.New("admin state resource not found")
|
||||
ErrConflict = errors.New("admin state conflict")
|
||||
ErrUnavailable = errors.New("admin state unavailable")
|
||||
)
|
||||
|
||||
var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
type Mutator interface {
|
||||
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
|
||||
SwitchRouting(context.Context, SwitchRoutingCommand) (MutationResult, error)
|
||||
CommitConfig(context.Context, CommitConfigCommand) (MutationResult, error)
|
||||
}
|
||||
|
||||
type SnapshotReader interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
type AuditReader interface {
|
||||
ReadAudit(context.Context, AuditQuery) ([]AuditRecord, error)
|
||||
}
|
||||
|
||||
type Outbox interface {
|
||||
Claim(context.Context, ClaimCommand) ([]Event, error)
|
||||
Acknowledge(context.Context, AcknowledgeCommand) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Mutator
|
||||
SnapshotReader
|
||||
AuditReader
|
||||
Outbox
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
ID string
|
||||
SourceIP string
|
||||
}
|
||||
|
||||
type SetUpstreamCommand struct {
|
||||
RequestID string
|
||||
Actor Actor
|
||||
OccurredAt time.Time
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type SwitchRoutingCommand struct {
|
||||
RequestID string
|
||||
Actor Actor
|
||||
OccurredAt time.Time
|
||||
Name string
|
||||
ExpectedCurrent string
|
||||
Target string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type CommitConfigCommand struct {
|
||||
RequestID string
|
||||
Actor Actor
|
||||
OccurredAt time.Time
|
||||
ConfigVersion string
|
||||
Checksum string
|
||||
Source string
|
||||
Upstreams []UpstreamDefinition
|
||||
Routings []RoutingDefinition
|
||||
}
|
||||
|
||||
type UpstreamDefinition struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type RoutingDefinition struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Upstreams []string
|
||||
CurrentUpstream string
|
||||
}
|
||||
|
||||
type MutationResult struct {
|
||||
RequestID string
|
||||
Changed bool
|
||||
Revision uint64
|
||||
Message string
|
||||
}
|
||||
|
||||
type ConfigRevision struct {
|
||||
Revision uint64
|
||||
ConfigVersion string
|
||||
Checksum string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UpstreamState struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Revision uint64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type RoutingState struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Upstreams []string
|
||||
CurrentUpstream string
|
||||
Revision uint64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Revision uint64
|
||||
Config *ConfigRevision
|
||||
Upstreams []UpstreamState
|
||||
Routings []RoutingState
|
||||
}
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionSetUpstream Action = "set_upstream_enabled"
|
||||
ActionSwitchRoute Action = "switch_routing"
|
||||
ActionCommitConfig Action = "commit_config"
|
||||
)
|
||||
|
||||
type AuditRecord struct {
|
||||
ID uint64
|
||||
RequestID string
|
||||
Actor Actor
|
||||
Action Action
|
||||
ResourceType string
|
||||
ResourceName string
|
||||
Changed bool
|
||||
Revision uint64
|
||||
Reason string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type AuditQuery struct {
|
||||
AfterID uint64
|
||||
Limit int
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID uint64
|
||||
Revision uint64
|
||||
Type string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
Payload json.RawMessage
|
||||
OccurredAt time.Time
|
||||
ClaimedBy string
|
||||
ClaimUntil *time.Time
|
||||
PublishedAt *time.Time
|
||||
}
|
||||
|
||||
type ClaimCommand struct {
|
||||
ConsumerID string
|
||||
Now time.Time
|
||||
Limit int
|
||||
Lease time.Duration
|
||||
}
|
||||
|
||||
type AcknowledgeCommand struct {
|
||||
ConsumerID string
|
||||
Now time.Time
|
||||
EventIDs []uint64
|
||||
}
|
||||
|
||||
// Validate checks whether the command is safe for every Store adapter to execute.
|
||||
func (command SetUpstreamCommand) Validate() error {
|
||||
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
||||
!validIdentifier(command.Name) {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks whether the command is safe for every Store adapter to execute.
|
||||
func (command SwitchRoutingCommand) Validate() error {
|
||||
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
||||
!validIdentifier(command.Name) || !validIdentifier(command.ExpectedCurrent) ||
|
||||
!validIdentifier(command.Target) || !validOptionalText(command.Reason, MaxReasonBytes) {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the complete management snapshot and all of its references.
|
||||
func (command CommitConfigCommand) Validate() error {
|
||||
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
||||
!validRequiredText(command.ConfigVersion, MaxIdentifierBytes) ||
|
||||
!validSHA256(command.Checksum) || !validRequiredText(command.Source, MaxSourceBytes) ||
|
||||
len(command.Upstreams) > MaxDefinitions || len(command.Routings) > MaxDefinitions {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
|
||||
upstreamNames := make(map[string]struct{}, len(command.Upstreams))
|
||||
for _, upstream := range command.Upstreams {
|
||||
if !validIdentifier(upstream.Name) {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if _, duplicate := upstreamNames[upstream.Name]; duplicate {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
upstreamNames[upstream.Name] = struct{}{}
|
||||
}
|
||||
|
||||
routingNames := make(map[string]struct{}, len(command.Routings))
|
||||
for _, routing := range command.Routings {
|
||||
if !validIdentifier(routing.Name) || len(routing.Upstreams) == 0 ||
|
||||
len(routing.Upstreams) > MaxDefinitions || !validIdentifier(routing.CurrentUpstream) {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if _, duplicate := routingNames[routing.Name]; duplicate {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
routingNames[routing.Name] = struct{}{}
|
||||
candidates := make(map[string]struct{}, len(routing.Upstreams))
|
||||
for _, upstream := range routing.Upstreams {
|
||||
if !validIdentifier(upstream) {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if _, exists := upstreamNames[upstream]; !exists {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if _, duplicate := candidates[upstream]; duplicate {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
candidates[upstream] = struct{}{}
|
||||
}
|
||||
if _, exists := candidates[routing.CurrentUpstream]; !exists {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the bounded outbox claim request.
|
||||
func (command ClaimCommand) Validate() error {
|
||||
if !validIdentifier(command.ConsumerID) || command.Now.IsZero() || command.Limit <= 0 ||
|
||||
command.Limit > MaxPageSize || command.Lease <= 0 {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the bounded, duplicate-free outbox acknowledgement request.
|
||||
func (command AcknowledgeCommand) Validate() error {
|
||||
if !validIdentifier(command.ConsumerID) || command.Now.IsZero() || len(command.EventIDs) == 0 ||
|
||||
len(command.EventIDs) > MaxPageSize {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
seen := make(map[uint64]struct{}, len(command.EventIDs))
|
||||
for _, eventID := range command.EventIDs {
|
||||
if eventID == 0 {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if _, duplicate := seen[eventID]; duplicate {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
seen[eventID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the bounded audit pagination request.
|
||||
func (query AuditQuery) Validate() error {
|
||||
if query.Limit <= 0 || query.Limit > MaxPageSize {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMutationBase(requestID string, actor Actor, occurredAt time.Time) error {
|
||||
if !validRequiredText(requestID, MaxIdentifierBytes) || !validRequiredText(actor.ID, MaxActorIDBytes) ||
|
||||
occurredAt.IsZero() {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
if actor.SourceIP != "" {
|
||||
address, err := netip.ParseAddr(actor.SourceIP)
|
||||
if err != nil || !address.IsValid() {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validIdentifier(value string) bool {
|
||||
return len(value) <= MaxIdentifierBytes && identifierPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != SHA256HexBytes {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == SHA256HexBytes/2
|
||||
}
|
||||
|
||||
func validRequiredText(value string, maximum int) bool {
|
||||
return value != "" && validOptionalText(value, maximum)
|
||||
}
|
||||
|
||||
func validOptionalText(value string, maximum int) bool {
|
||||
if len(value) > maximum || !utf8.ValidString(value) || strings.TrimSpace(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneCommitConfigCommand(command CommitConfigCommand) CommitConfigCommand {
|
||||
cloned := command
|
||||
cloned.Upstreams = append([]UpstreamDefinition(nil), command.Upstreams...)
|
||||
cloned.Routings = make([]RoutingDefinition, len(command.Routings))
|
||||
for index, routing := range command.Routings {
|
||||
cloned.Routings[index] = routing
|
||||
cloned.Routings[index].Upstreams = append([]string(nil), routing.Upstreams...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneSnapshot(snapshot Snapshot) Snapshot {
|
||||
cloned := snapshot
|
||||
if snapshot.Config != nil {
|
||||
config := *snapshot.Config
|
||||
cloned.Config = &config
|
||||
}
|
||||
cloned.Upstreams = append([]UpstreamState(nil), snapshot.Upstreams...)
|
||||
cloned.Routings = make([]RoutingState, len(snapshot.Routings))
|
||||
for index, routing := range snapshot.Routings {
|
||||
cloned.Routings[index] = routing
|
||||
cloned.Routings[index].Upstreams = append([]string(nil), routing.Upstreams...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneEvent(event Event) Event {
|
||||
cloned := event
|
||||
cloned.Payload = append(json.RawMessage(nil), event.Payload...)
|
||||
if event.ClaimUntil != nil {
|
||||
value := *event.ClaimUntil
|
||||
cloned.ClaimUntil = &value
|
||||
}
|
||||
if event.PublishedAt != nil {
|
||||
value := *event.PublishedAt
|
||||
cloned.PublishedAt = &value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneEvents(events []Event) []Event {
|
||||
cloned := make([]Event, len(events))
|
||||
for index, event := range events {
|
||||
cloned[index] = cloneEvent(event)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneAuditRecords(records []AuditRecord) []AuditRecord {
|
||||
return append([]AuditRecord(nil), records...)
|
||||
}
|
||||
15
internal/domain/adminstate/contract_external_test.go
Normal file
15
internal/domain/adminstate/contract_external_test.go
Normal file
@ -0,0 +1,15 @@
|
||||
package adminstate_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
"proxy-pool/internal/domain/adminstate/contracttest"
|
||||
)
|
||||
|
||||
func TestMemoryStoreContract(t *testing.T) {
|
||||
contracttest.Run(t, func(t *testing.T) adminstate.Store {
|
||||
t.Helper()
|
||||
return adminstate.NewMemoryStore()
|
||||
})
|
||||
}
|
||||
413
internal/domain/adminstate/contracttest/contract.go
Normal file
413
internal/domain/adminstate/contracttest/contract.go
Normal file
@ -0,0 +1,413 @@
|
||||
package contracttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/domain/adminstate"
|
||||
)
|
||||
|
||||
type Factory func(*testing.T) adminstate.Store
|
||||
|
||||
func Run(t *testing.T, factory Factory) {
|
||||
t.Helper()
|
||||
t.Run("config transaction and immutability", func(t *testing.T) {
|
||||
runConfigContract(t, factory(t))
|
||||
})
|
||||
t.Run("upstream idempotency", func(t *testing.T) {
|
||||
runUpstreamContract(t, factory(t))
|
||||
})
|
||||
t.Run("routing compare and swap", func(t *testing.T) {
|
||||
runRoutingContract(t, factory(t))
|
||||
})
|
||||
t.Run("routing no-op audit", func(t *testing.T) {
|
||||
runRoutingNoOpContract(t, factory(t))
|
||||
})
|
||||
t.Run("audit pagination and fields", func(t *testing.T) {
|
||||
runAuditContract(t, factory(t))
|
||||
})
|
||||
t.Run("outbox lease and acknowledgement", func(t *testing.T) {
|
||||
runOutboxContract(t, factory(t))
|
||||
})
|
||||
t.Run("outbox acknowledgement is atomic", func(t *testing.T) {
|
||||
runAtomicAcknowledgeContract(t, factory(t))
|
||||
})
|
||||
t.Run("context cancellation", func(t *testing.T) {
|
||||
runContextContract(t, factory(t))
|
||||
})
|
||||
}
|
||||
|
||||
func runConfigContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
command := configCommand("req-config", "cfg-1", strings.Repeat("ab", adminstate.SHA256HexBytes/2))
|
||||
result, err := store.CommitConfig(context.Background(), command)
|
||||
if err != nil || !result.Changed || result.Revision != 1 || result.RequestID != command.RequestID {
|
||||
t.Fatalf("CommitConfig(first) = %+v, %v", result, err)
|
||||
}
|
||||
|
||||
snapshot, err := store.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Revision != 1 || snapshot.Config == nil ||
|
||||
snapshot.Config.ConfigVersion != "cfg-1" || len(snapshot.Upstreams) != 2 || len(snapshot.Routings) != 1 {
|
||||
t.Fatalf("Snapshot() = %+v, %v", snapshot, err)
|
||||
}
|
||||
snapshot.Config.ConfigVersion = "mutated"
|
||||
snapshot.Upstreams[0].Name = "mutated"
|
||||
snapshot.Routings[0].Upstreams[0] = "mutated"
|
||||
again, err := store.Snapshot(context.Background())
|
||||
if err != nil || again.Config.ConfigVersion != "cfg-1" ||
|
||||
again.Upstreams[0].Name == "mutated" || again.Routings[0].Upstreams[0] == "mutated" {
|
||||
t.Fatalf("Snapshot() leaked mutable state: %+v, %v", again, err)
|
||||
}
|
||||
|
||||
command.RequestID = "req-config-replay"
|
||||
command.OccurredAt = command.OccurredAt.Add(time.Second)
|
||||
command.Checksum = strings.ToUpper(command.Checksum)
|
||||
replayed, err := store.CommitConfig(context.Background(), command)
|
||||
if err != nil || replayed.Changed || replayed.Revision != 1 {
|
||||
t.Fatalf("CommitConfig(replay) = %+v, %v", replayed, err)
|
||||
}
|
||||
audits, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||
if err != nil || len(audits) != 2 || !audits[0].Changed || audits[1].Changed ||
|
||||
audits[0].Revision != 1 || audits[1].Revision != 1 {
|
||||
t.Fatalf("ReadAudit() = %+v, %v", audits, err)
|
||||
}
|
||||
|
||||
conflict := command
|
||||
conflict.RequestID = "req-config-conflict"
|
||||
conflict.Checksum = strings.Repeat("b", adminstate.SHA256HexBytes)
|
||||
if _, err := store.CommitConfig(context.Background(), conflict); !errors.Is(err, adminstate.ErrConflict) {
|
||||
t.Fatalf("CommitConfig(conflict) error = %v", err)
|
||||
}
|
||||
audits, err = store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||
if err != nil || len(audits) != 2 {
|
||||
t.Fatalf("ReadAudit(after conflict) = %+v, %v", audits, err)
|
||||
}
|
||||
|
||||
invalid := command
|
||||
invalid.RequestID = "req-config-invalid"
|
||||
invalid.Routings[0].CurrentUpstream = "missing"
|
||||
if _, err := store.CommitConfig(context.Background(), invalid); !errors.Is(err, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("CommitConfig(invalid) error = %v", err)
|
||||
}
|
||||
final, err := store.Snapshot(context.Background())
|
||||
if err != nil || final.Revision != 1 || final.Config.ConfigVersion != "cfg-1" {
|
||||
t.Fatalf("Snapshot(after invalid) = %+v, %v", final, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runUpstreamContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
|
||||
result, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-disable", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil || !result.Changed || result.Revision != 2 {
|
||||
t.Fatalf("SetUpstreamEnabled(disable) = %+v, %v", result, err)
|
||||
}
|
||||
replayed, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-disable-replay", Actor: contractActor(), OccurredAt: now.Add(2 * time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil || replayed.Changed || replayed.Revision != 2 {
|
||||
t.Fatalf("SetUpstreamEnabled(replay) = %+v, %v", replayed, err)
|
||||
}
|
||||
if _, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-missing", Actor: contractActor(), OccurredAt: now.Add(3 * time.Second),
|
||||
Name: "missing", Enabled: true,
|
||||
}); !errors.Is(err, adminstate.ErrNotFound) {
|
||||
t.Fatalf("SetUpstreamEnabled(missing) error = %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := store.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Revision != 2 || upstreamEnabled(snapshot, "provider-a") {
|
||||
t.Fatalf("Snapshot() = %+v, %v", snapshot, err)
|
||||
}
|
||||
audits, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||
if err != nil || len(audits) != 3 || !audits[1].Changed || audits[2].Changed {
|
||||
t.Fatalf("ReadAudit() = %+v, %v", audits, err)
|
||||
}
|
||||
events, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(4 * time.Second), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(events) != 2 || events[0].Revision != 1 || events[1].Revision != 2 {
|
||||
t.Fatalf("Claim() = %+v, %v", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runRoutingContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
|
||||
if _, err := store.SwitchRouting(context.Background(), adminstate.SwitchRoutingCommand{
|
||||
RequestID: "req-bad-target", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-c",
|
||||
}); !errors.Is(err, adminstate.ErrInvalidCommand) {
|
||||
t.Fatalf("SwitchRouting(bad target) error = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var changed atomic.Int64
|
||||
var conflicts atomic.Int64
|
||||
var unexpectedMu sync.Mutex
|
||||
var unexpected []error
|
||||
var workers sync.WaitGroup
|
||||
for index := range 100 {
|
||||
workers.Add(1)
|
||||
go func(index int) {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
result, err := store.SwitchRouting(context.Background(), adminstate.SwitchRoutingCommand{
|
||||
RequestID: fmt.Sprintf("req-switch-%03d", index), Actor: contractActor(),
|
||||
OccurredAt: now.Add(2 * time.Second), Name: "checkout",
|
||||
ExpectedCurrent: "provider-a", Target: "provider-b", Reason: "capacity",
|
||||
})
|
||||
switch {
|
||||
case err == nil && result.Changed:
|
||||
changed.Add(1)
|
||||
case errors.Is(err, adminstate.ErrConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
unexpectedMu.Lock()
|
||||
unexpected = append(unexpected, err)
|
||||
unexpectedMu.Unlock()
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
if changed.Load() != 1 || conflicts.Load() != 99 || len(unexpected) != 0 {
|
||||
t.Fatalf("concurrent switch changed=%d conflicts=%d unexpected=%v", changed.Load(), conflicts.Load(), unexpected)
|
||||
}
|
||||
snapshot, err := store.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Revision != 2 || snapshot.Routings[0].CurrentUpstream != "provider-b" {
|
||||
t.Fatalf("Snapshot() = %+v, %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runRoutingNoOpContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
|
||||
result, err := store.SwitchRouting(context.Background(), adminstate.SwitchRoutingCommand{
|
||||
RequestID: "req-switch-noop", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-a", Reason: "already selected",
|
||||
})
|
||||
if err != nil || result.Changed || result.Revision != 1 {
|
||||
t.Fatalf("SwitchRouting(no-op) = %+v, %v", result, err)
|
||||
}
|
||||
audits, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||
if err != nil || len(audits) != 2 || audits[1].Action != adminstate.ActionSwitchRoute ||
|
||||
audits[1].Changed || audits[1].Revision != 1 || audits[1].Reason != "already selected" {
|
||||
t.Fatalf("ReadAudit(no-op) = %+v, %v", audits, err)
|
||||
}
|
||||
events, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(2 * time.Second), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(events) != 1 || events[0].Type != "config.committed" {
|
||||
t.Fatalf("Claim(after no-op) = %+v, %v", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runAuditContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
_, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-disable", Actor: adminstate.Actor{ID: "admin-b", SourceIP: "::ffff:192.0.2.11"},
|
||||
OccurredAt: now.Add(time.Second), Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetUpstreamEnabled(): %v", err)
|
||||
}
|
||||
|
||||
first, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 1})
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("ReadAudit(first page) = %+v, %v", first, err)
|
||||
}
|
||||
if first[0].ID == 0 || first[0].RequestID != "req-config" || first[0].Actor != contractActor() ||
|
||||
first[0].Action != adminstate.ActionCommitConfig || first[0].ResourceType != "config" ||
|
||||
first[0].ResourceName != "cfg-1" || !first[0].Changed || first[0].Revision != 1 ||
|
||||
!first[0].OccurredAt.Equal(now) {
|
||||
t.Fatalf("first audit record = %+v", first[0])
|
||||
}
|
||||
second, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{AfterID: first[0].ID, Limit: 1})
|
||||
if err != nil || len(second) != 1 || second[0].ID <= first[0].ID || second[0].RequestID != "req-disable" ||
|
||||
second[0].Actor.ID != "admin-b" || second[0].Actor.SourceIP != "192.0.2.11" ||
|
||||
second[0].Action != adminstate.ActionSetUpstream || second[0].ResourceType != "upstream" ||
|
||||
second[0].ResourceName != "provider-a" || !second[0].Changed || second[0].Revision != 2 ||
|
||||
!second[0].OccurredAt.Equal(now.Add(time.Second)) {
|
||||
t.Fatalf("ReadAudit(second page) = %+v, %v", second, err)
|
||||
}
|
||||
empty, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{AfterID: second[0].ID, Limit: 1})
|
||||
if err != nil || len(empty) != 0 {
|
||||
t.Fatalf("ReadAudit(after end) = %+v, %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runOutboxContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
result, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-disable", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
})
|
||||
if err != nil || !result.Changed {
|
||||
t.Fatalf("SetUpstreamEnabled() = %+v, %v", result, err)
|
||||
}
|
||||
|
||||
first, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(2 * time.Second), Limit: 1, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(first) != 1 || first[0].ID != 1 {
|
||||
t.Fatalf("Claim(first) = %+v, %v", first, err)
|
||||
}
|
||||
first[0].Payload[0] = 'X'
|
||||
second, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-b", Now: now.Add(2 * time.Second), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(second) != 1 || second[0].ID != 2 {
|
||||
t.Fatalf("Claim(second) = %+v, %v", second, err)
|
||||
}
|
||||
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||
ConsumerID: "publisher-b", Now: now.Add(3 * time.Second), EventIDs: []uint64{1},
|
||||
}); !errors.Is(err, adminstate.ErrConflict) {
|
||||
t.Fatalf("Acknowledge(wrong owner) error = %v", err)
|
||||
}
|
||||
|
||||
reclaimed, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-b", Now: now.Add(2*time.Minute + time.Second), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(reclaimed) != 2 || reclaimed[0].ID != 1 || reclaimed[1].ID != 2 || reclaimed[0].Payload[0] == 'X' {
|
||||
t.Fatalf("Claim(reclaimed) = %+v, %v", reclaimed, err)
|
||||
}
|
||||
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||
ConsumerID: "publisher-b", Now: now.Add(2*time.Minute + 2*time.Second), EventIDs: []uint64{1, 2},
|
||||
}); err != nil {
|
||||
t.Fatalf("Acknowledge(valid): %v", err)
|
||||
}
|
||||
empty, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-c", Now: now.Add(4 * time.Minute), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(empty) != 0 {
|
||||
t.Fatalf("Claim(after ACK) = %+v, %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runAtomicAcknowledgeContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
now := contractNow()
|
||||
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||
if _, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-disable", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||
Name: "provider-a", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetUpstreamEnabled(): %v", err)
|
||||
}
|
||||
events, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(2 * time.Second), Limit: 10, Lease: time.Minute,
|
||||
})
|
||||
if err != nil || len(events) != 2 {
|
||||
t.Fatalf("Claim() = %+v, %v", events, err)
|
||||
}
|
||||
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(3 * time.Second), EventIDs: []uint64{events[0].ID, events[1].ID + 1000},
|
||||
}); !errors.Is(err, adminstate.ErrNotFound) {
|
||||
t.Fatalf("Acknowledge(partially invalid) error = %v", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||
ConsumerID: "publisher-a", Now: now.Add(4 * time.Second), EventIDs: []uint64{event.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("Acknowledge(%d) after failed batch: %v", event.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runContextContract(t *testing.T, store adminstate.Store) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
command := configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes))
|
||||
if _, err := store.CommitConfig(ctx, command); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("CommitConfig(canceled) error = %v", err)
|
||||
}
|
||||
if _, err := store.SetUpstreamEnabled(ctx, adminstate.SetUpstreamCommand{
|
||||
RequestID: "req-upstream", Actor: contractActor(), OccurredAt: contractNow(),
|
||||
Name: "provider-a", Enabled: false,
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("SetUpstreamEnabled(canceled) error = %v", err)
|
||||
}
|
||||
if _, err := store.SwitchRouting(ctx, adminstate.SwitchRoutingCommand{
|
||||
RequestID: "req-switch", Actor: contractActor(), OccurredAt: contractNow(),
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b",
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("SwitchRouting(canceled) error = %v", err)
|
||||
}
|
||||
if _, err := store.Snapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Snapshot(canceled) error = %v", err)
|
||||
}
|
||||
if _, err := store.ReadAudit(ctx, adminstate.AuditQuery{Limit: 1}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ReadAudit(canceled) error = %v", err)
|
||||
}
|
||||
if _, err := store.Claim(ctx, adminstate.ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: contractNow(), Limit: 1, Lease: time.Second,
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Claim(canceled) error = %v", err)
|
||||
}
|
||||
if err := store.Acknowledge(ctx, adminstate.AcknowledgeCommand{
|
||||
ConsumerID: "publisher-a", Now: contractNow(), EventIDs: []uint64{1},
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Acknowledge(canceled) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func configCommand(requestID, version, checksum string) adminstate.CommitConfigCommand {
|
||||
return adminstate.CommitConfigCommand{
|
||||
RequestID: requestID, Actor: contractActor(), OccurredAt: contractNow(),
|
||||
ConfigVersion: version, Checksum: checksum, Source: "configs/proxy-pool.yaml",
|
||||
Upstreams: []adminstate.UpstreamDefinition{
|
||||
{Name: "provider-a", Enabled: true},
|
||||
{Name: "provider-b", Enabled: true},
|
||||
},
|
||||
Routings: []adminstate.RoutingDefinition{{
|
||||
Name: "checkout", Enabled: true, Upstreams: []string{"provider-a", "provider-b"},
|
||||
CurrentUpstream: "provider-a",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func contractActor() adminstate.Actor {
|
||||
return adminstate.Actor{ID: "admin-a", SourceIP: "192.0.2.10"}
|
||||
}
|
||||
|
||||
func contractNow() time.Time {
|
||||
return time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func commit(t *testing.T, store adminstate.Store, command adminstate.CommitConfigCommand) {
|
||||
t.Helper()
|
||||
if result, err := store.CommitConfig(context.Background(), command); err != nil || !result.Changed {
|
||||
t.Fatalf("CommitConfig() = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamEnabled(snapshot adminstate.Snapshot, name string) bool {
|
||||
for _, upstream := range snapshot.Upstreams {
|
||||
if upstream.Name == name {
|
||||
return upstream.Enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
414
internal/domain/adminstate/memory.go
Normal file
414
internal/domain/adminstate/memory.go
Normal file
@ -0,0 +1,414 @@
|
||||
package adminstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ Store = (*MemoryStore)(nil)
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
revision uint64
|
||||
config *ConfigRevision
|
||||
configChecksums map[string]string
|
||||
upstreams map[string]UpstreamState
|
||||
routings map[string]RoutingState
|
||||
audits []AuditRecord
|
||||
events []Event
|
||||
nextAuditID uint64
|
||||
nextEventID uint64
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
configChecksums: make(map[string]string),
|
||||
upstreams: make(map[string]UpstreamState),
|
||||
routings: make(map[string]RoutingState),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MemoryStore) CommitConfig(ctx context.Context, command CommitConfigCommand) (MutationResult, error) {
|
||||
result := MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if store == nil || command.Validate() != nil {
|
||||
return result, ErrInvalidCommand
|
||||
}
|
||||
command = cloneCommitConfigCommand(command)
|
||||
command.Checksum = strings.ToLower(command.Checksum)
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if checksum, exists := store.configChecksums[command.ConfigVersion]; exists {
|
||||
if checksum != command.Checksum || store.config == nil || store.config.ConfigVersion != command.ConfigVersion {
|
||||
return result, ErrConflict
|
||||
}
|
||||
result.Revision = store.revision
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionCommitConfig, "config",
|
||||
command.ConfigVersion, false, store.revision, "", command.OccurredAt)
|
||||
return result, nil
|
||||
}
|
||||
if store.revision == math.MaxUint64 {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
|
||||
nextRevision := store.revision + 1
|
||||
nextConfig := &ConfigRevision{
|
||||
Revision: nextRevision, ConfigVersion: command.ConfigVersion, Checksum: command.Checksum,
|
||||
Source: command.Source, CreatedAt: command.OccurredAt.UTC(),
|
||||
}
|
||||
nextUpstreams := make(map[string]UpstreamState, len(command.Upstreams))
|
||||
for _, definition := range command.Upstreams {
|
||||
nextUpstreams[definition.Name] = UpstreamState{
|
||||
Name: definition.Name, Enabled: definition.Enabled, Revision: nextRevision,
|
||||
UpdatedAt: command.OccurredAt.UTC(),
|
||||
}
|
||||
}
|
||||
nextRoutings := make(map[string]RoutingState, len(command.Routings))
|
||||
for _, definition := range command.Routings {
|
||||
nextRoutings[definition.Name] = RoutingState{
|
||||
Name: definition.Name, Enabled: definition.Enabled,
|
||||
Upstreams: append([]string(nil), definition.Upstreams...), CurrentUpstream: definition.CurrentUpstream,
|
||||
Revision: nextRevision, UpdatedAt: command.OccurredAt.UTC(),
|
||||
}
|
||||
}
|
||||
payload, err := encodeEventPayload(map[string]any{
|
||||
"configVersion": command.ConfigVersion,
|
||||
"checksum": command.Checksum,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
|
||||
store.revision = nextRevision
|
||||
store.config = nextConfig
|
||||
store.configChecksums[command.ConfigVersion] = command.Checksum
|
||||
store.upstreams = nextUpstreams
|
||||
store.routings = nextRoutings
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionCommitConfig, "config",
|
||||
command.ConfigVersion, true, nextRevision, "", command.OccurredAt)
|
||||
store.appendEventLocked(nextRevision, "config.committed", "config", command.ConfigVersion,
|
||||
payload, command.OccurredAt)
|
||||
return MutationResult{RequestID: command.RequestID, Changed: true, Revision: nextRevision}, nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) SetUpstreamEnabled(ctx context.Context, command SetUpstreamCommand) (MutationResult, error) {
|
||||
result := MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if store == nil || command.Validate() != nil {
|
||||
return result, ErrInvalidCommand
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
state, exists := store.upstreams[command.Name]
|
||||
if !exists {
|
||||
return result, ErrNotFound
|
||||
}
|
||||
if state.Enabled == command.Enabled {
|
||||
result.Revision = store.revision
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionSetUpstream, "upstream",
|
||||
command.Name, false, store.revision, "", command.OccurredAt)
|
||||
return result, nil
|
||||
}
|
||||
if store.revision == math.MaxUint64 {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
|
||||
nextRevision := store.revision + 1
|
||||
payload, err := encodeEventPayload(map[string]any{
|
||||
"enabled": command.Enabled,
|
||||
"name": command.Name,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
state.Enabled = command.Enabled
|
||||
state.Revision = nextRevision
|
||||
state.UpdatedAt = command.OccurredAt.UTC()
|
||||
store.revision = nextRevision
|
||||
store.upstreams[command.Name] = state
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionSetUpstream, "upstream",
|
||||
command.Name, true, nextRevision, "", command.OccurredAt)
|
||||
store.appendEventLocked(nextRevision, "upstream.enabled_changed", "upstream", command.Name,
|
||||
payload, command.OccurredAt)
|
||||
return MutationResult{RequestID: command.RequestID, Changed: true, Revision: nextRevision}, nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) SwitchRouting(ctx context.Context, command SwitchRoutingCommand) (MutationResult, error) {
|
||||
result := MutationResult{RequestID: command.RequestID}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if store == nil || command.Validate() != nil {
|
||||
return result, ErrInvalidCommand
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
state, exists := store.routings[command.Name]
|
||||
if !exists {
|
||||
return result, ErrNotFound
|
||||
}
|
||||
if !state.Enabled || state.CurrentUpstream != command.ExpectedCurrent {
|
||||
return result, ErrConflict
|
||||
}
|
||||
if !contains(state.Upstreams, command.Target) {
|
||||
return result, ErrInvalidCommand
|
||||
}
|
||||
if state.CurrentUpstream == command.Target {
|
||||
result.Revision = store.revision
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionSwitchRoute, "routing",
|
||||
command.Name, false, store.revision, command.Reason, command.OccurredAt)
|
||||
return result, nil
|
||||
}
|
||||
if store.revision == math.MaxUint64 {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
|
||||
nextRevision := store.revision + 1
|
||||
payload, err := encodeEventPayload(map[string]any{
|
||||
"current": command.Target,
|
||||
"name": command.Name,
|
||||
"previous": command.ExpectedCurrent,
|
||||
"reason": command.Reason,
|
||||
"revision": nextRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return result, ErrUnavailable
|
||||
}
|
||||
state.CurrentUpstream = command.Target
|
||||
state.Revision = nextRevision
|
||||
state.UpdatedAt = command.OccurredAt.UTC()
|
||||
store.revision = nextRevision
|
||||
store.routings[command.Name] = state
|
||||
store.appendAuditLocked(command.RequestID, command.Actor, ActionSwitchRoute, "routing",
|
||||
command.Name, true, nextRevision, command.Reason, command.OccurredAt)
|
||||
store.appendEventLocked(nextRevision, "routing.switched", "routing", command.Name,
|
||||
payload, command.OccurredAt)
|
||||
return MutationResult{RequestID: command.RequestID, Changed: true, Revision: nextRevision}, nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if store == nil {
|
||||
return Snapshot{}, ErrInvalidCommand
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
snapshot := Snapshot{Revision: store.revision}
|
||||
if store.config != nil {
|
||||
config := *store.config
|
||||
snapshot.Config = &config
|
||||
}
|
||||
for _, upstream := range store.upstreams {
|
||||
snapshot.Upstreams = append(snapshot.Upstreams, upstream)
|
||||
}
|
||||
for _, routing := range store.routings {
|
||||
routing.Upstreams = append([]string(nil), routing.Upstreams...)
|
||||
snapshot.Routings = append(snapshot.Routings, routing)
|
||||
}
|
||||
sort.Slice(snapshot.Upstreams, func(left, right int) bool {
|
||||
return snapshot.Upstreams[left].Name < snapshot.Upstreams[right].Name
|
||||
})
|
||||
sort.Slice(snapshot.Routings, func(left, right int) bool {
|
||||
return snapshot.Routings[left].Name < snapshot.Routings[right].Name
|
||||
})
|
||||
return cloneSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) ReadAudit(ctx context.Context, query AuditQuery) ([]AuditRecord, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store == nil || query.Validate() != nil {
|
||||
return nil, ErrInvalidCommand
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]AuditRecord, 0, query.Limit)
|
||||
for _, record := range store.audits {
|
||||
if record.ID <= query.AfterID {
|
||||
continue
|
||||
}
|
||||
result = append(result, record)
|
||||
if len(result) == query.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return cloneAuditRecords(result), nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) Claim(ctx context.Context, command ClaimCommand) ([]Event, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store == nil || command.Validate() != nil {
|
||||
return nil, ErrInvalidCommand
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claimUntil := command.Now.UTC().Add(command.Lease)
|
||||
result := make([]Event, 0, command.Limit)
|
||||
for index := range store.events {
|
||||
event := &store.events[index]
|
||||
if event.PublishedAt != nil || (event.ClaimUntil != nil && command.Now.Before(*event.ClaimUntil)) {
|
||||
continue
|
||||
}
|
||||
event.ClaimedBy = command.ConsumerID
|
||||
event.ClaimUntil = &claimUntil
|
||||
result = append(result, cloneEvent(*event))
|
||||
if len(result) == command.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) Acknowledge(ctx context.Context, command AcknowledgeCommand) error {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if store == nil || command.Validate() != nil {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexes := make([]int, 0, len(command.EventIDs))
|
||||
for _, eventID := range command.EventIDs {
|
||||
index := store.eventIndexLocked(eventID)
|
||||
if index < 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
event := store.events[index]
|
||||
if event.PublishedAt != nil || event.ClaimedBy != command.ConsumerID || event.ClaimUntil == nil ||
|
||||
!command.Now.Before(*event.ClaimUntil) {
|
||||
return ErrConflict
|
||||
}
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
publishedAt := command.Now.UTC()
|
||||
for _, index := range indexes {
|
||||
store.events[index].PublishedAt = &publishedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MemoryStore) appendAuditLocked(
|
||||
requestID string,
|
||||
actor Actor,
|
||||
action Action,
|
||||
resourceType string,
|
||||
resourceName string,
|
||||
changed bool,
|
||||
revision uint64,
|
||||
reason string,
|
||||
occurredAt time.Time,
|
||||
) {
|
||||
store.nextAuditID++
|
||||
actor = canonicalActor(actor)
|
||||
store.audits = append(store.audits, AuditRecord{
|
||||
ID: store.nextAuditID, RequestID: requestID, Actor: actor, Action: action,
|
||||
ResourceType: resourceType, ResourceName: resourceName, Changed: changed,
|
||||
Revision: revision, Reason: reason, OccurredAt: occurredAt.UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (store *MemoryStore) appendEventLocked(
|
||||
revision uint64,
|
||||
eventType string,
|
||||
aggregateType string,
|
||||
aggregateID string,
|
||||
payload json.RawMessage,
|
||||
occurredAt time.Time,
|
||||
) {
|
||||
store.nextEventID++
|
||||
store.events = append(store.events, Event{
|
||||
ID: store.nextEventID, Revision: revision, Type: eventType,
|
||||
AggregateType: aggregateType, AggregateID: aggregateID,
|
||||
Payload: append(json.RawMessage(nil), payload...), OccurredAt: occurredAt.UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (store *MemoryStore) eventIndexLocked(eventID uint64) int {
|
||||
index := sort.Search(len(store.events), func(index int) bool {
|
||||
return store.events[index].ID >= eventID
|
||||
})
|
||||
if index >= len(store.events) || store.events[index].ID != eventID {
|
||||
return -1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func contextError(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return ErrInvalidCommand
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func encodeEventPayload(value any) (json.RawMessage, error) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func canonicalActor(actor Actor) Actor {
|
||||
if actor.SourceIP == "" {
|
||||
return actor
|
||||
}
|
||||
address, err := netip.ParseAddr(actor.SourceIP)
|
||||
if err == nil {
|
||||
actor.SourceIP = address.Unmap().String()
|
||||
}
|
||||
return actor
|
||||
}
|
||||
211
internal/domain/adminstate/validation_test.go
Normal file
211
internal/domain/adminstate/validation_test.go
Normal file
@ -0,0 +1,211 @@
|
||||
package adminstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateSetUpstreamCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
valid := SetUpstreamCommand{
|
||||
RequestID: "req-upstream", Actor: Actor{ID: "admin-a", SourceIP: "192.0.2.10"},
|
||||
OccurredAt: now, Name: "provider-a", Enabled: true,
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("SetUpstreamCommand.Validate(valid): %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*SetUpstreamCommand)
|
||||
}{
|
||||
{name: "missing request", mutate: func(command *SetUpstreamCommand) { command.RequestID = "" }},
|
||||
{name: "missing actor", mutate: func(command *SetUpstreamCommand) { command.Actor.ID = "" }},
|
||||
{name: "invalid source", mutate: func(command *SetUpstreamCommand) { command.Actor.SourceIP = "not-an-ip" }},
|
||||
{name: "zero time", mutate: func(command *SetUpstreamCommand) { command.OccurredAt = time.Time{} }},
|
||||
{name: "invalid name", mutate: func(command *SetUpstreamCommand) { command.Name = "provider/a" }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
command := valid
|
||||
test.mutate(&command)
|
||||
if err := command.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("SetUpstreamCommand.Validate() error = %v, want ErrInvalidCommand", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSwitchRoutingCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
valid := SwitchRoutingCommand{
|
||||
RequestID: "req-switch", Actor: Actor{ID: "admin-a"}, OccurredAt: now,
|
||||
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b", Reason: "capacity",
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("SwitchRoutingCommand.Validate(valid): %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*SwitchRoutingCommand)
|
||||
}{
|
||||
{name: "missing routing", mutate: func(command *SwitchRoutingCommand) { command.Name = "" }},
|
||||
{name: "missing expected", mutate: func(command *SwitchRoutingCommand) { command.ExpectedCurrent = "" }},
|
||||
{name: "missing target", mutate: func(command *SwitchRoutingCommand) { command.Target = "" }},
|
||||
{name: "reason too long", mutate: func(command *SwitchRoutingCommand) { command.Reason = strings.Repeat("r", MaxReasonBytes+1) }},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
command := valid
|
||||
test.mutate(&command)
|
||||
if err := command.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("SwitchRoutingCommand.Validate() error = %v, want ErrInvalidCommand", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommitConfigCommandAndReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := validCommitConfigCommand()
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("CommitConfigCommand.Validate(valid): %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommitConfigCommand)
|
||||
}{
|
||||
{name: "invalid checksum", mutate: func(command *CommitConfigCommand) { command.Checksum = "sha256:bad" }},
|
||||
{name: "duplicate upstream", mutate: func(command *CommitConfigCommand) {
|
||||
command.Upstreams = append(command.Upstreams, command.Upstreams[0])
|
||||
}},
|
||||
{name: "duplicate routing", mutate: func(command *CommitConfigCommand) { command.Routings = append(command.Routings, command.Routings[0]) }},
|
||||
{name: "duplicate candidate", mutate: func(command *CommitConfigCommand) {
|
||||
command.Routings[0].Upstreams = append(command.Routings[0].Upstreams, "provider-a")
|
||||
}},
|
||||
{name: "unknown candidate", mutate: func(command *CommitConfigCommand) { command.Routings[0].Upstreams[0] = "provider-missing" }},
|
||||
{name: "current not candidate", mutate: func(command *CommitConfigCommand) { command.Routings[0].CurrentUpstream = "provider-c" }},
|
||||
{name: "empty candidate list", mutate: func(command *CommitConfigCommand) { command.Routings[0].Upstreams = nil }},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
command := cloneCommitConfigCommand(valid)
|
||||
test.mutate(&command)
|
||||
if err := command.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("CommitConfigCommand.Validate() error = %v, want ErrInvalidCommand", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboxCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
if err := (ClaimCommand{
|
||||
ConsumerID: "publisher-a", Now: now, Limit: 100, Lease: time.Minute,
|
||||
}).Validate(); err != nil {
|
||||
t.Fatalf("ClaimCommand.Validate(valid): %v", err)
|
||||
}
|
||||
if err := (AcknowledgeCommand{
|
||||
ConsumerID: "publisher-a", Now: now, EventIDs: []uint64{1, 2},
|
||||
}).Validate(); err != nil {
|
||||
t.Fatalf("AcknowledgeCommand.Validate(valid): %v", err)
|
||||
}
|
||||
|
||||
invalidClaims := []ClaimCommand{
|
||||
{Now: now, Limit: 1, Lease: time.Second},
|
||||
{ConsumerID: "publisher-a", Limit: 1, Lease: time.Second},
|
||||
{ConsumerID: "publisher-a", Now: now, Lease: time.Second},
|
||||
{ConsumerID: "publisher-a", Now: now, Limit: MaxPageSize + 1, Lease: time.Second},
|
||||
{ConsumerID: "publisher-a", Now: now, Limit: 1},
|
||||
}
|
||||
for _, command := range invalidClaims {
|
||||
if err := command.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("ClaimCommand.Validate(%+v) error = %v", command, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalidAcks := []AcknowledgeCommand{
|
||||
{Now: now, EventIDs: []uint64{1}},
|
||||
{ConsumerID: "publisher-a", EventIDs: []uint64{1}},
|
||||
{ConsumerID: "publisher-a", Now: now},
|
||||
{ConsumerID: "publisher-a", Now: now, EventIDs: []uint64{0}},
|
||||
{ConsumerID: "publisher-a", Now: now, EventIDs: []uint64{1, 1}},
|
||||
}
|
||||
for _, command := range invalidAcks {
|
||||
if err := command.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("AcknowledgeCommand.Validate(%+v) error = %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := (AuditQuery{Limit: 10}).Validate(); err != nil {
|
||||
t.Fatalf("AuditQuery.Validate(valid): %v", err)
|
||||
}
|
||||
for _, query := range []AuditQuery{{}, {Limit: -1}, {Limit: MaxPageSize + 1}} {
|
||||
if err := query.Validate(); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("AuditQuery.Validate(%+v) error = %v, want ErrInvalidCommand", query, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneValuesDoNotShareMutableState(t *testing.T) {
|
||||
t.Parallel()
|
||||
command := validCommitConfigCommand()
|
||||
clonedCommand := cloneCommitConfigCommand(command)
|
||||
clonedCommand.Upstreams[0].Name = "changed"
|
||||
clonedCommand.Routings[0].Upstreams[0] = "changed"
|
||||
if command.Upstreams[0].Name != "provider-a" || command.Routings[0].Upstreams[0] != "provider-a" {
|
||||
t.Fatalf("cloneCommitConfigCommand shared input storage: %+v", command)
|
||||
}
|
||||
|
||||
snapshot := Snapshot{
|
||||
Revision: 3,
|
||||
Config: &ConfigRevision{Revision: 3, ConfigVersion: "cfg-3"},
|
||||
Upstreams: []UpstreamState{{Name: "provider-a", Enabled: true}},
|
||||
Routings: []RoutingState{{Name: "checkout", Upstreams: []string{"provider-a"}, CurrentUpstream: "provider-a"}},
|
||||
}
|
||||
clonedSnapshot := cloneSnapshot(snapshot)
|
||||
clonedSnapshot.Config.ConfigVersion = "changed"
|
||||
clonedSnapshot.Upstreams[0].Name = "changed"
|
||||
clonedSnapshot.Routings[0].Upstreams[0] = "changed"
|
||||
if snapshot.Config.ConfigVersion != "cfg-3" || snapshot.Upstreams[0].Name != "provider-a" ||
|
||||
snapshot.Routings[0].Upstreams[0] != "provider-a" {
|
||||
t.Fatalf("cloneSnapshot shared input storage: %+v", snapshot)
|
||||
}
|
||||
|
||||
event := Event{Payload: json.RawMessage(`{"enabled":true}`)}
|
||||
clonedEvent := cloneEvent(event)
|
||||
clonedEvent.Payload[2] = 'X'
|
||||
if string(event.Payload) != `{"enabled":true}` {
|
||||
t.Fatalf("cloneEvent shared payload storage: %s", event.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func validCommitConfigCommand() CommitConfigCommand {
|
||||
return CommitConfigCommand{
|
||||
RequestID: "req-config", Actor: Actor{ID: "admin-a", SourceIP: "192.0.2.10"},
|
||||
OccurredAt: time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC),
|
||||
ConfigVersion: "cfg-1", Checksum: strings.Repeat("a", SHA256HexBytes), Source: "configs/proxy-pool.yaml",
|
||||
Upstreams: []UpstreamDefinition{
|
||||
{Name: "provider-a", Enabled: true},
|
||||
{Name: "provider-b", Enabled: true},
|
||||
},
|
||||
Routings: []RoutingDefinition{{
|
||||
Name: "checkout", Enabled: true, Upstreams: []string{"provider-a", "provider-b"}, CurrentUpstream: "provider-a",
|
||||
}},
|
||||
}
|
||||
}
|
||||
191
internal/domain/proxy/capacity_test.go
Normal file
191
internal/domain/proxy/capacity_test.go
Normal file
@ -0,0 +1,191 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReservationCancelReleasesReservedCapacity(t *testing.T) {
|
||||
capacity := NewCapacity(1)
|
||||
reservation, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatal("Reserve() = false, want reservation")
|
||||
}
|
||||
if err := reservation.Cancel(); err != nil {
|
||||
t.Fatalf("Cancel() error = %v", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
|
||||
if err := reservation.Cancel(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("second Cancel() error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
if err := reservation.Commit(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("Commit() after Cancel error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
if err := reservation.Release(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("Release() after Cancel error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
|
||||
reused, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatal("Reserve() after Cancel = false, want released slot")
|
||||
}
|
||||
if err := reused.Cancel(); err != nil {
|
||||
t.Fatalf("reused Cancel() error = %v", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
}
|
||||
|
||||
func TestReservationCommitAndReleaseAreSingleUse(t *testing.T) {
|
||||
capacity := NewCapacity(1)
|
||||
reservation, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatal("Reserve() = false, want reservation")
|
||||
}
|
||||
if err := reservation.Commit(); err != nil {
|
||||
t.Fatalf("Commit() error = %v", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 1, 0)
|
||||
|
||||
if err := reservation.Commit(); !errors.Is(err, ErrReservationCommitted) {
|
||||
t.Fatalf("second Commit() error = %v, want ErrReservationCommitted", err)
|
||||
}
|
||||
if err := reservation.Cancel(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("Cancel() after Commit error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 1, 0)
|
||||
|
||||
if err := reservation.Release(); err != nil {
|
||||
t.Fatalf("Release() error = %v", err)
|
||||
}
|
||||
if err := reservation.Release(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("second Release() error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
if err := reservation.Commit(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("Commit() after Release error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
}
|
||||
|
||||
func TestReleaseBeforeCommitDoesNotConsumeReservation(t *testing.T) {
|
||||
capacity := NewCapacity(1)
|
||||
reservation, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatal("Reserve() = false, want reservation")
|
||||
}
|
||||
if err := reservation.Release(); !errors.Is(err, ErrReservationFinished) {
|
||||
t.Fatalf("Release() before Commit error = %v, want ErrReservationFinished", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 1)
|
||||
|
||||
if err := reservation.Cancel(); err != nil {
|
||||
t.Fatalf("Cancel() after rejected Release error = %v", err)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
}
|
||||
|
||||
func TestConcurrentReservationTerminationPreservesCounters(t *testing.T) {
|
||||
for iteration := range 1_000 {
|
||||
capacity := NewCapacity(1)
|
||||
reservation, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatalf("iteration %d Reserve() = false", iteration)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan terminationResult, 2)
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(2)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
results <- terminationResult{operation: "commit", err: reservation.Commit()}
|
||||
}()
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
results <- terminationResult{operation: "cancel", err: reservation.Cancel()}
|
||||
}()
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(results)
|
||||
|
||||
var succeeded string
|
||||
for result := range results {
|
||||
if result.err == nil {
|
||||
if succeeded != "" {
|
||||
t.Fatalf("iteration %d operations %s and %s both succeeded", iteration, succeeded, result.operation)
|
||||
}
|
||||
succeeded = result.operation
|
||||
continue
|
||||
}
|
||||
if !errors.Is(result.err, ErrReservationFinished) {
|
||||
t.Fatalf("iteration %d %s error = %v, want ErrReservationFinished", iteration, result.operation, result.err)
|
||||
}
|
||||
}
|
||||
|
||||
switch succeeded {
|
||||
case "commit":
|
||||
assertCapacityCounters(t, capacity, 1, 0)
|
||||
if err := reservation.Release(); err != nil {
|
||||
t.Fatalf("iteration %d Release() error = %v", iteration, err)
|
||||
}
|
||||
case "cancel":
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
default:
|
||||
t.Fatalf("iteration %d has no successful termination", iteration)
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReleaseSucceedsOnce(t *testing.T) {
|
||||
capacity := NewCapacity(1)
|
||||
reservation, ok := capacity.Reserve()
|
||||
if !ok {
|
||||
t.Fatal("Reserve() = false, want reservation")
|
||||
}
|
||||
if err := reservation.Commit(); err != nil {
|
||||
t.Fatalf("Commit() error = %v", err)
|
||||
}
|
||||
|
||||
var succeeded atomic.Int64
|
||||
var unexpected atomic.Int64
|
||||
var wait sync.WaitGroup
|
||||
for range 100 {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
err := reservation.Release()
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded.Add(1)
|
||||
case !errors.Is(err, ErrReservationFinished):
|
||||
unexpected.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
|
||||
if succeeded.Load() != 1 || unexpected.Load() != 0 {
|
||||
t.Fatalf("Release() results = success:%d unexpected:%d, want 1 and 0", succeeded.Load(), unexpected.Load())
|
||||
}
|
||||
assertCapacityCounters(t, capacity, 0, 0)
|
||||
}
|
||||
|
||||
type terminationResult struct {
|
||||
operation string
|
||||
err error
|
||||
}
|
||||
|
||||
func assertCapacityCounters(t *testing.T, capacity *Capacity, active, reserved int64) {
|
||||
t.Helper()
|
||||
if got := capacity.Active(); got != active {
|
||||
t.Fatalf("Active() = %d, want %d", got, active)
|
||||
}
|
||||
if got := capacity.Reserved(); got != reserved {
|
||||
t.Fatalf("Reserved() = %d, want %d", got, reserved)
|
||||
}
|
||||
}
|
||||
@ -96,6 +96,33 @@ func TestSequentialValidFetchResetsEmptyCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequentialDefaultsToStopAfterLastUpstream(t *testing.T) {
|
||||
sequence, err := NewSequential([]string{"a", "b"}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSequential(): %v", err)
|
||||
}
|
||||
if !sequence.ObserveEmpty("a") || sequence.Current() != "b" {
|
||||
t.Fatalf("first transition current = %q, want b", sequence.Current())
|
||||
}
|
||||
if !sequence.ObserveEmpty("b") {
|
||||
t.Fatal("ObserveEmpty(b) = false, want transition to stopped")
|
||||
}
|
||||
if current, available, _ := sequence.CurrentSelection(); available || current != "" {
|
||||
t.Fatalf("CurrentSelection() = %q, %v; want stopped", current, available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequentialRejectsSingleUpstream(t *testing.T) {
|
||||
if _, err := NewSequential([]string{"a"}, 1); err == nil {
|
||||
t.Fatal("NewSequential(single upstream) error = nil")
|
||||
}
|
||||
if _, err := NewSequentialWithState(
|
||||
[]string{"a"}, 1, EndStop, NewUpstreamEmptyState(),
|
||||
); err == nil {
|
||||
t.Fatal("NewSequentialWithState(single upstream) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) {
|
||||
empty := NewUpstreamEmptyState()
|
||||
first, err := NewSequentialWithState([]string{"a", "b"}, 5, EndStayLast, empty)
|
||||
@ -122,15 +149,18 @@ func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSequentialStopEndBehaviorHasNoCurrentSelection(t *testing.T) {
|
||||
sequence, err := NewSequentialWithState([]string{"a"}, 1, EndStop, NewUpstreamEmptyState())
|
||||
sequence, err := NewSequentialWithState([]string{"a", "b"}, 1, EndStop, NewUpstreamEmptyState())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSequentialWithState(): %v", err)
|
||||
}
|
||||
if !sequence.ObserveEmpty("a") {
|
||||
t.Fatal("ObserveEmpty() = false, want transition to stopped")
|
||||
t.Fatal("ObserveEmpty(a) = false, want transition to b")
|
||||
}
|
||||
if current, ok, version := sequence.CurrentSelection(); ok || current != "" || version != 2 {
|
||||
t.Fatalf("CurrentSelection() = %q, %v, %d; want stopped version 2", current, ok, version)
|
||||
if !sequence.ObserveEmpty("b") {
|
||||
t.Fatal("ObserveEmpty(b) = false, want transition to stopped")
|
||||
}
|
||||
if current, ok, version := sequence.CurrentSelection(); ok || current != "" || version != 3 {
|
||||
t.Fatalf("CurrentSelection() = %q, %v, %d; want stopped version 3", current, ok, version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -132,7 +132,7 @@ type Sequential struct {
|
||||
}
|
||||
|
||||
func NewSequential(upstreams []string, threshold int) (*Sequential, error) {
|
||||
return NewSequentialWithState(upstreams, threshold, EndStayLast, NewUpstreamEmptyState())
|
||||
return NewSequentialWithState(upstreams, threshold, EndStop, NewUpstreamEmptyState())
|
||||
}
|
||||
|
||||
func NewSequentialWithState(
|
||||
@ -141,14 +141,14 @@ func NewSequentialWithState(
|
||||
end EndBehavior,
|
||||
empty *UpstreamEmptyState,
|
||||
) (*Sequential, error) {
|
||||
if len(upstreams) == 0 {
|
||||
return nil, fmt.Errorf("sequential strategy requires at least one upstream")
|
||||
if len(upstreams) < 2 {
|
||||
return nil, fmt.Errorf("sequential strategy requires at least two upstreams")
|
||||
}
|
||||
if threshold <= 0 {
|
||||
return nil, fmt.Errorf("sequential threshold must be greater than zero")
|
||||
}
|
||||
if end == "" {
|
||||
end = EndStayLast
|
||||
end = EndStop
|
||||
}
|
||||
if end != EndStayLast && end != EndStop && end != EndLoop {
|
||||
return nil, fmt.Errorf("sequential end behavior %q is invalid", end)
|
||||
|
||||
92
progress.md
92
progress.md
@ -1,7 +1,82 @@
|
||||
# 项目进度
|
||||
|
||||
## 2026-07-30
|
||||
|
||||
- 固定 `github.com/jackc/pgx/v5 v5.6.0`,实现封装在 `adminstate.Store` 后的
|
||||
PostgreSQL 深适配器;配置、Upstream、Routing mutation 在同一事务中提交
|
||||
revision、管理状态、审计与 Outbox,数据库错误不泄漏 DSN、SQL 或参数。
|
||||
- 实现只读 Repeatable Read Snapshot、审计分页、`FOR UPDATE SKIP LOCKED`
|
||||
Outbox 领取和整批 ACK;具体 Adapter、SQL、pgx 类型及 codec 不向业务层暴露。
|
||||
- 新增同一物理连接迁移执行器和 PostgreSQL 18 隔离 fixture;每个测试使用唯一
|
||||
Schema,回环端口和 tmpfs 不保留数据,CI 已加入 Redis/PostgreSQL 集成任务。
|
||||
- 真实 PostgreSQL 18 已通过完整公用契约:配置事务、Upstream 幂等、100 并发
|
||||
Routing CAS、Routing no-op、审计分页、Outbox 租约/重领、原子 ACK 和 Context。
|
||||
- 审计与 Outbox 触发器故障注入证明状态、revision、审计和事件完整回滚;迁移
|
||||
重复执行及 `information_schema` 检查确认仅有六张管理表,无 Proxy、凭据、
|
||||
逐次提取、Worker ownership 或幂等明细。
|
||||
- Docker Hub 直连因 Docker Desktop 未配置 HTTPS 代理超时,改从 Google 官方
|
||||
Docker Hub 公共缓存拉取相同 `postgres:18-alpine` 镜像并本地重标记;未修改
|
||||
Docker Desktop 全局镜像配置。
|
||||
- `implementation-plan.md` 中 PostgreSQL 两项验收已完成,总进度由 49/73 更新为
|
||||
51/73(69.9%);生产命令装配、分布式协调、Checker 和 100,000 QPS 集群压测
|
||||
仍未完成。
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 新增公用 `verify-proto.ps1`:自动发现 Google well-known types include,固定
|
||||
编译控制面 Proto 的 imports/source-info descriptor,并拒绝空输出;本机生成
|
||||
30,972 字节 descriptor。`verify.ps1` 在存在 protoc 时执行,CI 强制安装待补。
|
||||
- 新增 Distribution/Admin OpenAPI 公用 CI 结构门禁,递归验证本地 `$ref`、
|
||||
operationId 唯一性、HTTP operation 响应和 security scheme 引用;完整 OAS
|
||||
工具验证与 Protobuf descriptor CI 编译仍保持未完成。
|
||||
- 一次 OpenAPI 检索在 PowerShell 双引号中误触发 `$ref` 变量解析;已改用单引号
|
||||
模式继续审计,未重复原命令。
|
||||
- 新增 PostgreSQL 18 隔离测试服务的静态契约:回环端口、正确 PG18 tmpfs 数据
|
||||
根目录、零持久卷;Redis 测试改用独立 Compose 项目和显式服务启动,避免两个
|
||||
fixture 相互影响。未拉取或启动容器,pgx Adapter/真实契约仍待依赖确认。
|
||||
- 扩展 `adminstate/contracttest` 公用契约:审计完整字段和 AfterID 分页、Routing
|
||||
no-op 审计且不写 Outbox、批量 ACK 零部分提交,以及六个 Store 方法的 Context
|
||||
取消;MemoryStore 全部通过,后续 PostgreSQL Adapter 必须运行同一套契约。
|
||||
- `adminstate` 六类命令/查询已封装公用 `Validate()`,MemoryStore 改为统一复用;
|
||||
后续 pgx Adapter 不再重复实现名称、引用、分页和 Outbox 租约输入校验。
|
||||
- 一次命令装配审计误用了不存在的 `controller/distribution/service.go`;实际领域
|
||||
服务位于 `controller/extraction`,Distribution 包当前只负责 HTTP Handler。
|
||||
- 根据对话最终定稿统一 Sequential:至少两个 Upstream,省略 `endBehavior` 时
|
||||
默认 `stop`;新增领域和严格配置回归测试。disabled candidate、跨实例游标和
|
||||
`onUnavailable` 请求链仍未提前标记完成。
|
||||
- 一次并行读取误用了不存在的 `routing/sequential_test.go`,另一次误用了旧 ADR
|
||||
文件名;已改用 `routing_test.go` 和 `006-postgresql-admin-state.md`,未重复命令。
|
||||
- 新增文档契约测试,递归验证 README、docs、deploy、diagrams 的相对链接,并
|
||||
拒绝公开指南引用不存在的具体 Go 命令目标;修正配置参考中尚未实现的
|
||||
`cmd/proxy-controller` 启动命令,改为当前真实可执行的严格配置校验工具。
|
||||
- 已复核 `implementation-plan.md` 的验收项:机器契约、OpenAPI/Protobuf 验证、
|
||||
完整文档导航、20 份配置示例和 35 张 Mermaid 图已有仓库及验证证据,修正
|
||||
滞后勾选;进一步核对发现 descriptor 编译尚未进入 CI,同时验证脚本和双平台
|
||||
CI 已完成,最终校正为 49/73,约 67%。
|
||||
- 正在并行审计 Routing/Sequential 与 Proxy Capacity 的剩余边界;本轮只收敛
|
||||
已确认公共接口和既有需求,不把 Checker、pgx 或 100,000 QPS 目标提前记为完成。
|
||||
- Routing 审计确认五种策略尚未进入真实请求链、`onUnavailable` 只有配置校验、
|
||||
Sequential 仅有进程内 CAS;Capacity 审计确认固定 Max 不超卖,但动态降容、
|
||||
Reservation 完整生命周期观测和短 TTL 运行态回收待完成。追踪矩阵已降级为
|
||||
准确的“已完成证据 + 待办”描述。
|
||||
- 定向执行 Proxy、Routing、Config、Gateway Dispatch/Server 测试,全部通过;
|
||||
该结果只证明现有行为基线,不替代上述缺失运行链的验收。
|
||||
- 一次组合检索因包含不存在的 `config` 路径返回退出码 1;有效输出已保留,
|
||||
后续检索改用实际目录 `internal/config`,未重复原命令。
|
||||
- Routing Runtime 设计核对确认采用 Controller 权威状态 + Gateway 不可变本地
|
||||
快照的混合模型;已定位 Extract direct、默认值、disabled 推进和单 Upstream
|
||||
Sequential 四项需确认语义,尚未在未批准设计上开始实现。
|
||||
- 一次读取误用了不存在的 `controller/runtime/bootstrap.go` 路径;实际文件为
|
||||
`runtime.go`,Gateway 构建辅助位于 `gateway/server/bootstrap.go`,已改用真实
|
||||
文件继续核对,未重复失败命令。
|
||||
- 架构证据审计确认 Provider 分布式 Leader、Health Reducer/Checker、Prometheus
|
||||
指标模块和四个生产命令仍缺实现;追踪矩阵已把这些条目的配置/领域基础与
|
||||
完整运行时证据拆开描述。
|
||||
- 新增独立 Capacity 生命周期测试,覆盖 Cancel 后复用、重复 Commit/Cancel/
|
||||
Release、Release-before-Commit、1,000 轮并发 Commit/Cancel 及 100 并发
|
||||
Release;Proxy、Dispatch、Snapshot、Gateway Server 定向测试全部通过。
|
||||
- 一次 `rg` 同时包含不存在的 `cmd` 路径,以及两次使用 PowerShell 不展开的
|
||||
通配路径,分别返回退出码 2/123;后续改用实际目录和 `-g` 过滤,不重复原命令。
|
||||
- 已实现共享 `httpapi`、`httpsecurity` 与 `httpserver`,统一严格 JSON、Problem、
|
||||
Request ID、认证、可信代理、Client ID、准入、多监听器生命周期和优雅停机。
|
||||
- Distribution/Admin Handler 已装配到独立监听器;Gateway 配置认证统一复用
|
||||
@ -20,9 +95,26 @@
|
||||
余量停止新分配。
|
||||
- PostgreSQL 管理面 Adapter、Provider Leader/分布式限流与心跳装配、生产命令
|
||||
入口、Redis 故障转移验证和代表性 100,000 QPS 集群压测仍待实现。
|
||||
- 已新增 ADR-006 与公用 `adminstate` 事务 seam;MemoryStore 在同一锁内提交管理
|
||||
状态、Admin 审计与 Outbox,覆盖配置冲突、Upstream 幂等、100 并发 Routing
|
||||
CAS、Outbox claim/ack 租约、上下文取消和不可变快照。
|
||||
- PostgreSQL 管理 Schema 已限制为六张配置/状态/审计/Outbox 表,并由静态测试
|
||||
拒绝 Proxy、凭据、逐次提取、ownership 和短期幂等明细;pgx Adapter 和真实
|
||||
PostgreSQL 18 契约仍待实现。
|
||||
- Admin Handler 现在复用 `httpsecurity.Identity`,把 Actor ID 与可信 SourceIP
|
||||
传给 enable/disable/switch/reload mutation,供持久化审计直接使用。
|
||||
- 已实现 Admin `ApplicationService`:mutation 统一映射到 `adminstate`;Status 合并
|
||||
一个权威管理快照和低基数运行态聚合;配置重载严格执行解析/校验、脱敏摘要、
|
||||
持久化、原子发布顺序,任何持久化失败都不会替换旧运行配置。
|
||||
- 已新增公用原子 `config.Store` 与标准 `FileConfigurationLoader`;覆盖 100 组
|
||||
并发读写、typed-nil、未知字段、主配置/Secret 文件 I/O、取消、Secret 轮换和
|
||||
Admin 禁止依赖 Redis Extract/Proxy 明细的架构边界。
|
||||
- 本轮 `.\scripts\verify.ps1`、`.\scripts\test-redis.ps1`、Compose 静态展开和
|
||||
Compose 非持久化策略测试通过;Windows `CGO_ENABLED=0`,race 继续由 Linux
|
||||
CI 执行。
|
||||
- 本地 PostgreSQL 管理面基础已提交为 `7951c29`;推送
|
||||
`build/proxy-pool-architecture` 时远端返回 `Authentication failed`,未重复执行
|
||||
相同推送。当前本地提交保持完整,等待可用 Git 凭据后同步。
|
||||
|
||||
## 2026-07-28
|
||||
|
||||
|
||||
34
scripts/test-postgres.ps1
Normal file
34
scripts/test-postgres.ps1
Normal file
@ -0,0 +1,34 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$composeFile = Join-Path $repositoryRoot "deploy/docker-compose.test.yml"
|
||||
$composeProject = "proxy-pool-postgres-test"
|
||||
$previousPostgresURL = [Environment]::GetEnvironmentVariable("PROXY_POOL_TEST_POSTGRES_URL", "Process")
|
||||
|
||||
try {
|
||||
docker compose -p $composeProject -f $composeFile up -d --wait --wait-timeout 60 postgres
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "starting PostgreSQL test fixture failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$env:PROXY_POOL_TEST_POSTGRES_URL = "postgres://proxy_pool_test:proxy-pool-test@127.0.0.1:15432/proxy_pool_test?sslmode=disable"
|
||||
Push-Location $repositoryRoot
|
||||
try {
|
||||
go test -count=1 -tags=integration -timeout 60s ./internal/adapters/postgresadmin/...
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "PostgreSQL integration tests failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -eq $previousPostgresURL) {
|
||||
Remove-Item Env:PROXY_POOL_TEST_POSTGRES_URL -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:PROXY_POOL_TEST_POSTGRES_URL = $previousPostgresURL
|
||||
}
|
||||
docker compose -p $composeProject -f $composeFile down --volumes --remove-orphans
|
||||
}
|
||||
@ -2,10 +2,11 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$composeFile = Join-Path $repositoryRoot "deploy/docker-compose.test.yml"
|
||||
$composeProject = "proxy-pool-redis-test"
|
||||
$previousRedisURL = [Environment]::GetEnvironmentVariable("PROXY_POOL_TEST_REDIS_URL", "Process")
|
||||
|
||||
try {
|
||||
docker compose -f $composeFile up -d --wait
|
||||
docker compose -p $composeProject -f $composeFile up -d --wait --wait-timeout 60 redis
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "starting Redis test fixture failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
@ -29,5 +30,5 @@ finally {
|
||||
else {
|
||||
$env:PROXY_POOL_TEST_REDIS_URL = $previousRedisURL
|
||||
}
|
||||
docker compose -f $composeFile down --remove-orphans
|
||||
docker compose -p $composeProject -f $composeFile down --volumes --remove-orphans
|
||||
}
|
||||
|
||||
51
scripts/verify-proto.ps1
Normal file
51
scripts/verify-proto.ps1
Normal file
@ -0,0 +1,51 @@
|
||||
param(
|
||||
[string]$Protoc = "protoc",
|
||||
[string]$IncludePath = $env:PROTOC_INCLUDE,
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$protoRoot = Join-Path $repositoryRoot "api/proto"
|
||||
$source = Join-Path $protoRoot "controlplane/v1/controlplane.proto"
|
||||
|
||||
$protocCommand = Get-Command $Protoc -ErrorAction Stop
|
||||
if ([string]::IsNullOrWhiteSpace($IncludePath)) {
|
||||
$installationRoot = Split-Path (Split-Path $protocCommand.Source -Parent) -Parent
|
||||
$candidates = @(
|
||||
(Join-Path $installationRoot "include"),
|
||||
"/usr/include",
|
||||
"/usr/local/include"
|
||||
)
|
||||
$IncludePath = $candidates |
|
||||
Where-Object { Test-Path (Join-Path $_ "google/protobuf/timestamp.proto") } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($IncludePath) -or
|
||||
-not (Test-Path (Join-Path $IncludePath "google/protobuf/timestamp.proto"))) {
|
||||
throw "protoc well-known type include directory was not found; set PROTOC_INCLUDE"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$outputDirectory = Join-Path $repositoryRoot ".tmp-proto"
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
$OutputPath = Join-Path $outputDirectory "controlplane.pb"
|
||||
}
|
||||
|
||||
& $protocCommand.Source `
|
||||
"--proto_path=$protoRoot" `
|
||||
"--proto_path=$IncludePath" `
|
||||
"--include_imports" `
|
||||
"--include_source_info" `
|
||||
"--descriptor_set_out=$OutputPath" `
|
||||
$source
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc descriptor compilation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$descriptor = Get-Item -LiteralPath $OutputPath
|
||||
if ($descriptor.Length -le 0) {
|
||||
throw "protoc produced an empty descriptor set"
|
||||
}
|
||||
Write-Host "descriptor: $($descriptor.FullName) ($($descriptor.Length) bytes)"
|
||||
@ -22,6 +22,13 @@ if ($unformatted) {
|
||||
Invoke-Step "go vet" { go vet ./... }
|
||||
Invoke-Step "unit tests" { go test -timeout 60s ./... }
|
||||
|
||||
if (Get-Command protoc -ErrorAction SilentlyContinue) {
|
||||
Invoke-Step "protobuf descriptor" { & (Join-Path $PSScriptRoot "verify-proto.ps1") }
|
||||
}
|
||||
else {
|
||||
Write-Host "==> protobuf descriptor skipped: protoc is not installed"
|
||||
}
|
||||
|
||||
if ((go env CGO_ENABLED) -eq "1") {
|
||||
Invoke-Step "race tests" { go test -race -timeout 60s ./internal/... }
|
||||
} else {
|
||||
|
||||
11
task_plan.md
11
task_plan.md
@ -28,6 +28,11 @@
|
||||
TTL 活动池契约;PostgreSQL 退出代理数据路径
|
||||
10. [已完成] 实现生产 Redis Activity Adapter、原子 Lua、公用行为契约和
|
||||
Redis 8.2 集成 fixture;本地 Redis 禁止短效代理数据持久化
|
||||
11. [已完成] 实现 PostgreSQL 管理面;ADR、领域事务契约、MemoryStore、公用
|
||||
契约、六表 Schema、Admin Actor 传播、应用服务、原子配置发布、pgx Adapter
|
||||
和真实 PostgreSQL 18 集成测试已完成
|
||||
12. [进行中] 复核验收清单并收敛既有 Routing/Sequential 与 Proxy 容量边界;
|
||||
机器契约和文档类滞后勾选已按仓库证据校正
|
||||
|
||||
## 串并行关系
|
||||
|
||||
@ -46,7 +51,9 @@
|
||||
|
||||
## 已知环境限制
|
||||
|
||||
- Docker Compose 配置与 Kubernetes Kustomize 已完成静态渲染验证;未启动
|
||||
目标运行拓扑。
|
||||
- Docker Compose 配置与 Kubernetes Kustomize 已完成静态渲染验证;Redis 8.2
|
||||
与 PostgreSQL 18 的隔离 Adapter fixture 已运行,完整目标运行拓扑尚未启动。
|
||||
- `cmd/proxy-*`、PostgreSQL 管理面 Adapter、Provider Leader/分布式限流、
|
||||
Checker 运行时、Redis 故障转移验证与代表性集群压测属于后续实施范围。
|
||||
- `implementation-plan.md` 当前按 73 个验收项统计;已校正为 51 项完成,
|
||||
验收项完成率约 69.9%,不等同于生产就绪度。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user