Compare commits

...

23 Commits

Author SHA1 Message Date
youfak
61dd03d207 docs: record postgres admin state delivery
Some checks are pending
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run
2026-07-30 10:36:54 +08:00
youfak
6bfef0fcf5 test: add postgres admin state contract fixture 2026-07-30 10:35:55 +08:00
youfak
48682f0bfc fix: preserve postgres storage error boundary 2026-07-30 04:57:39 +08:00
youfak
2be4eb23e3 feat: implement postgres admin state adapter 2026-07-30 04:48:15 +08:00
youfak
dacf48ea29 build: verify protobuf descriptors locally 2026-07-29 22:00:27 +08:00
youfak
b86a729792 test: validate openapi contract graph 2026-07-29 21:57:42 +08:00
youfak
02097596fa test: isolate postgres integration fixture 2026-07-29 21:53:33 +08:00
youfak
2341296271 test: strengthen admin state contract 2026-07-29 21:50:21 +08:00
youfak
c5311aa9c0 refactor: share admin state validation 2026-07-29 21:47:50 +08:00
youfak
2dbfe2f742 fix: align sequential routing defaults 2026-07-29 21:41:17 +08:00
youfak
35a201cab1 test: validate documentation references 2026-07-29 21:30:09 +08:00
youfak
125740f58d test: cover capacity reservation lifecycle 2026-07-29 21:23:34 +08:00
youfak
f46d79511f docs: align architecture evidence with runtime 2026-07-29 21:18:37 +08:00
youfak
80c6ba1ea8 docs: correct project completion evidence 2026-07-29 20:56:40 +08:00
youfak
5b053f0e65 feat: connect admin API to management state 2026-07-29 20:45:49 +08:00
youfak
0108b9e813 docs: record checker design inputs 2026-07-29 20:18:14 +08:00
youfak
7951c292d3 docs: record admin state foundation 2026-07-29 20:13:53 +08:00
youfak
beaf4c9c38 fix: normalize admin config checksums 2026-07-29 20:04:09 +08:00
youfak
363d217c35 feat: propagate admin audit identity 2026-07-29 19:58:29 +08:00
youfak
912db2aa18 feat: add postgres admin state schema 2026-07-29 19:55:33 +08:00
youfak
b53b9f1adc feat: add transactional admin state reference store 2026-07-29 18:27:03 +08:00
youfak
801712ff24 feat: define admin state transaction contracts 2026-07-29 18:21:21 +08:00
youfak
08800cb7a5 docs: design postgres admin state module 2026-07-29 18:17:26 +08:00
61 changed files with 6101 additions and 89 deletions

View File

@ -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

View 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
}

View File

@ -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` 中的一致性、安全、恢复、
竞态与容量门禁,才能构建镜像并发布。

View File

@ -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
}

View File

@ -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", ""]

View File

@ -0,0 +1,152 @@
# ADR-006PostgreSQL 管理面采用事务深模块
## 状态
接受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` 是并发安全参考 AdapterPostgreSQL 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违反短效活动池与数据最小化边界。

View File

@ -38,7 +38,7 @@ PostgreSQL 只持久化配置版本、Upstream/Routing 管理状态、Admin 审
## ADR-005Redis 活动池采用单实例原子深模块
**状态:** 接受。
**状态:** 接受并已实现
首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现
Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和
@ -47,3 +47,15 @@ Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、
完整决策、键空间、原子操作和测试门禁见
[ADR-005](005-redis-activity-pool.md)。
## ADR-006PostgreSQL 管理面采用事务深模块
**状态:** 接受。
Upstream/Routing/配置修订、Admin 审计和 Outbox 由一个深 Adapter 在同一事务
提交。调用方只依赖 mutation、snapshot 和有界 outbox 接口,不接触数据库事务
或细粒度 Repository。PostgreSQL Schema 明确排除 Proxy、Worker ownership、
逐次提取记录和短期幂等结果。
完整接口、事务不变量、Schema 与公用契约见
[ADR-006](006-postgresql-admin-state.md)。

View File

@ -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 无效。

View File

@ -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`

View File

@ -272,6 +272,9 @@ sequenceDiagram
Upstream 的 Empty 事实全局共享;每条 Routing 独立 CAS 当前索引。多个并发
协程只能有一个成功从 A 切到 B其他协程读取新版本不会再切到 C。
Sequential 至少配置两个 Upstream列表耗尽后的默认行为是 `stop``loop` 和
`stayLast` 必须显式配置。disabled Upstream 不参与新分配,其运行时跳过与权威
游标持久化仍由后续 Routing Runtime 完成。
## 10. Exclusive Extraction

View File

@ -58,6 +58,9 @@ Gateway Worker 只读取本地不可变快照并维护本地容量计数。供
### 4.3 Sequential 切换
Sequential 至少配置两个 Upstream初始使用列表第一项。列表耗尽时默认 `stop`
也可显式选择 `loop``stayLast`
1. Provider 响应成功且解析成功,但合法候选为零,才累计 Empty。
2. 网络、认证、HTTP、模板或解析失败只计 Error。
3. 全部候选均重复时计 DuplicateOnly并重置连续 Empty。

View File

@ -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、无

View File

@ -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-29Distribution/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
View 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)
}

View File

@ -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. 停止领取新任务。

View File

@ -17,6 +17,8 @@
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。
- 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和
security scheme完整标准工具验证仍待补齐。
- ProtobufWorker 注册、全量/增量 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 和只含六张管理表的 Schemapgx
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. 容量结论

View File

@ -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 公用契约覆盖,跨进程故障场景仍按清单推进 |

View 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.

View File

@ -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

View File

@ -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 明细和逐次提取记录。

View File

@ -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/
OnUnavailableGateway 仍把全部 Upstream 交给一个全局轮询 Dispatcher配置
策略尚未影响真实请求链。
- `onUnavailable` 已严格校验 reject/wait/direct但 Gateway 无候选时统一返回
503wait/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 RuntimeController 维护 Sequential 权威游标和跨实例
CASGateway 消费不可变路由快照并在本地执行 random/roundRobin/weighted/
leastConnections热路径不访问 Redis/PostgreSQLDistribution 在 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 或目标 ProfileHEALTH-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
View File

@ -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
View File

@ -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=

View 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)
}

View 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",
}
}

View 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()
}

View 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)
}
}

View 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)
}

View 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
}

View File

@ -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;

View 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)
}
}
}

View 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, &current); 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(&currentRevision); 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
}

View 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")
}

View 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
}

View 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
}

View 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}
}

View File

@ -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
View 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)
}

View 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"},
}},
}
}

View File

@ -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
}

View 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
}
}

View 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}
`

View File

@ -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

View File

@ -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"`}},

View 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
}
}

View 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
})
}
}
}

View 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)
}

View 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...)
}

View 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()
})
}

View 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
}

View 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
}

View 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",
}},
}
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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/7369.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 仅有进程内 CASCapacity 审计确认固定 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 并发
ReleaseProxy、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` 事务 seamMemoryStore 在同一锁内提交管理
状态、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
View 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
}

View File

@ -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
View 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)"

View File

@ -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 {

View File

@ -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%,不等同于生产就绪度。