diff --git a/docs/database/README.md b/docs/database/README.md index 5f0008a..c812f5f 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -11,8 +11,8 @@ Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership re 依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见 [系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。 -本轮只有文档修订,registry adapter、旧 Instance 判定与测试仍待代码重构撤除;新增 Database -资源、绑定、导入、角色/凭据管理边界与回收链路尚未实现。wiki 同步位置见 +registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理 +能力观察直接判定 Ready。新增 Database 资源、绑定、导入、角色/凭据管理边界与回收链路尚未实现。wiki 同步位置见 `homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。 ## 来源基线 @@ -35,7 +35,7 @@ Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership re 代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新; 首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新 验证或删除时失效,且支持判定不授权 Tenant provisioning。已有 Ready 切片曾按原合同加入 -registry 准备决策;这一依赖现已撤销,后续须从代码移除,不能把旧运行链路接回模型。 +registry 准备决策;这一依赖现已从代码移除,不能把旧运行链路接回模型。 各层验证边界见 [Instance 领域规格](domain-instance.md)。 ## 边界 @@ -97,15 +97,16 @@ checkpoint/status/finalizer 链路仍是后续切片。 单元测试补充成功空列表、查询附带部分数据时丢弃、结果与可变 slice 隔离、每轮重新读取和 generation 变化时的目标绑定;原凭据/TLS/超时/并发测试沿同一 metadata 路径继续运行。 -## 待撤换的 registry 实现 +## Registry 撤除 -`adapter/postgresql/registry` 曾从固定来源基线迁入,使用调用方提供的 pgx 连接和 tern -迁移;已有测试覆盖唯一约束、Retain 墓碑和事务结果不确定的恢复。这是旧实现状态, -不再是新领域需求,也未装配到 InstanceService。 +原 `adapter/postgresql/registry`、SQL 迁移、registry 专属集成测试及 tern 依赖已删除; +未提交的 inspection 实验也已撤除。来源仍可在 Git 历史追溯,没有删除外部 PostgreSQL 对象。 +Instance 不再具有 InitializingRegistry 阶段、RegistryState、准备决策或回读方法; +首次完整管理能力观察即可完成验证,重验失败则撤销本轮就绪证据。 -工作树中另有未提交的 registry inspection 实验及测试,本轮保留,不继续接入或宣称完成。 -后续代码变更撤除 registry adapter、Instance 相关状态/方法和仅服务旧合同的测试与依赖, -保留凭据、TLS、metadata 与真实权限验证的有效部分。不操作现有 PostgreSQL 服务或旧仓库工作树。 +保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力 +在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。 +这不等于管理权限探测矩阵或三资源 controller 已实现。 ## 设计入口 diff --git a/docs/database/development.md b/docs/database/development.md index fe264a0..9b8e32b 100644 --- a/docs/database/development.md +++ b/docs/database/development.md @@ -6,8 +6,7 @@ ## 当前设计验收(2026-09-24) [ADR-0009](../decisions/0009-database-resource-and-claim.md) 将资源生命周期从 Tenant 中分离。 -新增验收矩阵见 [系统规格](specification.md#11-验收)。旧 registry 测试仅说明历史代码行为, -不再是完成标准;代码重构时撤除,不继续 schema 审计或自动所有权恢复切片。 +新增验收矩阵见 [系统规格](specification.md#11-验收)。registry 实现、专属测试与迁移依赖已撤除,不继续 schema 审计或自动所有权恢复切片。 | 层次 | 本次设计要求 | | --- | --- | @@ -21,7 +20,7 @@ Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的 envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。 新增资源 API 尚未实现,本轮没有完成或运行这些新增行为测试。 -## Ayatori 已接入的凭据、metadata 与历史 registry 切片测试 +## Ayatori 已接入的凭据与 metadata 切片测试 本节命令已在 Ayatori 接入;以下历史 Compose/Kind 操作仍属于迁入的目标合同。 @@ -51,12 +50,8 @@ metadata 测试验证版本与可用扩展的只读查询,包括未安装扩 权限撤回只修改每个场景自建 PostgreSQL 容器的 ACL;不连接现有服务。 可用列表不等于安装权限,这些检查不替代后续的完整管理权限矩阵或 Instance Ready 验收。 -registry 测试独立使用一次性 PostgreSQL,不启动 Kubernetes API server。覆盖重复和并发迁移、 -所有权唯一约束、并发占用、Retain 墓碑与 Delete 幂等、连接重建、取消恢复、未知 schema 与 -超前版本拒绝。通过在真实 COMMIT 成功后注入客户端错误,验证结果不确定时的重试;这不替代 -真实网络故障测试,也不覆盖跨后端的删除步骤。 - -工作树中未提交的 registry 回读实验不再继续;本轮保留其文件,不把实验测试作为新设计验收。 +Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的 +未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。 这些测试尚不包含 Instance CRD/controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。版本查询成功不意味着 Instance Ready。 @@ -175,8 +170,8 @@ make test-integration ``` 该 target 会启动一次性 Compose 依赖,并通过 `POSTGRES_TEST_DSN` 把测试指向开发 -PostgreSQL。registry 测试会删除并重建固定的测试 schema,因此禁止将该变量指向真实 -homelab database。测试后运行 `make dev-down` 清理依赖。 +PostgreSQL。这是旧环境设计,不是当前 Ayatori 入口;当前 fixture 不接受外部 DSN, +使用本页前部的 `make test-database-integration`,禁止把测试指向真实 homelab database。 Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问 Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络, diff --git a/docs/database/domain-instance.md b/docs/database/domain-instance.md index 920a03c..e163927 100644 --- a/docs/database/domain-instance.md +++ b/docs/database/domain-instance.md @@ -2,7 +2,7 @@ 日期:2026-09-24。资源模型修订依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以 -[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同,不表示代码已改完。 +[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,完整应用/controller 链路尚未接入。 ## 职责 @@ -83,6 +83,6 @@ finalizer 不阻止并发申请 CR 创建;新请求见 Instance 删除中/不 真实 API server 验证 Secret、resourceVersion、watch、finalizer;真实 PostgreSQL 验证 权限、TLS、凭据更新和查询失败,不以领域布尔值或 server_version 查询代替管理权限验收。 -现有 Instance 领域代码仍含 registry 判定,现有 registry adapter 和测试也尚未撤除。 -本轮只修订文档;后续代码重构必须删除 registry 依赖并重写对应测试,不能因旧测试通过 -就声称新 Instance Ready 或三资源生命周期已完成。 +Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察 +直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项 +在这两个入口的失败与恢复,但完整权限探测、Instance controller 和三资源生命周期尚未完成。 diff --git a/go.mod b/go.mod index 01c9113..73f301d 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.27.1 require ( github.com/jackc/pgx/v5 v5.11.0 - github.com/jackc/tern/v2 v2.4.3 k8s.io/api v0.37.0 k8s.io/apimachinery v0.37.0 k8s.io/client-go v0.37.0 @@ -13,10 +12,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect - dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -49,14 +45,11 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -65,8 +58,6 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -83,7 +74,6 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index f8da443..4c42cc0 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,7 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -31,8 +25,6 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= @@ -97,8 +89,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -109,22 +99,12 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jackc/tern/v2 v2.4.3 h1:g293d3OZgW7OFEhsYXgEv0C21jea2boNr0VR4K5I7OY= -github.com/jackc/tern/v2 v2.4.3/go.mod h1:rMpMuRYcff5wWLptoTSO1qcDxJ4OodysvK17i2SVBys= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -150,13 +130,7 @@ github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLA github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -167,8 +141,8 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -202,8 +176,6 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= diff --git a/internal/database/adapter/postgresql/metadata_integration_test.go b/internal/database/adapter/postgresql/metadata_integration_test.go index 6eb5fb9..dc46b1a 100644 --- a/internal/database/adapter/postgresql/metadata_integration_test.go +++ b/internal/database/adapter/postgresql/metadata_integration_test.go @@ -37,6 +37,7 @@ func TestMetadataObservesAvailableExtensionsWithoutInstalling(t *testing.T) { // 提供同名遮蔽对象,验证 adapter 不依赖管理账号可修改的 search_path。 f.queryPostgres(t, "CREATE VIEW public.pg_available_extensions AS SELECT 'fake_extension'::name AS name") f.queryPostgres(t, "ALTER ROLE postgres SET search_path = public, pg_catalog") + schemasBefore := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace") observed, err := f.service.ObserveMetadata(f.ctx, f.target) if err != nil { t.Fatal(err) @@ -56,8 +57,8 @@ func TestMetadataObservesAvailableExtensionsWithoutInstalling(t *testing.T) { if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" { t.Fatal("metadata observation installed an extension") } - if schemas := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_namespace WHERE nspname = 'postgresql_tenant_operator'"); schemas != "0" { - t.Fatal("metadata observation initialized the registry") + if schemas := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace"); schemas != schemasBefore { + t.Fatal("metadata observation changed database schemas") } aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false) diff --git a/internal/database/adapter/postgresql/registry/migrations/001_create_registry.sql b/internal/database/adapter/postgresql/registry/migrations/001_create_registry.sql deleted file mode 100644 index 24889bb..0000000 --- a/internal/database/adapter/postgresql/registry/migrations/001_create_registry.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE SCHEMA postgresql_tenant_operator; - -CREATE TABLE postgresql_tenant_operator.tenant_ownership ( - instance_uid text NOT NULL, - tenant_uid text NOT NULL, - tenant_namespace text NOT NULL, - tenant_name text NOT NULL, - database_name text NOT NULL, - role_name text NOT NULL, - credential_path text NOT NULL, - managed boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT clock_timestamp(), - updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), - retained_at timestamptz, - PRIMARY KEY (instance_uid, tenant_uid), - UNIQUE (instance_uid, tenant_namespace, tenant_name), - UNIQUE (instance_uid, database_name), - UNIQUE (instance_uid, role_name), - UNIQUE (credential_path), - CHECK (managed OR retained_at IS NOT NULL) -); - ----- create above / drop below ---- - -DROP SCHEMA postgresql_tenant_operator CASCADE; diff --git a/internal/database/adapter/postgresql/registry/registry.go b/internal/database/adapter/postgresql/registry/registry.go deleted file mode 100644 index 12a60bb..0000000 --- a/internal/database/adapter/postgresql/registry/registry.go +++ /dev/null @@ -1,326 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Package registry persists controller ownership evidence in the PostgreSQL -// management database. It deliberately does not persist reconciliation phases; -// those belong to the Kubernetes resource status. -package registry - -import ( - "context" - "embed" - "errors" - "fmt" - "io/fs" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/tern/v2/migrate" -) - -const migrationVersionTable = "public.postgresql_tenant_operator_schema_version" - -//go:embed migrations/*.sql -var migrationFiles embed.FS - -var ( - // ErrNotFound indicates that the registry has no matching ownership record. - ErrNotFound = errors.New("registry ownership record not found") - // ErrConflict indicates that a requested name or path belongs to another Tenant UID. - ErrConflict = errors.New("registry ownership conflict") -) - -// Beginner is implemented by pgx.Conn and pgxpool.Pool. -type Beginner interface { - Begin(context.Context) (pgx.Tx, error) -} - -// Store manages ownership records in one PostgreSQLInstance management database. -type Store struct { - db Beginner -} - -// NewStore creates a registry store backed by a PostgreSQL connection or pool. -func NewStore(db Beginner) *Store { - return &Store{db: db} -} - -// Ownership identifies every external resource reserved for one Tenant UID. -type Ownership struct { - InstanceUID string - TenantUID string - TenantNamespace string - TenantName string - DatabaseName string - RoleName string - CredentialPath string -} - -// Record is the persisted ownership state. -type Record struct { - Ownership - Managed bool - CreatedAt time.Time - UpdatedAt time.Time - RetainedAt *time.Time -} - -// ClaimResult reports whether Claim inserted a new record or found the same claim. -type ClaimResult string - -const ( - ClaimCreated ClaimResult = "Created" - ClaimOwned ClaimResult = "Owned" -) - -// Bootstrap applies all pending versioned registry migrations idempotently. -func (s *Store) Bootstrap(ctx context.Context) error { - if s == nil || s.db == nil { - return errors.New("bootstrap registry: nil database") - } - - return s.withMigrationConnection(ctx, func(conn *pgx.Conn) error { - migrations, err := fs.Sub(migrationFiles, "migrations") - if err != nil { - return fmt.Errorf("open embedded registry migrations: %w", err) - } - migrator, err := migrate.NewMigrator(ctx, conn, migrationVersionTable) - if err != nil { - return fmt.Errorf("initialize registry migrator: %w", err) - } - if err := migrator.LoadMigrations(migrations); err != nil { - return fmt.Errorf("load registry migrations: %w", err) - } - if err := migrator.Migrate(ctx); err != nil { - return fmt.Errorf("apply registry migrations: %w", err) - } - return nil - }) -} - -// Claim reserves all names for an owner. Repeating an identical claim is idempotent. -func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error) { - if s == nil || s.db == nil { - return "", errors.New("claim registry ownership: nil database") - } - if err := owner.validate(); err != nil { - return "", err - } - - tx, err := s.db.Begin(ctx) - if err != nil { - return "", fmt.Errorf("begin registry claim: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - - _, err = scanRecord(tx.QueryRow(ctx, claimStatement, - owner.InstanceUID, - owner.TenantUID, - owner.TenantNamespace, - owner.TenantName, - owner.DatabaseName, - owner.RoleName, - owner.CredentialPath, - )) - if err != nil && !errors.Is(err, ErrNotFound) { - return "", fmt.Errorf("insert registry claim: %w", err) - } - if errors.Is(err, ErrNotFound) { - record, err := getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID) - if err != nil { - if errors.Is(err, ErrNotFound) { - return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict) - } - return "", err - } - if !record.equal(owner) || !record.Managed { - return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict) - } - if err := tx.Commit(ctx); err != nil { - return "", fmt.Errorf("commit registry claim: %w", err) - } - return ClaimOwned, nil - } - - if err := tx.Commit(ctx); err != nil { - return "", fmt.Errorf("commit registry claim: %w", err) - } - return ClaimCreated, nil -} - -func (s *Store) withMigrationConnection(ctx context.Context, run func(*pgx.Conn) error) error { - switch db := s.db.(type) { - case *pgx.Conn: - return run(db) - case *pgxpool.Pool: - conn, err := db.Acquire(ctx) - if err != nil { - return fmt.Errorf("acquire registry migration connection: %w", err) - } - defer conn.Release() - return run(conn.Conn()) - default: - return fmt.Errorf("bootstrap registry: database type %T cannot provide a migration connection", s.db) - } -} - -// Get returns the ownership record for an Instance UID and Tenant UID. -func (s *Store) Get(ctx context.Context, instanceUID, tenantUID string) (Record, error) { - if s == nil || s.db == nil { - return Record{}, errors.New("get registry record: nil database") - } - if instanceUID == "" || tenantUID == "" { - return Record{}, errors.New("get registry record: instance UID and tenant UID are required") - } - - tx, err := s.db.Begin(ctx) - if err != nil { - return Record{}, fmt.Errorf("begin registry read: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - - record, err := getByTenantUID(ctx, tx, instanceUID, tenantUID) - if err != nil { - return Record{}, err - } - if err := tx.Commit(ctx); err != nil { - return Record{}, fmt.Errorf("commit registry read: %w", err) - } - return record, nil -} - -// MarkRetained changes a matching managed ownership record into an unmanaged tombstone. -// Repeating the operation for the same tombstone is safe. -func (s *Store) MarkRetained(ctx context.Context, owner Ownership) error { - return s.changeOwnership(ctx, owner, "mark registry record retained", func(ctx context.Context, tx pgx.Tx, record Record) error { - if !record.Managed { - return nil - } - tag, err := tx.Exec(ctx, markRetainedStatement, owner.InstanceUID, owner.TenantUID) - if err != nil { - return err - } - if tag.RowsAffected() != 1 { - return ErrConflict - } - return nil - }) -} - -// Delete removes a matching managed record after its external resources have been deleted. -// An already absent record is treated as a successful retry; a retained record is never deleted. -func (s *Store) Delete(ctx context.Context, owner Ownership) error { - return s.changeOwnership(ctx, owner, "delete registry record", func(ctx context.Context, tx pgx.Tx, record Record) error { - if !record.Managed { - return ErrConflict - } - tag, err := tx.Exec(ctx, deleteStatement, owner.InstanceUID, owner.TenantUID) - if err != nil { - return err - } - if tag.RowsAffected() != 1 { - return ErrConflict - } - return nil - }) -} - -func (s *Store) changeOwnership( - ctx context.Context, - owner Ownership, - operation string, - change func(context.Context, pgx.Tx, Record) error, -) error { - if s == nil || s.db == nil { - return fmt.Errorf("%s: nil database", operation) - } - if err := owner.validate(); err != nil { - return fmt.Errorf("%s: %w", operation, err) - } - - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("begin %s: %w", operation, err) - } - defer func() { _ = tx.Rollback(ctx) }() - - record, err := getByTenantUIDForUpdate(ctx, tx, owner.InstanceUID, owner.TenantUID) - if errors.Is(err, ErrNotFound) && operation == "delete registry record" { - return nil - } - if err != nil { - return fmt.Errorf("%s: %w", operation, err) - } - if !record.equal(owner) { - return fmt.Errorf("%s: %w", operation, ErrConflict) - } - if err := change(ctx, tx, record); err != nil { - return fmt.Errorf("%s: %w", operation, err) - } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit %s: %w", operation, err) - } - return nil -} - -func getByTenantUIDForUpdate(ctx context.Context, tx pgx.Tx, instanceUID, tenantUID string) (Record, error) { - return scanRecord(tx.QueryRow(ctx, getByTenantUIDForUpdateStatement, instanceUID, tenantUID)) -} - -func getByTenantUID(ctx context.Context, tx pgx.Tx, instanceUID, tenantUID string) (Record, error) { - return scanRecord(tx.QueryRow(ctx, getByTenantUIDStatement, instanceUID, tenantUID)) -} - -type rowScanner interface { - Scan(...any) error -} - -func scanRecord(row rowScanner) (Record, error) { - var record Record - err := row.Scan( - &record.InstanceUID, - &record.TenantUID, - &record.TenantNamespace, - &record.TenantName, - &record.DatabaseName, - &record.RoleName, - &record.CredentialPath, - &record.Managed, - &record.CreatedAt, - &record.UpdatedAt, - &record.RetainedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return Record{}, ErrNotFound - } - if err != nil { - return Record{}, fmt.Errorf("read registry record: %w", err) - } - return record, nil -} - -func (o Ownership) validate() error { - if o.InstanceUID == "" || o.TenantUID == "" || o.TenantNamespace == "" || o.TenantName == "" || - o.DatabaseName == "" || o.RoleName == "" || o.CredentialPath == "" { - return errors.New("claim registry ownership: all ownership fields are required") - } - return nil -} - -func (o Ownership) equal(other Ownership) bool { - return o == other -} diff --git a/internal/database/adapter/postgresql/registry/sql.go b/internal/database/adapter/postgresql/registry/sql.go deleted file mode 100644 index fe75d57..0000000 --- a/internal/database/adapter/postgresql/registry/sql.go +++ /dev/null @@ -1,68 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package registry - -const claimStatement = ` -INSERT INTO postgresql_tenant_operator.tenant_ownership ( - instance_uid, - tenant_uid, - tenant_namespace, - tenant_name, - database_name, - role_name, - credential_path -) VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT DO NOTHING -RETURNING - instance_uid, - tenant_uid, - tenant_namespace, - tenant_name, - database_name, - role_name, - credential_path, - managed, - created_at, - updated_at, - retained_at` - -const getByTenantUIDStatement = ` -SELECT - instance_uid, - tenant_uid, - tenant_namespace, - tenant_name, - database_name, - role_name, - credential_path, - managed, - created_at, - updated_at, - retained_at -FROM postgresql_tenant_operator.tenant_ownership -WHERE instance_uid = $1 AND tenant_uid = $2` - -const getByTenantUIDForUpdateStatement = getByTenantUIDStatement + ` FOR UPDATE` - -const markRetainedStatement = ` -UPDATE postgresql_tenant_operator.tenant_ownership -SET managed = false, retained_at = clock_timestamp(), updated_at = clock_timestamp() -WHERE instance_uid = $1 AND tenant_uid = $2 AND managed = true` - -const deleteStatement = ` -DELETE FROM postgresql_tenant_operator.tenant_ownership -WHERE instance_uid = $1 AND tenant_uid = $2 AND managed = true` diff --git a/internal/database/adapter/postgresql/registry_integration_test.go b/internal/database/adapter/postgresql/registry_integration_test.go deleted file mode 100644 index 66c68bb..0000000 --- a/internal/database/adapter/postgresql/registry_integration_test.go +++ /dev/null @@ -1,427 +0,0 @@ -//go:build integration - -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package postgresql_test - -import ( - "context" - "errors" - "net" - "net/url" - "strconv" - "sync" - "testing" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql/registry" -) - -// registryFixture 只连接本测试创建的容器,不接受外部数据库地址。 -type registryFixture struct { - ctx context.Context - pool *pgxpool.Pool - store *registry.Store -} - -func newRegistryFixture(t *testing.T) *registryFixture { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - t.Cleanup(cancel) - _, port := postgresFixture(t, ctx) - endpoint := url.URL{ - Scheme: "postgres", - User: url.UserPassword(fixtureUser, fixturePassword), - Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), - Path: "/postgres", - RawQuery: "sslmode=disable", - } - pool, err := pgxpool.New(ctx, endpoint.String()) - if err != nil { - t.Fatal("cannot configure registry fixture connection") - } - t.Cleanup(pool.Close) - if err := pool.Ping(ctx); err != nil { - t.Fatal("cannot connect to registry fixture") - } - return ®istryFixture{ctx: ctx, pool: pool, store: registry.NewStore(pool)} -} - -func registryOwner(suffix string) registry.Ownership { - return registry.Ownership{ - InstanceUID: "instance-uid", - TenantUID: "tenant-uid-" + suffix, - TenantNamespace: "applications", - TenantName: "tenant-" + suffix, - DatabaseName: "database_" + suffix, - RoleName: "role_" + suffix, - CredentialPath: "credentials/" + suffix, - } -} - -func (f *registryFixture) bootstrap(t *testing.T) { - t.Helper() - if err := f.store.Bootstrap(f.ctx); err != nil { - t.Fatalf("bootstrap registry: %v", err) - } -} - -func (f *registryFixture) claim(t *testing.T, owner registry.Ownership, expected registry.ClaimResult) { - t.Helper() - result, err := f.store.Claim(f.ctx, owner) - if err != nil || result != expected { - t.Fatalf("claim: result=%q, error=%v; want %q", result, err, expected) - } -} - -func TestRegistryOwnershipLifecycle(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - f.bootstrap(t) - owner := registryOwner("lifecycle") - f.claim(t, owner, registry.ClaimCreated) - f.claim(t, owner, registry.ClaimOwned) - record, err := f.store.Get(f.ctx, owner.InstanceUID, owner.TenantUID) - if err != nil { - t.Fatal(err) - } - if record.Ownership != owner || !record.Managed || record.CreatedAt.IsZero() || record.RetainedAt != nil { - t.Fatalf("unexpected ownership record: %+v", record) - } - - // 错误的资源归属不能删除或 Retain 原记录。 - wrongOwner := owner - wrongOwner.RoleName = "another_role" - if err := f.store.Delete(f.ctx, wrongOwner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("delete mismatched owner: %v", err) - } - if err := f.store.MarkRetained(f.ctx, wrongOwner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("retain mismatched owner: %v", err) - } - for range 2 { - if err := f.store.Delete(f.ctx, owner); err != nil { - t.Fatalf("delete retry: %v", err) - } - } - if _, err := f.store.Get(f.ctx, owner.InstanceUID, owner.TenantUID); !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("read deleted record: %v", err) - } -} - -func TestRegistryRetainedRecordCannotBeReclaimed(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - owner := registryOwner("retained") - f.claim(t, owner, registry.ClaimCreated) - if err := f.store.MarkRetained(f.ctx, owner); err != nil { - t.Fatal(err) - } - retained, err := f.store.Get(f.ctx, owner.InstanceUID, owner.TenantUID) - if err != nil { - t.Fatal(err) - } - if retained.Managed || retained.RetainedAt == nil { - t.Fatalf("missing retained tombstone: %+v", retained) - } - if err := f.store.MarkRetained(f.ctx, owner); err != nil { - t.Fatalf("retain retry: %v", err) - } - retried, err := f.store.Get(f.ctx, owner.InstanceUID, owner.TenantUID) - if err != nil || !retried.UpdatedAt.Equal(retained.UpdatedAt) { - t.Fatalf("retain retry changed tombstone: %+v, %v", retried, err) - } - if err := f.store.Delete(f.ctx, owner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("delete retained record: %v", err) - } - if _, err := f.store.Claim(f.ctx, owner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("reclaim retained record: %v", err) - } - owner.TenantUID = "replacement-uid" - if _, err := f.store.Claim(f.ctx, owner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("replacement tenant reclaimed tombstone: %v", err) - } -} - -func TestRegistryUniqueReservations(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - owner := registryOwner("original") - f.claim(t, owner, registry.ClaimCreated) - tests := []struct { - name string - change func(*registry.Ownership) - }{ - {name: "tenant UID", change: func(other *registry.Ownership) { other.TenantUID = owner.TenantUID }}, - {name: "tenant name", change: func(other *registry.Ownership) { other.TenantName = owner.TenantName }}, - {name: "database", change: func(other *registry.Ownership) { other.DatabaseName = owner.DatabaseName }}, - {name: "role", change: func(other *registry.Ownership) { other.RoleName = owner.RoleName }}, - {name: "credential path", change: func(other *registry.Ownership) { other.CredentialPath = owner.CredentialPath }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - other := registryOwner("other") - tt.change(&other) - if _, err := f.store.Claim(f.ctx, other); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("conflicting reservation: %v", err) - } - }) - } -} - -func TestRegistryConcurrentBootstrapAndClaims(t *testing.T) { - f := newRegistryFixture(t) - const workers = 8 - start := make(chan struct{}) - results := make(chan error, workers) - var group sync.WaitGroup - for range workers { - group.Go(func() { - <-start - results <- f.store.Bootstrap(f.ctx) - }) - } - close(start) - group.Wait() - close(results) - for err := range results { - if err != nil { - t.Fatalf("concurrent bootstrap: %v", err) - } - } - - owner := registryOwner("concurrent") - type claimOutcome struct { - result registry.ClaimResult - err error - } - claims := make(chan claimOutcome, workers) - start = make(chan struct{}) - for range workers { - group.Go(func() { - <-start - result, err := f.store.Claim(f.ctx, owner) - claims <- claimOutcome{result: result, err: err} - }) - } - close(start) - group.Wait() - close(claims) - created := 0 - for outcome := range claims { - if outcome.err != nil { - t.Fatal(outcome.err) - } - switch outcome.result { - case registry.ClaimCreated: - created++ - case registry.ClaimOwned: - default: - t.Fatalf("unexpected claim result: %q", outcome.result) - } - } - if created != 1 { - t.Fatalf("created %d records for the same owner; want 1", created) - } -} - -func TestRegistryRestartAndCanceledRequestRecovery(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - owner := registryOwner("restart") - f.claim(t, owner, registry.ClaimCreated) - config := f.pool.Config() - f.pool.Close() - pool, err := pgxpool.NewWithConfig(f.ctx, config) - if err != nil { - t.Fatal("cannot reopen fixture connection") - } - t.Cleanup(pool.Close) - f.store = registry.NewStore(pool) - f.bootstrap(t) - // 模拟客户端丢失上次成功结果后,以新连接重试;归属证据必须来自数据库。 - f.claim(t, owner, registry.ClaimOwned) - canceled, cancel := context.WithCancel(f.ctx) - cancel() - if _, err := f.store.Claim(canceled, registryOwner("canceled")); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled claim: %v", err) - } - f.claim(t, registryOwner("canceled"), registry.ClaimCreated) -} - -func TestRegistryBootstrapRejectsUnknownSchema(t *testing.T) { - f := newRegistryFixture(t) - // 人工创建的同名 schema 不能被初始化过程接管或覆盖。 - if _, err := f.pool.Exec(f.ctx, "CREATE SCHEMA postgresql_tenant_operator"); err != nil { - t.Fatal(err) - } - if err := f.store.Bootstrap(f.ctx); err == nil { - t.Fatal("bootstrap accepted an unmanaged schema") - } - var version int - if err := f.pool.QueryRow(f.ctx, "SELECT version FROM public.postgresql_tenant_operator_schema_version").Scan(&version); err != nil { - t.Fatal(err) - } - if version != 0 { - t.Fatalf("failed migration advanced version to %d", version) - } - // 仅在本测试拥有的临时数据库中清除空冲突 schema,然后重试失败的迁移。 - if _, err := f.pool.Exec(f.ctx, "DROP SCHEMA postgresql_tenant_operator"); err != nil { - t.Fatal(err) - } - f.bootstrap(t) - if _, err := f.pool.Exec(f.ctx, "UPDATE public.postgresql_tenant_operator_schema_version SET version = 100"); err != nil { - t.Fatal(err) - } - if err := f.store.Bootstrap(f.ctx); err == nil { - t.Fatal("bootstrap accepted a future migration version") - } - if err := f.pool.QueryRow(f.ctx, "SELECT version FROM public.postgresql_tenant_operator_schema_version").Scan(&version); err != nil { - t.Fatal(err) - } - if version != 100 { - t.Fatalf("bootstrap rewrote future version to %d", version) - } -} - -// 数据库执行真实 COMMIT 后才注入客户端错误,模拟客户端无法确认提交结果。 -// 这不是网络故障测试,但能确定性覆盖已提交、调用者却收到失败的恢复分支。 -type lostCommitReply struct { - registry.Beginner - err error -} - -func (db lostCommitReply) Begin(ctx context.Context) (pgx.Tx, error) { - tx, err := db.Beginner.Begin(ctx) - if err != nil { - return nil, err - } - return uncertainCommit{Tx: tx, err: db.err}, nil -} - -type uncertainCommit struct { - pgx.Tx - err error -} - -func (tx uncertainCommit) Commit(ctx context.Context) error { - if err := tx.Tx.Commit(ctx); err != nil { - return err - } - return tx.err -} - -func TestRegistryRetriesAfterLostCommitReply(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - replyLost := errors.New("test-only lost commit reply") - uncertain := registry.NewStore(lostCommitReply{Beginner: f.pool, err: replyLost}) - owner := registryOwner("uncertain") - if _, err := uncertain.Claim(f.ctx, owner); !errors.Is(err, replyLost) { - t.Fatalf("claim did not report lost reply: %v", err) - } - f.claim(t, owner, registry.ClaimOwned) - if err := uncertain.MarkRetained(f.ctx, owner); !errors.Is(err, replyLost) { - t.Fatalf("retain did not report lost reply: %v", err) - } - if err := f.store.MarkRetained(f.ctx, owner); err != nil { - t.Fatalf("retry uncertain retain: %v", err) - } - if err := f.store.Delete(f.ctx, owner); !errors.Is(err, registry.ErrConflict) { - t.Fatalf("uncertain retain lost tombstone protection: %v", err) - } - deletable := registryOwner("uncertain_delete") - f.claim(t, deletable, registry.ClaimCreated) - if err := uncertain.Delete(f.ctx, deletable); !errors.Is(err, replyLost) { - t.Fatalf("delete did not report lost reply: %v", err) - } - if err := f.store.Delete(f.ctx, deletable); err != nil { - t.Fatalf("retry uncertain delete: %v", err) - } -} - -func TestRegistryConcurrentConflictingClaims(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - first := registryOwner("first") - second := registryOwner("second") - second.DatabaseName = first.DatabaseName - start := make(chan struct{}) - results := make(chan error, 2) - var group sync.WaitGroup - for _, owner := range []registry.Ownership{first, second} { - group.Go(func() { - <-start - _, err := f.store.Claim(f.ctx, owner) - results <- err - }) - } - close(start) - group.Wait() - close(results) - created, conflicts := 0, 0 - for err := range results { - switch { - case err == nil: - created++ - case errors.Is(err, registry.ErrConflict): - conflicts++ - default: - t.Fatalf("unexpected concurrent claim error: %v", err) - } - } - if created != 1 || conflicts != 1 { - t.Fatalf("concurrent reservation: created=%d conflicts=%d; want one of each", created, conflicts) - } -} - -func TestRegistryConcurrentRetainAndDelete(t *testing.T) { - f := newRegistryFixture(t) - f.bootstrap(t) - owner := registryOwner("delete_race") - f.claim(t, owner, registry.ClaimCreated) - start := make(chan struct{}) - retainResult := make(chan error, 1) - deleteResult := make(chan error, 1) - go func() { - <-start - retainResult <- f.store.MarkRetained(f.ctx, owner) - }() - go func() { - <-start - deleteResult <- f.store.Delete(f.ctx, owner) - }() - close(start) - retainErr, deleteErr := <-retainResult, <-deleteResult - record, readErr := f.store.Get(f.ctx, owner.InstanceUID, owner.TenantUID) - switch { - case retainErr == nil: - // Retain 先取得行锁时,Delete 必须拒绝删除墓碑。 - if !errors.Is(deleteErr, registry.ErrConflict) || readErr != nil || record.Managed || record.RetainedAt == nil { - t.Fatalf("retain won but tombstone was not protected: delete=%v read=%v record=%+v", deleteErr, readErr, record) - } - case errors.Is(retainErr, registry.ErrNotFound): - // Delete 先提交时,Retain 必须报告记录已不存在,不能重建墓碑。 - if deleteErr != nil || !errors.Is(readErr, registry.ErrNotFound) { - t.Fatalf("delete won but record remains: delete=%v read=%v", deleteErr, readErr) - } - default: - t.Fatalf("unexpected retain/delete race: retain=%v delete=%v", retainErr, deleteErr) - } -} diff --git a/internal/database/application/instance_service.go b/internal/database/application/instance_service.go index 78f8917..781d2f8 100644 --- a/internal/database/application/instance_service.go +++ b/internal/database/application/instance_service.go @@ -161,7 +161,7 @@ func credentialError(err error) error { return ErrCredentialsUnavailable } -// Forget 只释放本地连接;不删除数据库或 registry,不替代 Instance finalizer。 +// Forget 只释放本地连接;不删除数据库,不替代 Instance finalizer。 func (s *InstanceService) Forget(name string) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/database/application/metadata.go b/internal/database/application/metadata.go index 73d5d1a..2f6f4e4 100644 --- a/internal/database/application/metadata.go +++ b/internal/database/application/metadata.go @@ -18,7 +18,7 @@ package application import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" -// DatabaseMetadata 是一次只读查询的事实,不包含管理权限或 registry 就绪结论。 +// DatabaseMetadata 是一次只读查询的事实,不包含管理权限或完整就绪结论。 // AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。 type DatabaseMetadata struct { Version string diff --git a/internal/database/domain/instance/instance.go b/internal/database/domain/instance/instance.go index 466b681..8a7bc57 100644 --- a/internal/database/domain/instance/instance.go +++ b/internal/database/domain/instance/instance.go @@ -22,11 +22,10 @@ import "errors" type Phase string const ( - PhasePending Phase = "Pending" - PhaseValidating Phase = "Validating" - PhaseInitializingRegistry Phase = "InitializingRegistry" - PhaseReady Phase = "Ready" - PhaseDeleting Phase = "Deleting" + PhasePending Phase = "Pending" + PhaseValidating Phase = "Validating" + PhaseReady Phase = "Ready" + PhaseDeleting Phase = "Deleting" ) type Readiness string @@ -61,7 +60,7 @@ func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (* return nil, err } switch snapshot.Phase { - case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting: + case PhasePending, PhaseValidating, PhaseReady, PhaseDeleting: default: snapshot.Phase = PhasePending snapshot.Readiness = Unknown diff --git a/internal/database/domain/instance/instance_test.go b/internal/database/domain/instance/instance_test.go index 62226e2..1766e27 100644 --- a/internal/database/domain/instance/instance_test.go +++ b/internal/database/domain/instance/instance_test.go @@ -39,7 +39,7 @@ func lifecycleInstance(t *testing.T, snapshot instance.Snapshot, deleting bool) // Acceptance: docs/database/domain-instance.md §3, checkpoint reconstruction and intent-only transitions. func TestReconstituteCheckpoints(t *testing.T) { for _, phase := range []instance.Phase{ - instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry, + instance.PhasePending, instance.PhaseValidating, instance.PhaseReady, instance.PhaseDeleting, } { snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"} @@ -97,7 +97,7 @@ func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) { t.Fatal("rejected deletion mutated state") } for _, phase := range []instance.Phase{ - instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry, + instance.PhasePending, instance.PhaseValidating, instance.PhaseReady, instance.PhaseDeleting, } { snapshot.Phase = phase diff --git a/internal/database/domain/instance/readiness.go b/internal/database/domain/instance/readiness.go index fea8ec7..35b3196 100644 --- a/internal/database/domain/instance/readiness.go +++ b/internal/database/domain/instance/readiness.go @@ -27,8 +27,6 @@ const ( DependencyUnavailable AuthenticationFailed InsufficientPrivileges - RegistryIncompatible - RegistryNotUsable ) // CheckResult 的零值表示未观察,不能视为成功。 @@ -70,32 +68,22 @@ func (c ManagementChecks) failure() Failure { return NoFailure } -type RegistryState uint8 - -const ( - RegistryUnobserved RegistryState = iota - RegistryAbsent - RegistryNeedsMigration - RegistryUsable - RegistryUnsupported - RegistryUnavailable -) - // CapabilityObservation 是值对象,不包含连接、凭据或可变集合。 type CapabilityObservation struct { - target ObservationTarget - version string - checks ManagementChecks - registry RegistryState + target ObservationTarget + version string + checks ManagementChecks } -func NewCapabilityObservation(target ObservationTarget, version string, - checks ManagementChecks, registry RegistryState, +func NewCapabilityObservation( + target ObservationTarget, + version string, + checks ManagementChecks, ) (CapabilityObservation, error) { if err := target.Validate(); err != nil { return CapabilityObservation{}, err } - return CapabilityObservation{target: target, version: version, checks: checks, registry: registry}, nil + return CapabilityObservation{target: target, version: version, checks: checks}, nil } func (o CapabilityObservation) managementFailure() Failure { @@ -108,29 +96,6 @@ func (o CapabilityObservation) managementFailure() Failure { return NoFailure } -func (o CapabilityObservation) registryFailure() Failure { - switch o.registry { - case RegistryUsable: - return NoFailure - case RegistryAbsent, RegistryNeedsMigration: - return RegistryNotUsable - case RegistryUnsupported: - return RegistryIncompatible - case RegistryUnavailable: - return DependencyUnavailable - default: - return ObservationIncomplete - } -} - -type PreparationDecision uint8 - -const ( - PreparationDenied PreparationDecision = iota - PreparationAllowed - AlreadyUsable -) - func (i *Instance) acceptObservation(o CapabilityObservation, phase Phase) error { if !i.target.Matches(o.target) { return errors.New("capability observation target does not match instance") @@ -149,75 +114,11 @@ func (i *Instance) fail(failure Failure) { i.snapshot.ObservedRevision = i.target.Revision().Value() } -// AssessManagement 只推进意图,不执行 registry 写入,也不完成 observedRevision。 +// AssessManagement 根据本轮完整能力观察完成验证,不执行外部写入。 func (i *Instance) AssessManagement(o CapabilityObservation) error { if err := i.acceptObservation(o, PhaseValidating); err != nil { return err } - if failure := o.managementFailure(); failure != NoFailure { - i.fail(failure) - return nil - } - if failure := o.registryFailure(); failure != NoFailure && failure != RegistryNotUsable { - i.fail(failure) - return nil - } - i.snapshot.Phase = PhaseInitializingRegistry - i.snapshot.Readiness = Unknown - i.snapshot.Failure = NoFailure - i.evidence = nil - return nil -} - -// PlanRegistryPreparation 不证明 checkpoint 已落盘;应用层必须先保存意图再执行写入。 -func (i *Instance) PlanRegistryPreparation(o CapabilityObservation) (PreparationDecision, error) { - if err := i.acceptObservation(o, PhaseInitializingRegistry); err != nil { - return PreparationDenied, err - } - if failure := o.managementFailure(); failure != NoFailure { - i.fail(failure) - return PreparationDenied, nil - } - switch o.registry { - case RegistryUsable: - return AlreadyUsable, nil - case RegistryAbsent, RegistryNeedsMigration: - return PreparationAllowed, nil - default: - i.fail(o.registryFailure()) - return PreparationDenied, nil - } -} - -// RegistryPreparationResult 只能是安全失败或完整回读,不能表达裸操作成功。 -type RegistryPreparationResult struct { - observation CapabilityObservation - failure Failure -} - -func RegistryReadBack(o CapabilityObservation) RegistryPreparationResult { - return RegistryPreparationResult{observation: o} -} - -func RegistryPreparationFailed(target ObservationTarget, failure Failure) (RegistryPreparationResult, error) { - if err := target.Validate(); err != nil { - return RegistryPreparationResult{}, err - } - if failure < ObservationIncomplete || failure > RegistryNotUsable { - return RegistryPreparationResult{}, errors.New("registry preparation requires a known failure category") - } - return RegistryPreparationResult{observation: CapabilityObservation{target: target}, failure: failure}, nil -} - -func (i *Instance) AssessRegistryResult(result RegistryPreparationResult) error { - o := result.observation - if err := i.acceptObservation(o, PhaseInitializingRegistry); err != nil { - return err - } - if result.failure != NoFailure { - i.fail(result.failure) - return nil - } i.assessComplete(o) return nil } @@ -227,12 +128,12 @@ func (i *Instance) assessComplete(o CapabilityObservation) { i.fail(failure) return } - if failure := o.registryFailure(); failure != NoFailure { - i.fail(failure) - return + i.snapshot = Snapshot{ + Phase: PhaseReady, + ObservedRevision: i.target.Revision().Value(), + Readiness: Ready, + ReportedVersion: o.version, } - i.snapshot = Snapshot{Phase: PhaseReady, ObservedRevision: i.target.Revision().Value(), - Readiness: Ready, ReportedVersion: o.version} i.evidence = &o } @@ -244,10 +145,8 @@ func (i *Instance) AssessReadiness(o CapabilityObservation) error { if i.snapshot.ObservedRevision != i.target.Revision().Value() { return i.BeginValidation() } - if o.managementFailure() != NoFailure || o.registry == RegistryUnavailable { + if o.managementFailure() != NoFailure { i.snapshot.Phase = PhaseValidating - } else if o.registryFailure() != NoFailure { - i.snapshot.Phase = PhaseInitializingRegistry } i.assessComplete(o) return nil diff --git a/internal/database/domain/instance/readiness_test.go b/internal/database/domain/instance/readiness_test.go index ad0feac..5acfc7c 100644 --- a/internal/database/domain/instance/readiness_test.go +++ b/internal/database/domain/instance/readiness_test.go @@ -32,11 +32,9 @@ func completeChecks() instance.ManagementChecks { } } -func capability(t *testing.T, value *instance.Instance, checks instance.ManagementChecks, - registry instance.RegistryState, -) instance.CapabilityObservation { +func capability(t *testing.T, value *instance.Instance, checks instance.ManagementChecks) instance.CapabilityObservation { t.Helper() - o, err := instance.NewCapabilityObservation(value.Target(), testServerVersion, checks, registry) + o, err := instance.NewCapabilityObservation(value.Target(), testServerVersion, checks) if err != nil { t.Fatal(err) } @@ -45,8 +43,8 @@ func capability(t *testing.T, value *instance.Instance, checks instance.Manageme func readyInstance(t *testing.T) *instance.Instance { t.Helper() - i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false) - if err := i.AssessRegistryResult(instance.RegistryReadBack(capability(t, i, completeChecks(), instance.RegistryUsable))); err != nil { + i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil { t.Fatal(err) } if err := i.RequireProvisioningReady(); err != nil { @@ -60,38 +58,27 @@ func TestReadinessRequiresCompleteReadBack(t *testing.T) { if err := i.BeginValidation(); err != nil { t.Fatal(err) } - absent := capability(t, i, completeChecks(), instance.RegistryAbsent) - if err := i.AssessManagement(absent); err != nil { + if i.RequireProvisioningReady() == nil { + t.Fatal("validation intent authorized provisioning") + } + if err := i.AssessManagement(capability(t, i, instance.ManagementChecks{})); err != nil { t.Fatal(err) } - if s := i.Snapshot(); s.Phase != instance.PhaseInitializingRegistry || s.ObservedRevision != 0 || s.Readiness != instance.Unknown { - t.Fatalf("management observation prematurely concluded readiness: %+v", s) + if snapshot := i.Snapshot(); snapshot.Phase != instance.PhaseValidating || + snapshot.Readiness != instance.NotReady || snapshot.Failure != instance.ObservationIncomplete { + t.Fatalf("incomplete observation accepted: %+v", snapshot) } - for range 2 { - decision, err := i.PlanRegistryPreparation(absent) - if err != nil || decision != instance.PreparationAllowed { - t.Fatalf("preparation: %v, %v", decision, err) - } - if i.RequireProvisioningReady() == nil { - t.Fatal("preparation authorized provisioning") - } + if i.RequireProvisioningReady() == nil { + t.Fatal("incomplete observation authorized provisioning") } - if err := i.AssessRegistryResult(instance.RegistryReadBack(absent)); err != nil { + if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil { t.Fatal(err) } - if i.Snapshot().Failure != instance.RegistryNotUsable || i.RequireProvisioningReady() == nil { - t.Fatal("absent registry accepted as ready") - } - usable := capability(t, i, completeChecks(), instance.RegistryUsable) - decision, err := i.PlanRegistryPreparation(usable) - if err != nil || decision != instance.AlreadyUsable { - t.Fatalf("retry after external preparation: %v, %v", decision, err) - } - if err := i.AssessRegistryResult(instance.RegistryReadBack(usable)); err != nil { - t.Fatal(err) - } - if s := i.Snapshot(); s.Readiness != instance.Ready || s.ReportedVersion != testServerVersion || s.ObservedRevision != i.Target().Revision().Value() { - t.Fatalf("complete observation not accepted: %+v", s) + snapshot := i.Snapshot() + if snapshot.Phase != instance.PhaseReady || snapshot.Readiness != instance.Ready || + snapshot.ReportedVersion != testServerVersion || + snapshot.ObservedRevision != i.Target().Revision().Value() { + t.Fatalf("complete management observation did not establish readiness: %+v", snapshot) } if err := i.RequireProvisioningReady(); err != nil { t.Fatal(err) @@ -109,7 +96,7 @@ func TestReadinessRecoveryAndInvalidation(t *testing.T) { t.Fatal("persisted Ready fabricated fresh evidence") } for range 2 { - if err := restored.AssessReadiness(capability(t, restored, completeChecks(), instance.RegistryUsable)); err != nil { + if err := restored.AssessReadiness(capability(t, restored, completeChecks())); err != nil { t.Fatal(err) } if err := restored.RequireProvisioningReady(); err != nil { @@ -138,120 +125,77 @@ func TestReadinessRecoveryAndInvalidation(t *testing.T) { } func TestEachManagementCheckIsRequired(t *testing.T) { - for field := range 6 { - for _, result := range []instance.CheckResult{instance.CheckUnobserved, instance.CheckUnavailable, - instance.CheckAuthenticationFailed, instance.CheckInsufficientPrivileges, 255} { - checks := completeChecks() - fields := []*instance.CheckResult{&checks.Connection, &checks.Metadata, &checks.Roles, - &checks.Databases, &checks.Grants, &checks.Extensions} - *fields[field] = result - i := readyInstance(t) - if err := i.AssessReadiness(capability(t, i, checks, instance.RegistryUsable)); err != nil { - t.Fatal(err) - } - if s := i.Snapshot(); s.Phase != instance.PhaseValidating || s.Readiness != instance.NotReady || - s.Failure == instance.NoFailure || i.RequireProvisioningReady() == nil { - t.Fatalf("check %d result %d accepted: %+v", field, result, s) - } - } - } -} - -func TestRegistryDecisionsAndReadinessLoss(t *testing.T) { - for _, tc := range []struct { - state instance.RegistryState - decision instance.PreparationDecision - failure instance.Failure + checkNames := []string{"connection", "metadata", "roles", "databases", "grants", "extensions"} + failures := []struct { + name string + result instance.CheckResult + want instance.Failure }{ - {instance.RegistryUsable, instance.AlreadyUsable, instance.NoFailure}, - {instance.RegistryAbsent, instance.PreparationAllowed, instance.RegistryNotUsable}, - {instance.RegistryNeedsMigration, instance.PreparationAllowed, instance.RegistryNotUsable}, - {instance.RegistryUnsupported, instance.PreparationDenied, instance.RegistryIncompatible}, - {instance.RegistryUnavailable, instance.PreparationDenied, instance.DependencyUnavailable}, - {instance.RegistryUnobserved, instance.PreparationDenied, instance.ObservationIncomplete}, - {255, instance.PreparationDenied, instance.ObservationIncomplete}, - } { - i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false) - o := capability(t, i, completeChecks(), tc.state) - decision, err := i.PlanRegistryPreparation(o) - if err != nil || decision != tc.decision { - t.Fatalf("registry %d: %v, %v", tc.state, decision, err) - } - i = readyInstance(t) - if err := i.AssessReadiness(o); err != nil { - t.Fatal(err) - } - if i.Snapshot().Failure != tc.failure { - t.Fatalf("registry %d: %+v", tc.state, i.Snapshot()) - } - if tc.state != instance.RegistryUsable { - wantPhase := instance.PhaseInitializingRegistry - if tc.state == instance.RegistryUnavailable { - wantPhase = instance.PhaseValidating - } - if i.Snapshot().Phase != wantPhase || i.RequireProvisioningReady() == nil { - t.Fatal("registry drift retained readiness") + {"unobserved", instance.CheckUnobserved, instance.ObservationIncomplete}, + {"unavailable", instance.CheckUnavailable, instance.DependencyUnavailable}, + {"authentication", instance.CheckAuthenticationFailed, instance.AuthenticationFailed}, + {"privileges", instance.CheckInsufficientPrivileges, instance.InsufficientPrivileges}, + {"unknown", 255, instance.ObservationIncomplete}, + } + for field, name := range checkNames { + for _, failure := range failures { + for _, phase := range []instance.Phase{instance.PhaseValidating, instance.PhaseReady} { + t.Run(name+"/"+failure.name+"/"+string(phase), func(t *testing.T) { + checks := completeChecks() + fields := []*instance.CheckResult{ + &checks.Connection, &checks.Metadata, &checks.Roles, + &checks.Databases, &checks.Grants, &checks.Extensions, + } + *fields[field] = failure.result + value := lifecycleInstance(t, instance.Snapshot{Phase: phase}, false) + assess := value.AssessManagement + if phase == instance.PhaseReady { + value = readyInstance(t) + assess = value.AssessReadiness + } + if err := assess(capability(t, value, checks)); err != nil { + t.Fatal(err) + } + snapshot := value.Snapshot() + if snapshot.Phase != instance.PhaseValidating || + snapshot.Readiness != instance.NotReady || + snapshot.Failure != failure.want || + snapshot.ObservedRevision != value.Target().Revision().Value() { + t.Fatalf("incorrect failed observation: %+v", snapshot) + } + if value.RequireProvisioningReady() == nil { + t.Fatal("failed check authorized provisioning") + } + // 依赖恢复后重新验证,不保留失败或旧就绪证据。 + if err := value.AssessManagement(capability(t, value, completeChecks())); err != nil { + t.Fatal(err) + } + if err := value.RequireProvisioningReady(); err != nil { + t.Fatal("dependency recovery did not restore readiness", err) + } + }) } } } } -func TestInitializationRejectsIncompleteOrFailedManagement(t *testing.T) { - for _, tc := range []struct { - checks instance.ManagementChecks - registry instance.RegistryState - failure instance.Failure - }{ - {instance.ManagementChecks{}, instance.RegistryUsable, instance.ObservationIncomplete}, - {completeChecks(), instance.RegistryUnsupported, instance.RegistryIncompatible}, - {completeChecks(), instance.RegistryUnavailable, instance.DependencyUnavailable}, - } { - i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) - if err := i.AssessManagement(capability(t, i, tc.checks, tc.registry)); err != nil { - t.Fatal(err) - } - if s := i.Snapshot(); s.Phase != instance.PhaseValidating || s.Failure != tc.failure || - s.ObservedRevision != i.Target().Revision().Value() || s.Readiness != instance.NotReady { - t.Fatalf("invalid management accepted: %+v", s) - } - } - i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false) - decision, err := i.PlanRegistryPreparation(capability(t, i, instance.ManagementChecks{}, instance.RegistryAbsent)) - if err != nil || decision != instance.PreparationDenied || i.Snapshot().Failure != instance.ObservationIncomplete { - t.Fatal("incomplete management allowed registry writes") - } - if err := i.AssessRegistryResult(instance.RegistryReadBack(capability(t, i, completeChecks(), instance.RegistryUsable))); err != nil { - t.Fatal(err) - } - if err := i.RequireProvisioningReady(); err != nil { - t.Fatal("dependency recovery did not restore readiness", err) - } -} - func TestReadinessMethodsRejectWrongPhaseAndDeletion(t *testing.T) { for _, deleting := range []bool{false, true} { for _, phase := range []instance.Phase{instance.PhasePending, instance.PhaseValidating, - instance.PhaseInitializingRegistry, instance.PhaseReady, instance.PhaseDeleting} { + instance.PhaseReady, instance.PhaseDeleting} { for _, operation := range []struct { phase instance.Phase apply func(*instance.Instance, instance.CapabilityObservation) error }{ {instance.PhaseValidating, (*instance.Instance).AssessManagement}, {instance.PhaseReady, (*instance.Instance).AssessReadiness}, - {instance.PhaseInitializingRegistry, func(i *instance.Instance, o instance.CapabilityObservation) error { - _, err := i.PlanRegistryPreparation(o) - return err - }}, - {instance.PhaseInitializingRegistry, func(i *instance.Instance, o instance.CapabilityObservation) error { - return i.AssessRegistryResult(instance.RegistryReadBack(o)) - }}, } { if !deleting && operation.phase == phase { continue } i := lifecycleInstance(t, instance.Snapshot{Phase: phase}, deleting) before := i.Snapshot() - if err := operation.apply(i, capability(t, i, completeChecks(), instance.RegistryUsable)); err == nil { + if err := operation.apply(i, capability(t, i, completeChecks())); err == nil { t.Fatalf("phase %s deleting=%t accepted operation for %s", phase, deleting, operation.phase) } if i.Snapshot() != before { @@ -273,7 +217,7 @@ func TestOldGenerationObservationDoesNotReplaceEvidence(t *testing.T) { if err != nil { t.Fatal(err) } - o, err := instance.NewCapabilityObservation(other, testServerVersion, completeChecks(), instance.RegistryUsable) + o, err := instance.NewCapabilityObservation(other, testServerVersion, completeChecks()) if err != nil { t.Fatal(err) } @@ -286,44 +230,14 @@ func TestOldGenerationObservationDoesNotReplaceEvidence(t *testing.T) { } } -func TestPreparationFailureCannotEstablishReadiness(t *testing.T) { - i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false) - for _, failure := range []instance.Failure{instance.DependencyUnavailable, instance.AuthenticationFailed, - instance.InsufficientPrivileges, instance.RegistryIncompatible} { - result, err := instance.RegistryPreparationFailed(i.Target(), failure) - if err != nil { - t.Fatal(err) - } - if err := i.AssessRegistryResult(result); err != nil { - t.Fatal(err) - } - if s := i.Snapshot(); s.Failure != failure || s.Readiness != instance.NotReady || - s.Phase != instance.PhaseInitializingRegistry || i.RequireProvisioningReady() == nil { - t.Fatalf("failed operation accepted: %+v", s) - } - } - for _, failure := range []instance.Failure{instance.NoFailure, 255} { - if _, err := instance.RegistryPreparationFailed(i.Target(), failure); err == nil { - t.Fatal("invalid failure accepted") - } - } -} - func TestCapabilityInputsAndLifecycleGuards(t *testing.T) { i := readyInstance(t) if _, err := instance.NewCapabilityObservation(instance.ObservationTarget{}, testServerVersion, - completeChecks(), instance.RegistryUsable); err == nil { + completeChecks()); err == nil { t.Fatal("invalid target accepted") } - if _, err := instance.RegistryPreparationFailed(instance.ObservationTarget{}, instance.DependencyUnavailable); err == nil { - t.Fatal("invalid failure target accepted") - } for _, method := range []func(instance.CapabilityObservation) error{ i.AssessManagement, i.AssessReadiness, - func(o instance.CapabilityObservation) error { _, err := i.PlanRegistryPreparation(o); return err }, - func(o instance.CapabilityObservation) error { - return i.AssessRegistryResult(instance.RegistryReadBack(o)) - }, } { before := i.Snapshot() if err := method(instance.CapabilityObservation{}); err == nil || i.Snapshot() != before { @@ -333,13 +247,13 @@ func TestCapabilityInputsAndLifecycleGuards(t *testing.T) { old := i.Snapshot() old.ObservedRevision = 0 changed := lifecycleInstance(t, old, false) - if err := changed.AssessReadiness(capability(t, changed, completeChecks(), instance.RegistryUsable)); err != nil { + if err := changed.AssessReadiness(capability(t, changed, completeChecks())); err != nil { t.Fatal(err) } if s := changed.Snapshot(); s.Phase != instance.PhaseValidating || s.ObservedRevision != 0 || s.Readiness != instance.Unknown { t.Fatalf("changed generation accepted old checkpoint: %+v", s) } - o, err := instance.NewCapabilityObservation(i.Target(), "", completeChecks(), instance.RegistryUsable) + o, err := instance.NewCapabilityObservation(i.Target(), "", completeChecks()) if err != nil { t.Fatal(err) }