Compare commits

..

No commits in common. "61dd03d2071ac1b649f4235505459b9d10323c21" and "63bc56be275eb8d1a728465506e849bba8d3bda8" have entirely different histories.

61 changed files with 89 additions and 6101 deletions

View File

@ -32,19 +32,3 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- run: go test -race -timeout 60s ./internal/... - 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

@ -1,169 +0,0 @@
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,15 +8,10 @@ Grafana 与 Kubernetes。配置已通过静态展开但当前仓库的 `cmd/p
```powershell ```powershell
docker compose -f deploy/docker-compose.yml config --quiet 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 kubectl kustomize deploy/kubernetes/base | Out-Null
go run ./deploy/tools/configcheck deploy/config/local.yaml 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` 中的一致性、安全、恢复、 运行时完成后,还必须通过 `production-readiness.md` 中的一致性、安全、恢复、
竞态与容量门禁,才能构建镜像并发布。 竞态与容量门禁,才能构建镜像并发布。

View File

@ -2,8 +2,6 @@ package deploy
import ( import (
"os" "os"
"slices"
"strings"
"testing" "testing"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
@ -15,67 +13,11 @@ type composeDocument struct {
} }
type composeService struct { type composeService struct {
Image string `yaml:"image"`
Command []string `yaml:"command"` Command []string `yaml:"command"`
Ports []string `yaml:"ports"`
Volumes []string `yaml:"volumes"` Volumes []string `yaml:"volumes"`
Tmpfs []string `yaml:"tmpfs"`
DependsOn any `yaml:"depends_on"` 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) { func TestLocalRedisIsExplicitlyEphemeral(t *testing.T) {
document := loadComposeDocument(t) document := loadComposeDocument(t)
redis, ok := document.Services["redis"] redis, ok := document.Services["redis"]
@ -113,18 +55,13 @@ func TestLocalGatewaysDoNotDependOnControlPlaneStorage(t *testing.T) {
func loadComposeDocument(t *testing.T) composeDocument { func loadComposeDocument(t *testing.T) composeDocument {
t.Helper() t.Helper()
return loadComposeFile(t, "docker-compose.yml") payload, err := os.ReadFile("docker-compose.yml")
}
func loadComposeFile(t *testing.T, name string) composeDocument {
t.Helper()
payload, err := os.ReadFile(name)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", name, err) t.Fatalf("read docker-compose.yml: %v", err)
} }
var document composeDocument var document composeDocument
if err := yaml.Unmarshal(payload, &document); err != nil { if err := yaml.Unmarshal(payload, &document); err != nil {
t.Fatalf("parse %s: %v", name, err) t.Fatalf("parse docker-compose.yml: %v", err)
} }
return document return document
} }

View File

@ -1,23 +1,6 @@
name: proxy-pool-test name: proxy-pool-test
services: 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: redis:
image: redis:8.2-alpine image: redis:8.2-alpine
command: ["redis-server", "--appendonly", "no", "--save", ""] command: ["redis-server", "--appendonly", "no", "--save", ""]

View File

@ -1,152 +0,0 @@
# 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 活动池采用单实例原子深模块 ## ADR-005Redis 活动池采用单实例原子深模块
**状态:** 接受并已实现 **状态:** 接受。
首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现 首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现
Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和 Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和
@ -47,15 +47,3 @@ Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、
完整决策、键空间、原子操作和测试门禁见 完整决策、键空间、原子操作和测试门禁见
[ADR-005](005-redis-activity-pool.md)。 [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,28 +25,12 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
不能混用配置格式版本或单 Worker Snapshot 版本。 不能混用配置格式版本或单 Worker Snapshot 版本。
严格 JSON、请求体上限、Request ID、JSON/Problem 响应由 严格 JSON、请求体上限、Request ID、JSON/Problem 响应由
`platform/httpapi` 公用实现提供。Admin Handler 必须注入 `IdentityResolver` `platform/httpapi` 公用实现提供。Admin Handler 必须注入 `Authorizer`,标准
标准装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。解析出的 装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。网关使用的
Actor ID 与可信 SourceIP 会进入所有 mutation 命令,但认证材料不会进入审计。
网关使用的
`Proxy-Authorization`/407 语义不得复用到 Admin 的 `Authorization`/401 语义。 `Proxy-Authorization`/407 语义不得复用到 Admin 的 `Authorization`/401 语义。
`controller/runtime` 将 Admin 与 Distribution 放在不同 `net.Listener`,任一 `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 外,运行时还明确返回: 除契约中的 401/403/404/409/422 外,运行时还明确返回:
- `400`Request ID 或 JSON 无效。 - `400`Request ID 或 JSON 无效。

View File

@ -3,16 +3,12 @@
## 1. 加载规则 ## 1. 加载规则
主配置格式为 YAML根字段 `version` 当前固定为 `1`。加载器启用严格字段 主配置格式为 YAML根字段 `version` 当前固定为 `1`。加载器启用严格字段
检查,拼写错误或未来版本字段不会被静默忽略。当前仓库可使用与未来进程启动 检查,拼写错误或未来版本字段不会被静默忽略。推荐启动命令显式传入配置路径:
相同的严格加载器校验本地部署配置:
```powershell ```powershell
go run ./deploy/tools/configcheck deploy/config/local.yaml go run ./cmd/proxy-controller -config configs/proxy-pool.yaml
``` ```
规划中的生产入口为 `proxy-controller -config CONFIG_FILE`;该命令完成实现和
进程级测试前,不作为当前可执行能力。
所有时间值使用 Go duration例如 `500ms`、`30s`、`5m`。示例中的 所有时间值使用 Go duration例如 `500ms`、`30s`、`5m`。示例中的
`${TOKEN}`、`${PASSWORD}`、`${POSTGRES_URL}` 等由加载器从同名环境变量 `${TOKEN}`、`${PASSWORD}`、`${POSTGRES_URL}` 等由加载器从同名环境变量
展开;这些值不得写入日志、指标、配置转储或错误响应。生产配置优先使用 展开;这些值不得写入日志、指标、配置转储或错误响应。生产配置优先使用
@ -192,7 +188,7 @@ routing:
strategy: strategy:
type: sequential type: sequential
switchAfterEmptyFetch: 5 switchAfterEmptyFetch: 5
endBehavior: stop endBehavior: stayLast
onUnavailable: onUnavailable:
action: reject action: reject
waitTimeout: 0s waitTimeout: 0s
@ -203,9 +199,7 @@ routing:
- `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、 - `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、
`leastConnections` `leastConnections`
- `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。 - `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。
- `sequential` 至少引用两个 Upstream并设置大于零的 - `sequential` 必须设置大于零的 `switchAfterEmptyFetch`
`switchAfterEmptyFetch``endBehavior` 省略时默认为 `stop`,也可显式设置
`loop``stayLast`
- `onUnavailable.action``reject`、`wait` 或 `direct`;默认建议 `reject` - `onUnavailable.action``reject`、`wait` 或 `direct`;默认建议 `reject`
Sequential 的空计数属于 Upstream当前索引属于 Routing。只有 Provider 响应 Sequential 的空计数属于 Upstream当前索引属于 Routing。只有 Provider 响应
@ -352,7 +346,7 @@ PostgreSQL 故障本身不应使 Redis 中可完成的 Extract 返回 `503`。Me
2. 所有启用监听器具有合法 `host:port` 2. 所有启用监听器具有合法 `host:port`
3. 非回环监听器满足认证或来源 CIDR 保护。 3. 非回环监听器满足认证或来源 CIDR 保护。
4. Routing 名称唯一,正则可编译,引用的 Upstream 存在。 4. Routing 名称唯一,正则可编译,引用的 Upstream 存在。
5. Sequential 至少引用两个 Upstream、阈值大于零,`onUnavailable.action` 明确。 5. Sequential 阈值大于零,`onUnavailable.action` 明确。
6. 启用的 Upstream 有正数 `pool.maxSize`、并发和 Fetch 限制。 6. 启用的 Upstream 有正数 `pool.maxSize`、并发和 Fetch 限制。
7. `allocationSafetyMargin < ttl` 7. `allocationSafetyMargin < ttl`
8. `fetch.maxTotal == 0``fetch.maxTotal >= pool.maxSize` 8. `fetch.maxTotal == 0``fetch.maxTotal >= pool.maxSize`

View File

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

View File

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

View File

@ -6,8 +6,6 @@
- PostgreSQL 与 Redis 仅用于适配器集成测试,领域单测不依赖外部服务。 - PostgreSQL 与 Redis 仅用于适配器集成测试,领域单测不依赖外部服务。
- 运行 `go test -race` 需要 CGO 和 C 编译器。 - 运行 `go test -race` 需要 CGO 和 C 编译器。
- Docker Compose 用于本地完整拓扑Docker 不应成为普通单测前置条件。 - Docker Compose 用于本地完整拓扑Docker 不应成为普通单测前置条件。
- Protobuf descriptor 验证需要 `protoc`;非标准安装可通过 `PROTOC_INCLUDE`
指定 Google well-known types 的 include 目录。
## 2. 开发循环 ## 2. 开发循环
@ -61,7 +59,6 @@ go build ./...
```powershell ```powershell
./scripts/verify.ps1 ./scripts/verify.ps1
./scripts/verify-proto.ps1
``` ```
审查还要确认:无 Secret 日志、无 Proxy IP 高基数标签、无默认 direct、无 审查还要确认:无 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. - [ ] 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. - [ ] Pin YAML v4, pgx/v5, go-redis/v9, gRPC, protobuf, Prometheus, and x/sync.
- [x] Add `scripts/verify.ps1` that runs format check, `go vet`, unit tests, race tests, - [ ] 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. and builds all commands, each test command bounded to 60 seconds.
- [x] Add CI for Windows and Linux with unit/race/build jobs. - [ ] Add CI for Windows and Linux with unit/race/build jobs.
- [ ] Verify `go mod tidy`, `go test ./...`, and `go build ./cmd/...` succeed. - [ ] Verify `go mod tidy`, `go test ./...`, and `go build ./cmd/...` succeed.
## Task 2: Strict Configuration ## Task 2: Strict Configuration
@ -84,10 +84,6 @@ test/{fixtures,integration,e2e,load}/
- [x] Prove with 1,000 concurrent goroutines that effective capacity is never exceeded. - [x] Prove with 1,000 concurrent goroutines that effective capacity is never exceeded.
- [ ] Add race coverage and duplicate-release invariant metrics hook. - [ ] 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 ## Task 4: Routing and Sequential Switching
**Files:** `internal/domain/routing/*.go`, corresponding tests **Files:** `internal/domain/routing/*.go`, corresponding tests
@ -99,10 +95,6 @@ test/{fixtures,integration,e2e,load}/
- [ ] Cover four-empty-then-success, five-empty, A-to-B-only, disabled references, - [ ] Cover four-empty-then-success, five-empty, A-to-B-only, disabled references,
end behavior, and explicit onUnavailable. end behavior, and explicit onUnavailable.
当前进度2026-07-29领域构造器与严格配置已统一 Sequential 至少两个
Upstream、`endBehavior` 默认 `stop`并覆盖列表末端停止disabled candidate、
跨实例恢复和 `onUnavailable` 运行链仍待完成。
## Task 5: Provider Fetch Classification and Scheduling ## Task 5: Provider Fetch Classification and Scheduling
**Files:** `internal/controller/provider/*.go`, `internal/domain/upstream/*.go`, tests **Files:** `internal/controller/provider/*.go`, `internal/domain/upstream/*.go`, tests
@ -166,10 +158,9 @@ Upstream、`endBehavior` 默认 `stop`并覆盖列表末端停止disabled
**Files:** `internal/controller/distribution/*.go`, `admin/*.go`, **Files:** `internal/controller/distribution/*.go`, `admin/*.go`,
`internal/adapters/postgres/*.go`, `internal/adapters/redis/*.go`, migrations, tests `internal/adapters/postgres/*.go`, `internal/adapters/redis/*.go`, migrations, tests
- [x] Define the PostgreSQL management seam for ConfigVersion, Upstream/Routing state, - [ ] Define PostgreSQL ports for ConfigVersion, Upstream/Routing management state,
AdminAudit and leased Outbox; the public contract has no Proxy or extraction detail types. AdminAudit, Outbox, and optional aggregate metrics; never persist Proxy details or
- [x] Add the six-table PostgreSQL management migration with static data-boundary checks. per-extraction records.
- [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 - [x] Implement the Redis TTL activity pool and one atomic extraction operation covering
candidate eligibility, Gateway reserve, ownership, removal, and short-lived idempotency. candidate eligibility, Gateway reserve, ownership, removal, and short-lived idempotency.
- [x] Implement Redis Worker ownership, drain/ACK, expiry reclaim, inventory and bounded - [x] Implement Redis Worker ownership, drain/ACK, expiry reclaim, inventory and bounded
@ -181,22 +172,17 @@ Upstream、`endBehavior` 默认 `stop`并覆盖列表末端停止disabled
- [x] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload - [x] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload
HTTP handlers and contracts. HTTP handlers and contracts.
- [x] Add Compose-backed Redis 8.2 integration and shared Adapter contract tests. - [x] Add Compose-backed Redis 8.2 integration and shared Adapter contract tests.
- [x] Add PostgreSQL management Adapter and Compose-backed integration tests. - [ ] Add PostgreSQL management Adapter and Compose-backed integration tests.
当前进度2026-07-30):已实现共享 `platform/httpapi`、Distribution 当前进度2026-07-29):已实现共享 `platform/httpapi`、Distribution
extract/live/ready Handler 与 Admin status/enable/disable/switch/reload Handler extract/live/ready Handler 与 Admin status/enable/disable/switch/reload Handler
定向契约测试已覆盖严格 JSON、Body 上限、Request ID、幂等 Header、DTO 映射、 定向契约测试已覆盖严格 JSON、Body 上限、Request ID、幂等 Header、DTO 映射、
404/405 及业务错误映射。共享 `platform/httpsecurity` 已补齐 Basic/API Key/ 404/405 及业务错误映射。共享 `platform/httpsecurity` 已补齐 Basic/API Key/
Bearer/CIDR、可信代理、Client ID、本地准入和 API 401/Gateway 407 差异,并作为 Bearer/CIDR、可信代理、Client ID、本地准入和 API 401/Gateway 407 差异,并作为
Admin/Distribution 必需依赖。共享 `platform/httpserver` Admin/Distribution 必需依赖。共享 `platform/httpserver`
`controller/runtime` 已完成 Distribution/Admin 独立监听器、首错联动关闭和 `controller/runtime` 已完成 Distribution/Admin 独立监听器、首错联动关闭和
有界优雅停机。Admin mutation 已携带认证 Actor/SourceIP公用 `adminstate` 有界优雅停机。生产命令入口、PostgreSQL 管理面 Adapter 及其 Compose 集成测试
事务契约、MemoryStore、100 并发 Routing CAS、租约 Outbox 和六表管理 Schema 仍待实现。
已完成。pgx Adapter 已在真实 PostgreSQL 18 上运行同一公用契约,并验证
Repeatable Read 快照、`SKIP LOCKED`、原子 ACK、审计/Outbox 故障回滚和数据边界。
Admin `ApplicationService` 已将 mutation、权威管理快照、低基数运行态
聚合与配置重载接到同一公用 seam严格文件加载、脱敏管理摘要及原子配置发布
已通过失败路径和并发测试。生产命令入口及其连接池/迁移启动装配仍待实现。
已新增公用 `domain/activitypool` 契约及并发安全内存参考实现Provider 已新增公用 `domain/activitypool` 契约及并发安全内存参考实现Provider
Reconciler 通过 `UpsertFetched` 写入带供应商 TTL 和分配安全余量的批次;已覆盖 Reconciler 通过 `UpsertFetched` 写入带供应商 TTL 和分配安全余量的批次;已覆盖
@ -219,18 +205,12 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
**Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`, **Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`,
`docs/api/*.md` `docs/api/*.md`
- [x] Specify Distribution/Admin REST schemas, status codes, authentication, examples, - [ ] Specify Distribution/Admin REST schemas, status codes, authentication, examples,
and idempotency behavior. and idempotency behavior.
- [x] Specify Worker register, snapshot, delta, ACK, report, heartbeat, ownership drain, - [ ] Specify Worker register, snapshot, delta, ACK, report, heartbeat, ownership drain,
and resync messages. and resync messages.
- [ ] Validate OpenAPI and compile protobuf descriptors in CI. - [ ] 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 ## Task 13: Deployment and Observability
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**` **Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`
@ -247,10 +227,10 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
**Files:** `docs/**`, `examples/**`, `diagrams/**` **Files:** `docs/**`, `examples/**`, `diagrams/**`
- [x] Complete README navigation, design document, developer guide, configuration - [ ] Complete README navigation, design document, developer guide, configuration
reference, API guide, deployment guide, security model, testing guide, and roadmap. reference, API guide, deployment guide, security model, testing guide, and roadmap.
- [x] Provide at least 20 validated configuration examples. - [ ] Provide at least 20 validated configuration examples.
- [x] Provide at least 30 Mermaid architecture, flow, sequence, state, and failure diagrams. - [ ] Provide at least 30 Mermaid architecture, flow, sequence, state, and failure diagrams.
- [ ] Generate `proxy-pool-docs-v1.0.zip` from versioned documentation assets. - [ ] Generate `proxy-pool-docs-v1.0.zip` from versioned documentation assets.
## Task 15: Completion Audit ## Task 15: Completion Audit

View File

@ -1,135 +0,0 @@
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,12 +54,6 @@ kubectl kustomize deploy/kubernetes/base > rendered.yaml
不执行 `FLUSHDB`。生产环境的 Redis 高可用与持久化策略必须独立评审,不能照搬 不执行 `FLUSHDB`。生产环境的 Redis 高可用与持久化策略必须独立评审,不能照搬
本地 fixture。 本地 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` - Gateway`127.0.0.1:8080`
@ -118,9 +112,6 @@ kubectl -n proxy-pool rollout status deployment/proxy-gateway --timeout=10m
4. 逐批发布 Gateway每次至少保留 PDB 要求的健康副本。 4. 逐批发布 Gateway每次至少保留 PDB 要求的健康副本。
5. 观察 30 分钟,再清理已经无人读取的旧字段或旧迁移。 5. 观察 30 分钟,再清理已经无人读取的旧字段或旧迁移。
迁移失败时停止 Controller 发布,不自动重试结果不确定的管理 mutation。恢复后
先确认 Schema 版本、最近 revision、审计和 outbox 一致,再开放 Admin 写入口。
回滚只能回到仍兼容当前 Schema 和 Snapshot 版本的镜像。涉及不可逆数据迁移时, 回滚只能回到仍兼容当前 Schema 和 Snapshot 版本的镜像。涉及不可逆数据迁移时,
必须使用前向修复。 必须使用前向修复。
@ -182,11 +173,6 @@ Prometheus 标签禁止包含 Proxy IP、Client ID、Session、完整 URL、requ
3. 等待已提交 Redis 原子操作返回;未确认请求的客户端必须使用相同幂等键重试。 3. 等待已提交 Redis 原子操作返回;未确认请求的客户端必须使用相同幂等键重试。
4. 刷新 Admin outbox、审计和 Worker ACK再关闭 PostgreSQL/Redis 连接池。 4. 刷新 Admin outbox、审计和 Worker ACK再关闭 PostgreSQL/Redis 连接池。
Outbox 发布器必须以稳定 consumer ID 有界领取;发布成功后原子 ACK。进程在发布
成功但 ACK 结果不确定时,不得伪造确认;等待租约到期后重领,并由下游事件消费者
按事件 ID/revision 去重。持续积压时先暂停新的管理变更,检查发布目标、租约和
最老未发布事件,不删除未发布行。
### Checker ### Checker
1. 停止领取新任务。 1. 停止领取新任务。

View File

@ -17,8 +17,6 @@
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、 - Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。 Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。 - Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。
- 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和
security scheme完整标准工具验证仍待补齐。
- ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/ - ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
结果上报、Checker 任务与 Observation。 结果上报、Checker 任务与 Observation。
@ -27,10 +25,8 @@
- `CFG-*`YAML v4 未知字段拒绝、监听保护、引用/上限/认证边界校验21 份 - `CFG-*`YAML v4 未知字段拒绝、监听保护、引用/上限/认证边界校验21 份
配置持续测试。 配置持续测试。
- `PROXY-* / CAP-*`唯一键、TTL 优先级、状态迁移与 Active/Reserved 打包 - `PROXY-* / CAP-*`唯一键、TTL 优先级、状态迁移与 Active/Reserved 打包
原子计数1,000 goroutine 不超卖,以及 Cancel、重复终结、错误顺序、并发 原子计数1,000 goroutine 不超卖测试。
Commit/Cancel/Release 计数守恒测试。 - `ROUTE-001 / ROUTE-004`:首条命中规则与 Concurrent Sequential 单次切换。
- `ROUTE-001 / ROUTE-004`:首条命中规则与进程内 Concurrent Sequential 单次
切换;策略运行时接线、持久化恢复和跨实例 CAS 尚未完成。
- `FETCH-005 / FETCH-006`Valid、Empty、DuplicateOnly、Error 分类。 - `FETCH-005 / FETCH-006`Valid、Empty、DuplicateOnly、Error 分类。
- `DIST-001..003 / DIST-006..007`:内存活动池参考实现验证独占提取、满足模式、 - `DIST-001..003 / DIST-006..007`:内存活动池参考实现验证独占提取、满足模式、
TTL、健康时效与 Gateway 保留量1,000 并发不重复。 TTL、健康时效与 Gateway 保留量1,000 并发不重复。
@ -43,16 +39,6 @@
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin - `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
Handler 已实现共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入, Handler 已实现共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入,
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。 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. 已执行验证 ## 2. 已执行验证
@ -60,8 +46,7 @@
go test ./... PASS go test ./... PASS
go vet ./... PASS go vet ./... PASS
go build ./... PASS go build ./... PASS
PostgreSQL 18 shared contract PASS protoc descriptor compilation PASS
protoc descriptor compilation PASS (local script; CI enforcement pending)
docker compose config PASS docker compose config PASS
kubectl kustomize PASS kubectl kustomize PASS
configuration examples 21/21 PASS configuration examples 21/21 PASS
@ -69,8 +54,8 @@ Mermaid blocks 35
``` ```
Windows 环境为 `CGO_ENABLED=0` 且没有 C 编译器,`go test -race` 在本机未执行; Windows 环境为 `CGO_ENABLED=0` 且没有 C 编译器,`go test -race` 在本机未执行;
CI 已配置 Linux race job。PostgreSQL 18 和 Redis 8.2 的隔离 Adapter fixture CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有把目标拓扑
已经运行;完整 Controller/Gateway/Checker Docker/Kubernetes 拓扑仍只有静态验证 作为已运行系统
## 3. 后续实现范围 ## 3. 后续实现范围
@ -79,19 +64,16 @@ CI 已配置 Linux race job。PostgreSQL 18 和 Redis 8.2 的隔离 Adapter fixt
1. `cmd/proxy-gateway/controller/checker/loadgen` 进程装配。 1. `cmd/proxy-gateway/controller/checker/loadgen` 进程装配。
2. Gateway 进程装配、生产连接池调优与代表性流量压测。 2. Gateway 进程装配、生产连接池调优与代表性流量压测。
3. Provider 分布式 singleflight/Leader、长期凭据回收和累计额度执行器。 3. Provider 分布式 singleflight/Leader、长期凭据回收和累计额度执行器。
4. PostgreSQL 连接池、迁移和 pgx Adapter 的生产命令启动装配,以及可选聚合指标; 4. PostgreSQL 配置版本、Upstream/Routing 管理状态、Admin 审计/Outbox
Schema、领域 seam、Memory/pgx Adapter、真实 PostgreSQL 18 契约和 Admin repository 和迁移;可选聚合指标不包含 Proxy 明细。
应用层接线已经完成。 5. Redis TTL 活动池、原子独占提取、短期幂等结果、Leader、速率限制、心跳与
5. Redis Provider Leader、分布式速率与 Client 限制、Worker 心跳和自动重建; 所有权适配器;活动池可由 Provider 重建。接线时必须验证进入
TTL 活动池、原子提取和 Worker ownership 已完成。 `allocationSafetyMargin` 的不可分配条目不会长期占用补池额度,同时不得
6. Worker 网络快照流Redis ownership drain/ACK/过期回收已完成。 突破 `pool.maxSize` 硬上限。
6. Worker ownership drain/ACK/过期回收和网络快照流。
7. Checker 调度、探测器和健康 reducer。 7. Checker 调度、探测器和健康 reducer。
8. Admin/Distribution 细粒度授权、分布式限流和审计查询。 8. Admin/Distribution 细粒度授权、分布式限流和审计查询。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将五种 Routing 策略和 `onUnavailable` 接入 Gateway/Distribution 运行链,
补齐 Sequential 持久化恢复、跨实例 CAS 和 disabled candidate 语义。
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测及短 TTL
Proxy 运行态排空回收。
## 4. 容量结论 ## 4. 容量结论

View File

@ -7,10 +7,10 @@
| ID | 最终需求 | 来源 | 验证证据 | | ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---| |---|---|---|---|
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离;四个 `cmd/proxy-*` 构建产物待实现 | | ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 进程结构、架构图、构建产物 |
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Snapshot/Dispatch 及依赖边界已验证;完整 Gateway 进程与代表性性能剖析待完成 | | ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | 依赖规则、测试、性能剖析 |
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Distribution/Admin 独立监听已测试Gateway/Metrics 生产入口待装配 | | ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | 配置、监听装配、端口测试 |
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | 单进程 Reconciler、合并通知和切换领域契约已完成分布式 Leader 与运行装配待完成 | | ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Leader、singleflight、集成测试 |
| ARCH-005 | 100k QPS 峰值使用多 Worker 集群 | 当前会话 | 未验证设计目标;待代表性集群负载报告 | | ARCH-005 | 100k QPS 峰值使用多 Worker 集群 | 当前会话 | 未验证设计目标;待代表性集群负载报告 |
## Routing 与 Upstream ## Routing 与 Upstream
@ -19,11 +19,11 @@
|---|---|---|---| |---|---|---|---|
| ROUTE-001 | Routing 自上而下匹配,首条命中停止 | 3534-3798, 5825-6467 | `rule.go` 与不可变/首命中单测 | | ROUTE-001 | Routing 自上而下匹配,首条命中停止 | 3534-3798, 5825-6467 | `rule.go` 与不可变/首命中单测 |
| ROUTE-002 | Routing 与 Upstream 生命周期解耦 | 3534-3798 | 包依赖与配置模型 | | ROUTE-002 | Routing 与 Upstream 生命周期解耦 | 3534-3798 | 包依赖与配置模型 |
| ROUTE-003 | 支持 sequential、random、roundRobin、weighted、leastConnections | 5825-6467 | 五种领域策略及单测已完成;配置到 Gateway/Distribution 运行链的接线待完成 | | 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 并发测试已完成;持久化恢复和跨实例 CAS 待完成 | | ROUTE-004 | Sequential 连续空结果达到阈值后原子切换一次 | 5295-5824, 6520-6617 | `RoutingCursor` 版本 CAS 与 100 并发测试 |
| ROUTE-005 | 空计数属于 Upstream当前选择属于 Routing | 8442-8529 | 共享 `UpstreamEmptyState` 双 Routing 测试 | | ROUTE-005 | 空计数属于 Upstream当前选择属于 Routing | 8442-8529 | 共享 `UpstreamEmptyState` 双 Routing 测试 |
| ROUTE-006 | 旧 Upstream 已有 Proxy 继续耗尽,不因切换直接丢弃 | 6618-6641 | 通用 ownership Drain/ACK 原语已测试Routing 切换到 Drain 的编排待完成 | | ROUTE-006 | 旧 Upstream 已有 Proxy 继续耗尽,不因切换直接丢弃 | 6618-6641 | Drain 测试 |
| ROUTE-007 | 无可用 Upstream 时显式 reject、wait 或 direct默认 reject | 5075-5294, 6743-6760 | 三种动作的配置校验已完成Gateway 当前仅返回 503默认化及 wait/direct 运行时待完成 | | ROUTE-007 | 无可用 Upstream 时显式 reject、wait 或 direct默认 reject | 5075-5294, 6743-6760 | 配置默认值与端到端测试 |
## Provider 与补池 ## Provider 与补池
@ -32,7 +32,7 @@
| FETCH-001 | 每个 Provider 有独立 requestInterval、maxInFlight、timeout 和 retry | 968-2394 | `provider/reconciler_test.go` | | 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-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-003 | 错误使用指数退避和抖动429 尊重 Retry-After | 1601-1831, 8808-8856 | `provider/reconciler_test.go``providerapi/http_adapter_test.go` |
| FETCH-004 | Provider 获取由单逻辑 Leader 执行 | 1403-1580 | 单进程 Reconciler 已完成Redis Leader 租约及多实例互斥测试待完成 | | FETCH-004 | Provider 获取由单逻辑 Leader 执行 | 1403-1580 | 多实例锁测试 |
| FETCH-005 | Empty 与 Error 分开;只有合法候选为零时 Empty++ | 8442-8529 | `fetch_result_test.go` 分类矩阵 | | FETCH-005 | Empty 与 Error 分开;只有合法候选为零时 Empty++ | 8442-8529 | `fetch_result_test.go` 分类矩阵 |
| FETCH-006 | 重复候选不当作 Empty记录独立指标 | 8442-8480 | DuplicateOnly 分类与 Provider 测试 | | FETCH-006 | 重复候选不当作 Empty记录独立指标 | 8442-8480 | DuplicateOnly 分类与 Provider 测试 |
| FETCH-007 | 模板限制响应大小、执行时间、函数集和外部访问 | 8808-8856 | `providerapi/template_parser_test.go` 输入、输出、候选、超时、递归与函数白名单测试 | | FETCH-007 | 模板限制响应大小、执行时间、函数集和外部访问 | 8808-8856 | `providerapi/template_parser_test.go` 输入、输出、候选、超时、递归与函数白名单测试 |
@ -45,8 +45,8 @@
| PROXY-001 | Proxy 保存协议、地址、凭据引用、来源、TTL、健康、容量和标签 | 71-105, 8605-8678 | Domain 类型与序列化测试 | | PROXY-001 | Proxy 保存协议、地址、凭据引用、来源、TTL、健康、容量和标签 | 71-105, 8605-8678 | Domain 类型与序列化测试 |
| PROXY-002 | 唯一键包含 scheme、host、port、username、credentialVersion | 6655-6727, 8605-8678 | 去重单测 | | PROXY-002 | 唯一键包含 scheme、host、port、username、credentialVersion | 6655-6727, 8605-8678 | 去重单测 |
| PROXY-003 | TTL 来源优先级明确并统一 UTC | 681-747, 8655-8678 | TTL 表驱动测试 | | PROXY-003 | TTL 来源优先级明确并统一 UTC | 681-747, 8655-8678 | TTL 表驱动测试 |
| CAP-001 | Gateway 分配使用 Reserved -> Active 原子转换 | 1203-1467, 8530-8597 | 固定 Max 下打包 CAS 与 1,000 并发不超卖已完成;动态降容和完整生命周期证据待完成 | | CAP-001 | Gateway 分配使用 Reserved -> Active 原子转换 | 1203-1467, 8530-8597 | 高并发竞态测试 |
| CAP-002 | 补池依据 Available Slots不只看 Proxy 数量 | 1203-1402, 8530-8597 | TTL/状态/Active/Reserved 的 `AvailableSlots` 与 Reconciler 已测试ownership、目标健康及 Gateway reserve 聚合待完成 | | CAP-002 | 补池依据 Available Slots不只看 Proxy 数量 | 1203-1402, 8530-8597 | `Inventory.AvailableSlots` 与 Pool Reconciler 测试 |
| CAP-003 | pool.maxSize 包括 FETCHED/CHECKING/AVAILABLE/SUSPECT/DRAINING 与 pending expected | 3001-3533, 6642-6680 | `FetchBudget` 100 并发额度预占测试 | | 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-004 | TTL safety margin 内禁止新分配 | 173-220, 6728-6741 | 时钟测试 |
| CAP-005 | 多 Worker 不在热路径访问 Redis 计数 | 1403-1467 | Gateway 包依赖审计、Snapshot/Dispatch 测试 | | CAP-005 | 多 Worker 不在热路径访问 Redis 计数 | 1403-1467 | Gateway 包依赖审计、Snapshot/Dispatch 测试 |
@ -78,13 +78,12 @@
| ID | 最终需求 | 来源 | 验证证据 | | ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---| |---|---|---|---|
| HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | Protobuf/配置契约已定义;目标 Profile 与健康 Reducer 待实现 | | HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | 健康 reducer 测试 |
| HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 配置校验已完成;有界调度器、抖动和分级频率测试待实现 | | HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 调度测试 |
| HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | Proxy 状态迁移骨架已完成;连续失败 Reducer 与活动池原子提交待实现 | | HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | 状态机测试 |
| SEC-001 | API 认证与 Proxy 认证分离Secret 统一脱敏 | 7528-8111, 8904-8945 | Config 脱敏、Provider Store -> SecretRef -> Gateway Resolver 跨包测试与格式化泄漏回归测试 | | SEC-001 | API 认证与 Proxy 认证分离Secret 统一脱敏 | 7528-8111, 8904-8945 | Config 脱敏、Provider Store -> SecretRef -> Gateway Resolver 跨包测试与格式化泄漏回归测试 |
| SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 | | SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 |
| OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 | | OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 |
| OPS-002 | 优雅停机停止新请求/Fetch等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 | | OPS-002 | 优雅停机停止新请求/Fetch等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 |
| OPS-003 | PostgreSQL 只保存管理修订、Upstream/Routing 状态、Admin 审计与 Outbox | 当前会话 | ADR-006、`adminstate` 公用契约和六表 Schema 边界测试;真实 PostgreSQL 契约待完成 | | OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | 指标描述符测试 |
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | 文档和配置已约束Prometheus 指标模块及描述符测试待实现 |
| TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 | | TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 |

View File

@ -1,150 +0,0 @@
# 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,7 +8,6 @@
- **领域单测**状态机、TTL、路由、容量、Fetch 分类和 Extraction 原子性。 - **领域单测**状态机、TTL、路由、容量、Fetch 分类和 Extraction 原子性。
- **契约测试**配置、OpenAPI、Protobuf 和 Provider Adapter fixture。 - **契约测试**配置、OpenAPI、Protobuf 和 Provider Adapter fixture。
- **文档契约**:相对链接必须可解析,公开 Go 命令必须指向仓库内现有目标。
- **集成测试**Redis 活动池原子契约、PostgreSQL 管理事务、Leader/限流、 - **集成测试**Redis 活动池原子契约、PostgreSQL 管理事务、Leader/限流、
Outbox 与重建。 Outbox 与重建。
- **端到端测试**HTTP、CONNECT、Admin、Distribution 和优雅停机。 - **端到端测试**HTTP、CONNECT、Admin、Distribution 和优雅停机。
@ -38,9 +37,6 @@ go test -race ./internal/...
go build ./... go build ./...
``` ```
`go test ./docs` 自动扫描 README、设计/部署文档和公开执行指南,拒绝失效的
相对链接以及指向尚不存在 Go 入口的运行命令。
真实 Redis 8.2 活动池契约使用独立 Compose fixture 真实 Redis 8.2 活动池契约使用独立 Compose fixture
```powershell ```powershell

View File

@ -33,8 +33,6 @@
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。 - Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。 - Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。 - OpenAPI 错误模型、认证矩阵、批量 fulfillment。
- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
### 集成与端到端 ### 集成与端到端
@ -50,8 +48,7 @@
1. **并发容量**1000 协程争用同一 Proxy始终满足 1. **并发容量**1000 协程争用同一 Proxy始终满足
`active + reserved <= effectiveMaxConcurrency` `active + reserved <= effectiveMaxConcurrency`
2. **Reservation 生命周期**Dial 成功/失败、超时、取消和重复 Release 均不 2. **Reservation 生命周期**Dial 成功/失败、超时、取消和重复 Release 均不
泄漏或产生负计数。领域层已覆盖 Cancel、重复终结、错误顺序和同一 泄漏或产生负计数。
Reservation 并发终结Gateway Handler 覆盖建连失败、重试和请求取消。
3. **singleflight**100 个缺池信号只产生一个有效 Fetch 调度。 3. **singleflight**100 个缺池信号只产生一个有效 Fetch 调度。
4. **Provider 限流**requestInterval、maxInFlight、timeout、重试和 429 4. **Provider 限流**requestInterval、maxInFlight、timeout、重试和 429
`Retry-After` 在虚拟时钟下准确。 `Retry-After` 在虚拟时钟下准确。
@ -79,9 +76,6 @@ go vet ./...
go build ./... go build ./...
``` ```
`go test ./docs` 是文档契约门禁:递归校验相对链接,并确认用户指南中的
`go run`/`go build` 具体目标真实存在;通配包命令继续由 Go 工具链验证。
Redis 活动池 Adapter 与内存参考实现共享同一套公用行为契约。真实 Redis 8.2 Redis 活动池 Adapter 与内存参考实现共享同一套公用行为契约。真实 Redis 8.2
fixture 的执行命令是: fixture 的执行命令是:
@ -94,25 +88,6 @@ fixture 的执行命令是:
100 轮并发提取和 100 轮所有权竞争。fixture 使用唯一命名空间,不执行 100 轮并发提取和 100 轮所有权竞争。fixture 使用唯一命名空间,不执行
`FLUSHDB`;本地 Redis 关闭 AOF、RDB 和数据卷,避免短效 Proxy 与凭据落盘。 `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 的测试使用独立实例和短生命周期容器,不复用开发数据。 需要 PostgreSQL/Redis 的测试使用独立实例和短生命周期容器,不复用开发数据。
测试结束后验证没有残留 Worker ownership、Leader 租约、活动池条目或幂等键, 测试结束后验证没有残留 Worker ownership、Leader 租约、活动池条目或幂等键,
并检查 PostgreSQL 中不存在 Proxy 明细和逐次提取记录。 并检查 PostgreSQL 中不存在 Proxy 明细和逐次提取记录。

View File

@ -82,104 +82,3 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两
- Provider 超时不得计入 Empty Fetch。 - Provider 超时不得计入 Empty Fetch。
- 重复代理不得重复入池,也不得触发空结果切换。 - 重复代理不得重复入池,也不得触发空结果切换。
- 所有 Upstream 不可用时按显式策略执行。 - 所有 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,18 +3,11 @@ module proxy-pool
go 1.26.0 go 1.26.0
require ( require (
github.com/jackc/pgx/v5 v5.6.0
github.com/redis/go-redis/v9 v9.19.0 github.com/redis/go-redis/v9 v9.19.0
go.yaml.in/yaml/v4 v4.0.0-rc.3 go.yaml.in/yaml/v4 v4.0.0-rc.3
) )
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect 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 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,43 +4,21 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= 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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= 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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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

@ -1,103 +0,0 @@
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

@ -1,240 +0,0 @@
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

@ -1,72 +0,0 @@
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

@ -1,83 +0,0 @@
//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

@ -1,47 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,83 +0,0 @@
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

@ -1,112 +0,0 @@
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

@ -1,475 +0,0 @@
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

@ -1,179 +0,0 @@
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

@ -1,142 +0,0 @@
//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

@ -1,222 +0,0 @@
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

@ -1,98 +0,0 @@
//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 - name: extract
enabled: true enabled: true
purpose: extract purpose: extract
upstreams: [provider-a, provider-b] upstreams: [provider-a]
strategy: strategy:
type: sequential type: sequential
switchAfterEmptyFetch: 5 switchAfterEmptyFetch: 5
@ -43,7 +43,7 @@ routing:
onUnavailable: onUnavailable:
action: reject action: reject
upstreams: upstreams:
provider-a: &valid-upstream provider-a:
enabled: true enabled: true
exposure: [gateway, extract] exposure: [gateway, extract]
provider: provider:
@ -78,7 +78,6 @@ upstreams:
maxAttempts: 2 maxAttempts: 2
maxConsecutiveFailures: 3 maxConsecutiveFailures: 3
urls: [http://connect.rom.miui.com/generate_204] urls: [http://connect.rom.miui.com/generate_204]
provider-b: *valid-upstream
` `
func TestLoadStrictValidConfiguration(t *testing.T) { func TestLoadStrictValidConfiguration(t *testing.T) {
@ -134,7 +133,7 @@ func TestValidateRejectsUnprotectedPublicListener(t *testing.T) {
} }
func TestValidateRejectsMissingUpstreamReference(t *testing.T) { func TestValidateRejectsMissingUpstreamReference(t *testing.T) {
broken := strings.Replace(validConfig, "upstreams: [provider-a, provider-b]", "upstreams: [provider-a, missing]", 1) broken := strings.Replace(validConfig, "upstreams: [provider-a]", "upstreams: [missing]", 1)
_, err := Load(strings.NewReader(broken)) _, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "missing") { if err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Load() error = %v, want missing upstream error", err) t.Fatalf("Load() error = %v, want missing upstream error", err)
@ -444,13 +443,6 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) {
}, },
want: "endBehavior", 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", name: "weighted strategy missing weight",
mutate: func(cfg *Config) { mutate: func(cfg *Config) {
@ -464,10 +456,9 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) {
cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{ cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{
"provider-a": 1, "provider-a": 1,
"provider-b": 1, "provider-b": 1,
"provider-c": 1,
}} }}
}, },
want: "provider-c", want: "provider-b",
}, },
{ {
name: "weighted strategy nonpositive weight", name: "weighted strategy nonpositive weight",

View File

@ -1,43 +0,0 @@
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

@ -1,89 +0,0 @@
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,9 +187,6 @@ func validateStrategy(scope string, upstreams []string, strategy Strategy) error
return err return err
} }
if strategy.Type == "sequential" { 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 { if err := requirePositive(scope+" strategy.switchAfterEmptyFetch", strategy.SwitchAfterEmptyFetch); err != nil {
return err return err
} }

View File

@ -1,84 +0,0 @@
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

@ -1,156 +0,0 @@
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) ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
} }
type IdentityResolver interface { type Authorizer interface {
Resolve(*http.Request) (httpsecurity.Identity, error) Check(context.Context, *http.Request) error
} }
var _ IdentityResolver = (*httpsecurity.Protection)(nil) var _ Authorizer = (*httpsecurity.Protection)(nil)
type Options struct { type Options struct {
MaxBodyBytes int64 MaxBodyBytes int64
@ -80,16 +80,12 @@ type MutationResult struct {
type SetUpstreamCommand struct { type SetUpstreamCommand struct {
RequestID string RequestID string
ActorID string
SourceIP string
Name string Name string
Enabled bool Enabled bool
} }
type SwitchCommand struct { type SwitchCommand struct {
RequestID string `json:"-"` RequestID string `json:"-"`
ActorID string `json:"-"`
SourceIP string `json:"-"`
Name string `json:"-"` Name string `json:"-"`
ExpectedCurrent string `json:"expectedCurrent"` ExpectedCurrent string `json:"expectedCurrent"`
Target string `json:"target"` Target string `json:"target"`
@ -98,21 +94,19 @@ type SwitchCommand struct {
type ReloadCommand struct { type ReloadCommand struct {
RequestID string RequestID string
ActorID string
SourceIP string
} }
type Handler struct { type Handler struct {
service Service service Service
identity IdentityResolver authorizer Authorizer
maxBodyBytes int64 maxBodyBytes int64
} }
func NewHandler(service Service, identity IdentityResolver, options Options) (*Handler, error) { func NewHandler(service Service, authorizer Authorizer, options Options) (*Handler, error) {
if service == nil || identity == nil || options.MaxBodyBytes <= 0 { if service == nil || authorizer == nil || options.MaxBodyBytes <= 0 {
return nil, ErrInvalidHandler return nil, ErrInvalidHandler
} }
return &Handler{service: service, identity: identity, maxBodyBytes: options.MaxBodyBytes}, nil return &Handler{service: service, authorizer: authorizer, maxBodyBytes: options.MaxBodyBytes}, nil
} }
func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@ -121,8 +115,7 @@ 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) writeTransportProblem(writer, http.StatusBadRequest, "INVALID_REQUEST_ID", "Invalid request ID", "X-Request-ID is invalid", requestID)
return return
} }
identity, err := handler.identity.Resolve(request) if err := handler.authorizer.Check(request.Context(), request); err != nil {
if err != nil {
if !httpsecurity.WriteProblem(writer, requestID, err) { if !httpsecurity.WriteProblem(writer, requestID, err) {
writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID) writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID)
} }
@ -140,7 +133,7 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
if !requireMethod(writer, request, http.MethodPost, requestID) { if !requireMethod(writer, request, http.MethodPost, requestID) {
return return
} }
handler.reload(writer, request, requestID, identity) handler.reload(writer, request, requestID)
return return
} }
@ -148,14 +141,14 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
if !requireMethod(writer, request, http.MethodPost, requestID) { if !requireMethod(writer, request, http.MethodPost, requestID) {
return return
} }
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID, identity) handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID)
return return
} }
if name, _, ok := matchNamedAction(request.URL.Path, routingPrefix, "switch"); ok { if name, _, ok := matchNamedAction(request.URL.Path, routingPrefix, "switch"); ok {
if !requireMethod(writer, request, http.MethodPost, requestID) { if !requireMethod(writer, request, http.MethodPost, requestID) {
return return
} }
handler.switchRouting(writer, request, name, requestID, identity) handler.switchRouting(writer, request, name, requestID)
return return
} }
@ -178,18 +171,9 @@ func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Requ
_ = httpapi.WriteJSON(writer, http.StatusOK, status) _ = httpapi.WriteJSON(writer, http.StatusOK, status)
} }
func (handler *Handler) setUpstreamEnabled( func (handler *Handler) setUpstreamEnabled(writer http.ResponseWriter, request *http.Request, name string, enabled bool, requestID string) {
writer http.ResponseWriter,
request *http.Request,
name string,
enabled bool,
requestID string,
identity httpsecurity.Identity,
) {
result, err := handler.service.SetUpstreamEnabled(request.Context(), SetUpstreamCommand{ result, err := handler.service.SetUpstreamEnabled(request.Context(), SetUpstreamCommand{
RequestID: requestID, RequestID: requestID,
ActorID: identity.ClientID,
SourceIP: identity.SourceIP,
Name: name, Name: name,
Enabled: enabled, Enabled: enabled,
}) })
@ -200,13 +184,7 @@ func (handler *Handler) setUpstreamEnabled(
writeMutation(writer, result, requestID) writeMutation(writer, result, requestID)
} }
func (handler *Handler) switchRouting( func (handler *Handler) switchRouting(writer http.ResponseWriter, request *http.Request, name, requestID string) {
writer http.ResponseWriter,
request *http.Request,
name string,
requestID string,
identity httpsecurity.Identity,
) {
var command SwitchCommand var command SwitchCommand
if err := httpapi.DecodeJSON(writer, request, handler.maxBodyBytes, &command); err != nil { if err := httpapi.DecodeJSON(writer, request, handler.maxBodyBytes, &command); err != nil {
writeDecodeProblem(writer, err, requestID) writeDecodeProblem(writer, err, requestID)
@ -219,8 +197,6 @@ func (handler *Handler) switchRouting(
return return
} }
command.RequestID = requestID command.RequestID = requestID
command.ActorID = identity.ClientID
command.SourceIP = identity.SourceIP
command.Name = name command.Name = name
result, err := handler.service.SwitchRouting(request.Context(), command) result, err := handler.service.SwitchRouting(request.Context(), command)
if err != nil { if err != nil {
@ -230,17 +206,8 @@ func (handler *Handler) switchRouting(
writeMutation(writer, result, requestID) writeMutation(writer, result, requestID)
} }
func (handler *Handler) reload( func (handler *Handler) reload(writer http.ResponseWriter, request *http.Request, requestID string) {
writer http.ResponseWriter, result, err := handler.service.ReloadConfiguration(request.Context(), ReloadCommand{RequestID: requestID})
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 { if err != nil {
writeServiceProblem(writer, err, requestID) writeServiceProblem(writer, err, requestID)
return return

View File

@ -128,9 +128,6 @@ func TestHandlerEnablesAndDisablesUpstream(t *testing.T) {
if service.lastUpstream.Name != "provider-a" || service.lastUpstream.Enabled != test.enabled || service.lastUpstream.RequestID != "req-admin" { if service.lastUpstream.Name != "provider-a" || service.lastUpstream.Enabled != test.enabled || service.lastUpstream.RequestID != "req-admin" {
t.Fatalf("unexpected service call: %+v", service.lastUpstream) 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" { if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-admin" {
t.Fatalf("response request ID = %q, want req-admin", requestID) t.Fatalf("response request ID = %q, want req-admin", requestID)
} }
@ -155,9 +152,6 @@ func TestHandlerSwitchesRoutingWithStrictJSON(t *testing.T) {
if service.lastSwitch.Name != "checkout" || service.lastSwitch.ExpectedCurrent != "provider-a" || service.lastSwitch.Target != "provider-b" { if service.lastSwitch.Name != "checkout" || service.lastSwitch.ExpectedCurrent != "provider-a" || service.lastSwitch.Target != "provider-b" {
t.Fatalf("unexpected switch call: command=%+v", service.lastSwitch) 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) { func TestHandlerReloadsConfigurationWithCommandRequestID(t *testing.T) {
@ -176,9 +170,6 @@ func TestHandlerReloadsConfigurationWithCommandRequestID(t *testing.T) {
if service.lastReload.RequestID != "req-reload" { if service.lastReload.RequestID != "req-reload" {
t.Fatalf("reload command = %+v", service.lastReload) 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"`) { if !strings.Contains(recorder.Body.String(), `"requestId":"req-reload"`) {
t.Fatalf("unexpected reload response %q", recorder.Body.String()) t.Fatalf("unexpected reload response %q", recorder.Body.String())
} }
@ -307,14 +298,12 @@ func (service *stubService) ReloadConfiguration(_ context.Context, command Reloa
type allowAuthorizer struct{} type allowAuthorizer struct{}
func (allowAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) { func (allowAuthorizer) Check(context.Context, *http.Request) error { return nil }
return httpsecurity.Identity{ClientID: "admin:test", SourceIP: "192.0.2.10"}, nil
}
type rejectAuthorizer struct{} type rejectAuthorizer struct{}
func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) { func (rejectAuthorizer) Check(context.Context, *http.Request) error {
return httpsecurity.Identity{}, &httpsecurity.HTTPError{ return &httpsecurity.HTTPError{
StatusCode: http.StatusUnauthorized, StatusCode: http.StatusUnauthorized,
Code: "UNAUTHORIZED", Code: "UNAUTHORIZED",
Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}}, Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}},

View File

@ -1,281 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,513 +0,0 @@
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

@ -1,394 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,413 +0,0 @@
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

@ -1,414 +0,0 @@
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

@ -1,211 +0,0 @@
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

@ -1,191 +0,0 @@
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,33 +96,6 @@ 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) { func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) {
empty := NewUpstreamEmptyState() empty := NewUpstreamEmptyState()
first, err := NewSequentialWithState([]string{"a", "b"}, 5, EndStayLast, empty) first, err := NewSequentialWithState([]string{"a", "b"}, 5, EndStayLast, empty)
@ -149,18 +122,15 @@ func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) {
} }
func TestSequentialStopEndBehaviorHasNoCurrentSelection(t *testing.T) { func TestSequentialStopEndBehaviorHasNoCurrentSelection(t *testing.T) {
sequence, err := NewSequentialWithState([]string{"a", "b"}, 1, EndStop, NewUpstreamEmptyState()) sequence, err := NewSequentialWithState([]string{"a"}, 1, EndStop, NewUpstreamEmptyState())
if err != nil { if err != nil {
t.Fatalf("NewSequentialWithState(): %v", err) t.Fatalf("NewSequentialWithState(): %v", err)
} }
if !sequence.ObserveEmpty("a") { if !sequence.ObserveEmpty("a") {
t.Fatal("ObserveEmpty(a) = false, want transition to b") t.Fatal("ObserveEmpty() = false, want transition to stopped")
} }
if !sequence.ObserveEmpty("b") { if current, ok, version := sequence.CurrentSelection(); ok || current != "" || version != 2 {
t.Fatal("ObserveEmpty(b) = false, want transition to stopped") t.Fatalf("CurrentSelection() = %q, %v, %d; want stopped version 2", current, ok, version)
}
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) { func NewSequential(upstreams []string, threshold int) (*Sequential, error) {
return NewSequentialWithState(upstreams, threshold, EndStop, NewUpstreamEmptyState()) return NewSequentialWithState(upstreams, threshold, EndStayLast, NewUpstreamEmptyState())
} }
func NewSequentialWithState( func NewSequentialWithState(
@ -141,14 +141,14 @@ func NewSequentialWithState(
end EndBehavior, end EndBehavior,
empty *UpstreamEmptyState, empty *UpstreamEmptyState,
) (*Sequential, error) { ) (*Sequential, error) {
if len(upstreams) < 2 { if len(upstreams) == 0 {
return nil, fmt.Errorf("sequential strategy requires at least two upstreams") return nil, fmt.Errorf("sequential strategy requires at least one upstream")
} }
if threshold <= 0 { if threshold <= 0 {
return nil, fmt.Errorf("sequential threshold must be greater than zero") return nil, fmt.Errorf("sequential threshold must be greater than zero")
} }
if end == "" { if end == "" {
end = EndStop end = EndStayLast
} }
if end != EndStayLast && end != EndStop && end != EndLoop { if end != EndStayLast && end != EndStop && end != EndLoop {
return nil, fmt.Errorf("sequential end behavior %q is invalid", end) return nil, fmt.Errorf("sequential end behavior %q is invalid", end)

View File

@ -1,82 +1,7 @@
# 项目进度 # 项目进度
## 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 ## 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、 - 已实现共享 `httpapi`、`httpsecurity` 与 `httpserver`,统一严格 JSON、Problem、
Request ID、认证、可信代理、Client ID、准入、多监听器生命周期和优雅停机。 Request ID、认证、可信代理、Client ID、准入、多监听器生命周期和优雅停机。
- Distribution/Admin Handler 已装配到独立监听器Gateway 配置认证统一复用 - Distribution/Admin Handler 已装配到独立监听器Gateway 配置认证统一复用
@ -95,26 +20,9 @@
余量停止新分配。 余量停止新分配。
- PostgreSQL 管理面 Adapter、Provider Leader/分布式限流与心跳装配、生产命令 - PostgreSQL 管理面 Adapter、Provider Leader/分布式限流与心跳装配、生产命令
入口、Redis 故障转移验证和代表性 100,000 QPS 集群压测仍待实现。 入口、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 静态展开和 - 本轮 `.\scripts\verify.ps1`、`.\scripts\test-redis.ps1`、Compose 静态展开和
Compose 非持久化策略测试通过Windows `CGO_ENABLED=0`race 继续由 Linux Compose 非持久化策略测试通过Windows `CGO_ENABLED=0`race 继续由 Linux
CI 执行。 CI 执行。
- 本地 PostgreSQL 管理面基础已提交为 `7951c29`;推送
`build/proxy-pool-architecture` 时远端返回 `Authentication failed`,未重复执行
相同推送。当前本地提交保持完整,等待可用 Git 凭据后同步。
## 2026-07-28 ## 2026-07-28

View File

@ -1,34 +0,0 @@
$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,11 +2,10 @@ $ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot $repositoryRoot = Split-Path -Parent $PSScriptRoot
$composeFile = Join-Path $repositoryRoot "deploy/docker-compose.test.yml" $composeFile = Join-Path $repositoryRoot "deploy/docker-compose.test.yml"
$composeProject = "proxy-pool-redis-test"
$previousRedisURL = [Environment]::GetEnvironmentVariable("PROXY_POOL_TEST_REDIS_URL", "Process") $previousRedisURL = [Environment]::GetEnvironmentVariable("PROXY_POOL_TEST_REDIS_URL", "Process")
try { try {
docker compose -p $composeProject -f $composeFile up -d --wait --wait-timeout 60 redis docker compose -f $composeFile up -d --wait
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "starting Redis test fixture failed with exit code $LASTEXITCODE" throw "starting Redis test fixture failed with exit code $LASTEXITCODE"
} }
@ -30,5 +29,5 @@ finally {
else { else {
$env:PROXY_POOL_TEST_REDIS_URL = $previousRedisURL $env:PROXY_POOL_TEST_REDIS_URL = $previousRedisURL
} }
docker compose -p $composeProject -f $composeFile down --volumes --remove-orphans docker compose -f $composeFile down --remove-orphans
} }

View File

@ -1,51 +0,0 @@
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,13 +22,6 @@ if ($unformatted) {
Invoke-Step "go vet" { go vet ./... } Invoke-Step "go vet" { go vet ./... }
Invoke-Step "unit tests" { go test -timeout 60s ./... } 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") { if ((go env CGO_ENABLED) -eq "1") {
Invoke-Step "race tests" { go test -race -timeout 60s ./internal/... } Invoke-Step "race tests" { go test -race -timeout 60s ./internal/... }
} else { } else {

View File

@ -28,11 +28,6 @@
TTL 活动池契约PostgreSQL 退出代理数据路径 TTL 活动池契约PostgreSQL 退出代理数据路径
10. [已完成] 实现生产 Redis Activity Adapter、原子 Lua、公用行为契约和 10. [已完成] 实现生产 Redis Activity Adapter、原子 Lua、公用行为契约和
Redis 8.2 集成 fixture本地 Redis 禁止短效代理数据持久化 Redis 8.2 集成 fixture本地 Redis 禁止短效代理数据持久化
11. [已完成] 实现 PostgreSQL 管理面ADR、领域事务契约、MemoryStore、公用
契约、六表 Schema、Admin Actor 传播、应用服务、原子配置发布、pgx Adapter
和真实 PostgreSQL 18 集成测试已完成
12. [进行中] 复核验收清单并收敛既有 Routing/Sequential 与 Proxy 容量边界;
机器契约和文档类滞后勾选已按仓库证据校正
## 串并行关系 ## 串并行关系
@ -51,9 +46,7 @@
## 已知环境限制 ## 已知环境限制
- Docker Compose 配置与 Kubernetes Kustomize 已完成静态渲染验证;Redis 8.2 - Docker Compose 配置与 Kubernetes Kustomize 已完成静态渲染验证;未启动
与 PostgreSQL 18 的隔离 Adapter fixture 已运行,完整目标运行拓扑尚未启动 目标运行拓扑。
- `cmd/proxy-*`、PostgreSQL 管理面 Adapter、Provider Leader/分布式限流、 - `cmd/proxy-*`、PostgreSQL 管理面 Adapter、Provider Leader/分布式限流、
Checker 运行时、Redis 故障转移验证与代表性集群压测属于后续实施范围。 Checker 运行时、Redis 故障转移验证与代表性集群压测属于后续实施范围。
- `implementation-plan.md` 当前按 73 个验收项统计;已校正为 51 项完成,
验收项完成率约 69.9%,不等同于生产就绪度。