From 8ac628357357368086b946a1327de7a92a3dacdc Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 20:30:17 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=20Database=20Ins?= =?UTF-8?q?tance=20=E9=A2=86=E5=9F=9F=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/database/README.md | 42 ++ docs/database/api-reference.md | 190 ++++++ docs/database/architecture.md | 99 ++++ docs/database/deployment.md | 130 ++++ docs/database/development.md | 205 +++++++ docs/database/domain-instance.md | 230 ++++++++ docs/database/domain-model.md | 149 +++++ docs/database/migration.md | 125 ++++ docs/database/operations.md | 76 +++ docs/database/security.md | 71 +++ docs/database/specification.md | 558 ++++++++++++++++++ .../domain/instance/credential_reference.go | 67 +++ .../instance/credential_reference_test.go | 119 ++++ internal/database/domain/instance/endpoint.go | 89 +++ .../database/domain/instance/endpoint_test.go | 118 ++++ .../database/domain/instance/extensions.go | 84 +++ .../domain/instance/extensions_test.go | 107 ++++ internal/database/domain/instance/identity.go | 73 +++ .../database/domain/instance/identity_test.go | 102 ++++ internal/database/domain/instance/instance.go | 101 ++++ .../database/domain/instance/instance_test.go | 134 +++++ .../domain/instance/observation_target.go | 81 +++ .../instance/observation_target_test.go | 165 ++++++ 23 files changed, 3115 insertions(+) create mode 100644 docs/database/README.md create mode 100644 docs/database/api-reference.md create mode 100644 docs/database/architecture.md create mode 100644 docs/database/deployment.md create mode 100644 docs/database/development.md create mode 100644 docs/database/domain-instance.md create mode 100644 docs/database/domain-model.md create mode 100644 docs/database/migration.md create mode 100644 docs/database/operations.md create mode 100644 docs/database/security.md create mode 100644 docs/database/specification.md create mode 100644 internal/database/domain/instance/credential_reference.go create mode 100644 internal/database/domain/instance/credential_reference_test.go create mode 100644 internal/database/domain/instance/endpoint.go create mode 100644 internal/database/domain/instance/endpoint_test.go create mode 100644 internal/database/domain/instance/extensions.go create mode 100644 internal/database/domain/instance/extensions_test.go create mode 100644 internal/database/domain/instance/identity.go create mode 100644 internal/database/domain/instance/identity_test.go create mode 100644 internal/database/domain/instance/instance.go create mode 100644 internal/database/domain/instance/instance_test.go create mode 100644 internal/database/domain/instance/observation_target.go create mode 100644 internal/database/domain/instance/observation_target_test.go diff --git a/docs/database/README.md b/docs/database/README.md new file mode 100644 index 0000000..c6a22cc --- /dev/null +++ b/docs/database/README.md @@ -0,0 +1,42 @@ +# Database 模块 + +Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只建立 PostgreSQL Instance 的纯领域 +模型,不注册 CRD、不启动 controller,也不访问 PostgreSQL、OpenBao 或 Kubernetes Secret。 + +## 来源基线 + +完整设计合同及首批领域模型与测试提取自原 PostgreSQL Tenant Operator: + +- 仓库:`git.ddupan.top/panxiao81/postgresql-tenant-operator` +- source commit:`dae546e58efa1be81e930861c87f7fb13bb12113` +- 原路径:`internal/domain/instance/`、`docs/domain-instance.md` +- 迁移日期:2026-09-20 + +本目录迁入该基线的 specification、architecture、API、领域、部署、安全、开发、迁移与运维 +文档,并只进行 Ayatori 产品归属、API group、目录和链接适配;其余已批准行为保持不变。 + +迁移只使用该 commit 中已提交的文件。源仓库 +`feature/instance-extension-observations` 工作树中的 `instance.go` 修改与 +`instance_extensions_test.go` 未进入本切片。 + +代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新; +领域行为未在本切片中扩展。后续 Ready/observation 行为应在 Ayatori 内先更新合同与测试,再继续 +实现,不能把旧运行链路接回该模型。 + +## 边界 + +- 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。 +- CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。 +- Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。 +- 当前代码不授权 Tenant provisioning,也不表示 Database API 已经可用。 + +## 设计入口 + +- [系统规格](specification.md):规范性行为与验收标准; +- [架构](architecture.md)与[API 合同](api-reference.md); +- [领域模型](domain-model.md)与[Instance 领域规格](domain-instance.md); +- [安全](security.md)、[迁移](migration.md)与[运维](operations.md); +- [部署合同](deployment.md)与[开发测试合同](development.md)。 + +部署和开发文档描述目标合同,其中旧仓库专属的 Make target、脚手架版本和目录尚未接入 Ayatori; +在对应实现切片完成前,不应把其中命令理解为当前仓库已经可执行的入口。 diff --git a/docs/database/api-reference.md b/docs/database/api-reference.md new file mode 100644 index 0000000..3c31bf4 --- /dev/null +++ b/docs/database/api-reference.md @@ -0,0 +1,190 @@ +# v1alpha1 API 合同 + +| 项目 | 内容 | +| --- | --- | +| 状态 | Review | +| API group | `database.ayatori.ddupan.top` | +| version | `v1alpha1` | +| 最后更新 | 2026-09-10 | + +本文把已批准的系统规格映射为 CRD 字段合同。API types、生成 CRD、sample 和测试必须与本文 +一致。Ayatori 尚未注册这些 API,本页是后续实现的规范来源。 + +## 通用约定 + +- PostgreSQL identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`。 +- 所有引用名称使用 Kubernetes DNS label/name 的相应校验。 +- Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不超过 241 个字符,确保派生的 + `--postgresql` 不超过 Kubernetes DNS subdomain 的 + 253 字符限制。 +- port、TLS mode、deletion policy 等固定默认值由 CRD defaulting 提供。database、 + loginRole、Secret 名称等依赖其他字段的值是 controller 语义默认值:字段保持省略, + controller 计算 effective value 并通过 status/受管资源展示,不引入 mutating webhook。 +- 需要读取旧值或跨字段的校验由 CEL 或 controller 完成。 +- `status` 由 controller 独占写入,禁止出现密码、Token、管理用户名或完整连接串。 +- 两个 Kind 都只承诺一个 `Ready` Condition;调用方不得依赖内部协调阶段。 + +## PostgreSQLInstance + +cluster-scoped,short name 为 `pginstance`。 + +### Spec + +| JSON path | 类型 | 必填/默认 | 合同 | +| --- | --- | --- | --- | +| `spec.endpoint.host` | string | 必填 | PostgreSQL DNS 名;必须被服务端证书 DNS SAN 覆盖 | +| `spec.endpoint.hostaddr` | string | 必填 | 单个 IPv4/IPv6;必须被服务端证书 IP SAN 覆盖 | +| `spec.endpoint.port` | int32 | `5432` | 1–65535 | +| `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier | +| `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` | +| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 | +| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 | +| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 | + +`adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller +namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO +同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订, +现有 API types、生成 CRD 和 samples 尚未更新。 + +Instance endpoint、管理凭据引用可以修改。修改后 controller 重新验证。 +2026-09-14 修订:v1alpha1 不实现 allowedExtensions;现有 API types、生成 CRD 和 +samples 中的字段待后续移除,不作为一个可配置但被忽略的策略保留。 +扩展请求按目标 PostgreSQL 实际可安装列表判断,可用列表由应用层查询。 + +endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置 +的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。 + +### Status + +| JSON path | 类型 | 含义 | +| --- | --- | --- | +| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation | +| `status.phase` | enum | `Pending`、`Validating`、`InitializingRegistry`、`Ready`、`Deleting` | +| `status.postgresqlVersion` | string | 从 server 回读的版本,不用于客户端解析 | +| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` | + +print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。 + +Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry +可访问且权限预检成功。它不代表数据库已经备份或高可用。 + +管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO +暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。 + +## PostgreSQLTenant + +namespaced,short name 为 `pgtenant`。 + +### Spec + +| JSON path | 类型 | 必填/默认 | 合同 | +| --- | --- | --- | --- | +| `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 | +| `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier | +| `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login | +| `spec.extensions` | set[string] | 空集合 | 必须属于目标实例实际可安装的扩展列表;成功创建后只允许追加 | +| `spec.credential.secretName` | string | `--postgresql` | 合法的同 namespace ESO target Secret 名称 | +| `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` | + +Tenant 不声明 OpenBao mount 或 path。controller 使用部署级 mount/base path 和 +`namespace/name` 推导稳定路径,并用 UID metadata 验证所有权。ExternalSecret 固定为 +`--postgresql`;目标 Secret 可以由用户指定,只需 +满足 Kubernetes Secret 名称校验,不限制命名内容;省略时使用相同默认名。 + +`instanceRef`、`database`、`loginRole` 和 `credential.secretName` 在首次成功创建外部 +状态后不可变。 +`extensions` 只允许集合不变或追加;移除返回 `ImmutableField`,不会执行 +`DROP EXTENSION`。`deletionPolicy` 在对象进入删除前可以修改;删除开始后以 finalizer +首次观察到的值为准,避免清理过程中改变授权范围。 + +### Status + +| JSON path | 类型 | 含义 | +| --- | --- | --- | +| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation | +| `status.phase` | enum | controller 状态机的权威 checkpoint | +| `status.database` | string | 应用语义默认值后的实际 database 名称 | +| `status.loginRole` | string | 应用语义默认值后的实际 owner/login role 名称 | +| `status.databaseOID` | uint32 | 回读的 database OID,仅供诊断 | +| `status.credential.secretRef.name` | string | 同 namespace 目标 Secret 名称 | +| `status.credential.openBaoURL` | string | 完整 KV v2 API URL,不含认证信息 | +| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` | + +Secret reference 不重复 namespace,因为它必定与 Tenant 同 namespace。OpenBao URL 格式 +为 `/v1//data/`;不得包含 Token、用户名、密码或 +query credential。 + +Tenant phase 枚举为 `Pending`、`Planned`、`CredentialCreated`、`RoleCreated`、 +`DatabaseCreated`、`ExternalSecretCreated`、`CredentialProjected`、`Ready`、`Deleting`。 +它不包含 `Failed` 或 `Retained`;失败类型由 Condition Reason 表达。 + +print columns:`Instance`、`Database`、`Phase`、`Secret`、`Ready`、`Age`。完整 OpenBao URL 只在 +YAML/JSON status 中输出。 + +两个 Kind 的 `status.phase` 都是 controller 状态机的权威 checkpoint。controller 用它 +选择下一候选动作,但必须在动作前后核对外部事实,不能仅凭 phase 跳过幂等检查。status +丢失或领先于实际状态时必须保守重建/纠正。自动化就绪判断仍应读取 `Ready` Condition; +phase 用于进度展示、恢复和排障。 + +## Condition + +每种类型最多一个 Condition;更新必须保留正确的 `lastTransitionTime` 语义。 + +| Reason | Kind | 可重试性 | +| --- | --- | --- | +| `Reconciling` | 两者 | 正常进行中 | +| `Ready` | 两者 | 已收敛 | +| `InvalidSpec` | 两者 | 修改 spec 前不会恢复 | +| `ImmutableField` | Tenant | 恢复原值或重新迁移 | +| `DependencyUnavailable` | 两者 | 自动重试 | +| `AuthenticationFailed` | Instance | 修复凭据/TLS 后重试 | +| `InsufficientPrivileges` | Instance | 修复管理 role 后重试 | +| `InstanceNotReady` | Tenant | Instance 恢复后重试 | +| `Conflict` | Tenant | 人工解除名称/所有权冲突 | +| `ProvisioningFailed` | Tenant | 按错误类别退避重试 | +| `CredentialProjectionFailed` | Tenant | ESO/Secret 恢复后重试 | + +`Ready=True` 必须使用 Reason `Ready`。处理中为 `Unknown/Reconciling`;已知未满足合同为 +`False`。Condition message 可以包含资源名和错误类别,禁止包含凭据值或完整 Secret。 + +## 删除语义 + +- `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。 +- `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。 +- Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用 + (包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。 + 不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。 +- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得 + 开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。 + +## 示例 + +```yaml +apiVersion: database.ayatori.ddupan.top/v1alpha1 +kind: PostgreSQLInstance +metadata: + name: shared +spec: + endpoint: + host: postgresql.home.arpa + hostaddr: 192.0.2.10 + port: 5432 + database: postgres + sslMode: verify-full + adminCredentialRef: + name: shared-postgresql-admin +--- +apiVersion: database.ayatori.ddupan.top/v1alpha1 +kind: PostgreSQLTenant +metadata: + name: netbox + namespace: netbox +spec: + instanceRef: shared + database: netbox + loginRole: netbox + extensions: [pg_trgm] + credential: + secretName: shared-netbox-database-credentials + deletionPolicy: Retain +``` diff --git a/docs/database/architecture.md b/docs/database/architecture.md new file mode 100644 index 0000000..cfd5c65 --- /dev/null +++ b/docs/database/architecture.md @@ -0,0 +1,99 @@ +# 系统架构 + +本文是已批准 [`specification.md`](specification.md) 的架构视图。规范定义外部行为, +本文解释组件边界;二者冲突时以规范为准。Ayatori Database 模块目前只有首批领域模型,尚未 +注册 API 或接入运行链路。 + +## 组件与数据流 + +```text +GitOps / kubectl / Terraform / Backstage + | + v + Kubernetes API (CRD) + | + v + Ayatori Database controller + | | | + v v v + PostgreSQL DBMS OpenBao KV ExternalSecret + catalog+registry | + v + Kubernetes Secret +``` + +- Kubernetes `spec` 保存期望状态;`status.phase` 保存 controller 状态机 checkpoint, + 其他 status 字段保存可重建的观察结果。整个 status 都必须能由外部事实保守恢复。 +- PostgreSQL catalog 保存 database、role、grant 和 extension 的实际状态。 +- 两个 CR 的 `status.phase` 是 controller 状态机的权威 checkpoint。 +- PostgreSQL 管理 database 中的 controller registry 只负责所有权、安装身份和保留标记。 +- OpenBao KV v2 是应用凭据的事实来源。 +- External Secrets Operator(ESO)读取 OpenBao,并创建应用使用的 Kubernetes Secret。 + +controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 OpenBao PKI,也不直接 +把明文凭据写入 Kubernetes API。 + +## 资源模型 + +`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的 +DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。 +实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。 + +管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的 +Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取 +管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。 + +`PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner +的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV +记录,以及同 namespace 的 ExternalSecret 和目标 Secret。 + +Tenant namespace 只提供 Kubernetes RBAC 和身份边界。database 与 role 名称在一个 +Instance 内仍然全局唯一。 + +## Reconcile 与所有权 + +系统采用最终一致性,不在 Kubernetes、PostgreSQL、OpenBao 和 ESO 之间假装存在分布式 +事务。每个外部写入前在 CR status 记录阶段,执行幂等操作,回读验证,再推进阶段: + +```text +Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated + -> ExternalSecretCreated -> CredentialProjected -> Ready +``` + +controller 每轮同时读取 CR、registry、PostgreSQL catalog、OpenBao metadata 和 ESO +投射状态。`status.phase` 是状态机 checkpoint,但不能替代外部回读;丢失或与事实冲突 +时必须保守重建/纠正。`metadata.generation` 只表示 spec 修改;Condition 的 +`observedGeneration` 表示该版本是否已经完成一次有结论的协调。 + +所有权使用 Instance UID、Tenant UID 与 namespace/name 验证。database/role COMMENT +可以辅助排障,但不能代替 registry。未知资源只报告 `Conflict`,不得修改、接管或 +删除。Retain 后用相同名称重建 CR 会获得新 UID,因此仍然冲突。 + +## 创建与删除边界 + +创建时先校验全部输入和冲突,再生成一次密码并写入 OpenBao,随后创建 role、database、 +extension 和 ExternalSecret。只有 ESO 已投射 Secret 且应用凭据实际登录成功,Tenant +才可 Ready。 + +`Retain` 是默认删除策略,只移除 Kubernetes 管理关系并保留外部资源。显式 `Delete` +使用 finalizer,在重新验证所有权后依次删除 ExternalSecret/Secret、连接、database、 +role、OpenBao KV 历史和 registry。详细恢复与逃生步骤见 +[`operations.md`](operations.md)。 + +## 网络与 TLS + +Instance 同时公布 DNS `host` 和 IP `hostaddr`。PostgreSQL server 证书必须包含对应的 +DNS SAN 和 IP SAN,消费者自行选择可达目标,并可使用 `verify-full` 验证。OpenBao PKI +持有 CA 私钥并签发服务端证书;controller 只挂载公开 CA bundle。 + +OpenBao 的 controller 内部地址和外部消费者地址可以不同。Tenant status 同时提供目标 +Kubernetes Secret reference 和不含认证信息的 OpenBao KV v2 API URL。 + +## 文档入口 + +- API 字段与 Condition:[`api-reference.md`](api-reference.md) +- 安装、依赖和配置:[`deployment.md`](deployment.md) +- 本地与 CI 测试:[`development.md`](development.md) +- 安全模型与最小权限:[`security.md`](security.md) +- 现有数据库迁移:[`migration.md`](migration.md) +- 日常排障和删除逃生:[`operations.md`](operations.md) diff --git a/docs/database/deployment.md b/docs/database/deployment.md new file mode 100644 index 0000000..6862973 --- /dev/null +++ b/docs/database/deployment.md @@ -0,0 +1,130 @@ +# 部署与配置 + +> 本页迁入作为 Database 模块的目标部署合同。Ayatori manager flags、manifests 与发布装配尚未 +> 实现;原设计行为保持有效,但当前仓库不能直接按本页完成部署。 + +| 项目 | 内容 | +| --- | --- | +| 状态 | Review | +| 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao | +| 最后更新 | 2026-09-10 | + +本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。当前 manifests 尚未实现这些 +配置,示例是后续实现合同,不可直接用于现有脚手架。 + +## 依赖与顺序 + +1. 准备 PostgreSQL VM、持久盘、备份和网络入口。 +2. 用 OpenBao PKI 签发 PostgreSQL server 证书,包含 Instance `host` 的 DNS SAN 与 + `hostaddr` 的 IP SAN;配置 PostgreSQL 强制 TLS。 +3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。 +4. 在 OpenBao KV v2 写入管理 role 凭据。 +5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。 +6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller + namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的 + `ClusterSecretStore`。 +7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。 +8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。 + +任何一步都不得把真实密码、Token、kubeconfig 或 CA 私钥提交进 Git。 + +## Controller 配置合同 + +controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须 +在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。 + +| CLI flag | 必填/默认 | 说明 | +| --- | --- | --- | +| `--openbao-address` | 必填 | controller 可访问的 OpenBao API address | +| `--openbao-consumer-address` | 默认同 `--openbao-address` | 写入 Tenant status,必须能被预期外部消费者解析 | +| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 | +| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role | +| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` | +| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 | +| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 | +| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 | +| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 | +| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 | + +address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/` +在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以 +`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata` +API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。 + +Tenant 路径固定推导为 `//`。namespace/name 都已通过 +Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer +address 拼为 `
/v1//data///`。 + +base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空段、`.`、`..`、 +`data`/`metadata` API 层。ExternalSecret 固定命名为 +`--postgresql`;目标 Secret 可由 Tenant 指定,但名称必须 +满足 Kubernetes Secret 名称校验,不限制命名内容,默认与 ExternalSecret 同名。 + +配置变化不得隐式迁移既有凭据。修改 KV mount/base path 或 consumer address 前必须 +停止 controller、评估现有 Tenant,并走明确迁移;实现应把 mount/base path 视为安装 +身份的一部分并在 registry 留存,以便检测错误配置。 + +## PostgreSQL 管理 role + +生产部署禁止使用 superuser。管理 role 至少需要: + +- 连接管理 database、读取必要 catalog; +- 创建/修改受管 login role; +- 创建 database 并指定 owner; +- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT; +- 连接租户 database 并创建实例实际支持、租户申请的 extension; +- 创建和维护 controller 专属 registry schema/table; +- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。 + +部分 PostgreSQL 操作天然要求较高权限,尤其终止其他 session 和安装某些 extension。 +应优先使用 PostgreSQL 预定义角色、受控 SECURITY DEFINER 管理函数或限定数据库的 +授权;任何不得不使用 superuser 的 extension 都必须按实例单独记录,不得扩大默认 +controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成测试固化。 + +## OpenBao 与 ESO + +controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2 +data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。 + +管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份 +只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得 +读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。 +`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。 +controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标 +Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、 +`sslmode`。 + +## Kubernetes RBAC + +- controller 可读/写 Instance、Tenant 的 status/finalizer 和 Event。 +- controller 可在 Tenant namespace 创建、读取、更新、删除 ExternalSecret,并只读检查 + 对应 Secret 是否完成投射。 +- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller + 配置或其他 namespace 的 ExternalSecret。 +- controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace + 的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。 +- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用 + 凭据,只检查 Secret 存在性和 ESO 状态。 + +## 升级与回滚 + +v1alpha1 尚不承诺跨版本转换。升级前备份 CR、PostgreSQL registry 和 OpenBao metadata, +先在隔离 Kind 环境运行 E2E。禁止在同一组 CR 上同时运行两个 controller 版本。若新版本 +在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或 registry schema 迁移时, +必须先写独立升级规格和回滚步骤。 + +## 上线验证 + +```text +PostgreSQL TLS 与备份验证 + -> OpenBao auth/policy 验证 + -> ClusterSecretStore Ready + -> controller Ready/leader elected + -> Instance Ready + -> 测试 Tenant Ready + -> DNS host 与 IP hostaddr 分别登录 + -> 删除测试 Tenant 并验证所选策略 +``` + +生产 homelab 上线前还必须完成 [`security.md`](security.md) 的权限检查和 +[`operations.md`](operations.md) 的备份/逃生检查。 diff --git a/docs/database/development.md b/docs/database/development.md new file mode 100644 index 0000000..b51cc3a --- /dev/null +++ b/docs/database/development.md @@ -0,0 +1,205 @@ +# 开发与测试环境 + +> 本页迁入作为 Database 模块的测试分层与 fixture 合同。旧项目的 Make target、devcontainer +> 和脚手架版本尚未适配 Ayatori;实现时应复用 Ayatori 现有工具链,并保持这里定义的测试边界。 + +本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab +中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性 +容器。这样既避免污染真实数据,也能把启动顺序固化为命令。 + +## 是否需要开发 VM + +默认不需要。仓库的 devcontainer 使用独立 Docker-in-Docker daemon,Go 工具链、 +Kind 节点和依赖容器都与宿主机环境隔离。宿主机只需要能够运行支持 privileged +container 的 Docker/Dev Container 环境。 + +只有以下情况才建议增加一台可随时重建的开发 VM: + +- 宿主机不允许 privileged devcontainer; +- 无法安全使用 Docker socket 或 Docker-in-Docker; +- 本机地址段与 Kind/Docker 网络持续冲突; +- 需要长期运行、接近 homelab 网络和 TLS 配置的验收环境。 + +即使使用 VM,也应在 VM 内继续执行本文相同的容器化流程;不要把 VM 配置成第二套 +手工维护的开发环境。 + +## 环境分层 + +| 层次 | Kubernetes | PostgreSQL / OpenBao | 用途 | +| --- | --- | --- | --- | +| 单元测试 | fake client | fake client | SQL 计划、状态转换和错误分类 | +| controller 集成测试 | envtest | fake adapter | CRD、watch、status、finalizer、ExternalSecret 对象 | +| adapter 集成测试 | 不需要 | Docker Compose | 真实协议、权限和幂等行为 | +| E2E | 一次性 Kind + ESO | Kind 内测试实例 | 凭据投射、TLS、完整网络和删除路径 | + +envtest 只启动 API server 和 etcd,没有 kubelet、scheduler、ESO 或 controller-manager, +因此不能用它验证 Deployment、Pod 调度或 Service 网络。此类行为必须留给 Kind +E2E。 + +## 首次准备 + +推荐用支持 Dev Containers 的编辑器打开仓库。devcontainer 会提供 Go、Docker、 +Kubebuilder、Kind 和 kubectl。脚本固定 Kubebuilder 4.15.0、Kind 0.33.0 和 +kubectl 1.36.0,与当前脚手架和 Kubernetes Go module 对齐。容器启动后先确认: + +```sh +go version +docker info +kubebuilder version +kind version +kubectl version --client +``` + +不要在仓库中保存真实 OpenBao Token、数据库密码或 kubeconfig。Compose 中的 +`postgres-dev-only` 和 `dev-only-root-token` 是仅绑定回环地址、随容器销毁的公开 +测试值,不得复制到其他环境。 + +## 日常开发的正确顺序 + +### 1. 生成并验证纯 Go/Kubernetes 部分 + +```sh +make manifests generate +make test +make lint +``` + +`make test` 会下载与 `go.mod` 中 Kubernetes minor 版本匹配的 envtest 二进制, +启动临时 API server/etcd,测试结束后自动关闭。 + +规格实现后,快速测试必须覆盖默认值/校验、Condition `observedGeneration`、两个 CR 的 +status 状态机、不可变字段、extension 只追加、registry 所有权和外部错误分类。envtest 只断言 controller 创建了正确 +的 ExternalSecret;它不能证明 ESO 已生成 Secret。 + +### 2. 启动 PostgreSQL/OpenBao adapter 依赖 + +只有开发 PostgreSQL/OpenBao adapter 或完整 reconcile 时才需要: + +```sh +make dev-up +make dev-smoke +``` + +启动顺序由 Compose healthcheck 保证: + +1. 创建独立 Compose 网络; +2. 启动 PostgreSQL 和 OpenBao; +3. 等待 PostgreSQL `pg_isready` 成功; +4. 等待 OpenBao `bao status` 成功; +5. smoke test 执行 `SELECT 1`; +6. smoke test 在 OpenBao dev server 默认的 `secret/` KV v2 mount 写入并读回测试管理 + 凭据。 + +本机进程使用以下端点: + +```text +PostgreSQL: postgresql://postgres:postgres-dev-only@127.0.0.1:15432/postgres +OpenBao: http://127.0.0.1:18200 +Token: dev-only-root-token +``` + +若端口冲突,可以只对当前命令覆盖: + +```sh +POSTGRES_DEV_PORT=25432 OPENBAO_DEV_PORT=28200 make dev-up +``` + +后续执行 `dev-smoke` 和 controller 时必须使用相同端口变量。 + +Compose 使用明文 PostgreSQL/OpenBao dev 模式,不覆盖生产 TLS 合同。DNS SAN、IP SAN、 +Kubernetes auth、最小 policy 和 ESO 必须在 Kind E2E fixture 中验证。 + +### 3. 运行针对临时依赖的测试或 controller + +adapter 集成测试通过独立 Make target 执行,不默认塞进快速单元测试: + +```sh +make test-integration +``` + +该 target 会启动一次性 Compose 依赖,并通过 `POSTGRES_TEST_DSN` 把测试指向开发 +PostgreSQL。registry 测试会删除并重建固定的测试 schema,因此禁止将该变量指向真实 +homelab database。测试后运行 `make dev-down` 清理依赖。 + +Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问 +Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络, +并通过 `postgres:5432` 运行集成测试;清理前先断开该网络,才能删除 Compose +network。本机执行仍使用默认的 `127.0.0.1:15432`。 + +本机运行 controller 时,先确认当前 kubeconfig 指向专用 Kind,而不是真实 homelab: + +```sh +kubectl config current-context +make setup-test-e2e +kubectl config current-context +make install +make run +``` + +此时 controller 运行在开发容器内,可以直接访问上面的回环端口。若要验证 Tenant +Ready,专用 Kind 还必须安装 ESO、创建测试 ClusterSecretStore,并让 Kind workload +能够访问测试 OpenBao。不要把包含 +`127.0.0.1` 端点的样例部署到 Kind 内;Pod 中的回环地址只指向 Pod 自身。 + +### 4. 清理 + +```sh +make dev-down +make cleanup-test-e2e +``` + +`dev-down` 会删除 Compose volume;所有数据库和 OpenBao dev 数据都应视为一次性。 + +## E2E 顺序 + +CI 的 E2E 与本机 `make run` 不同:controller 会作为 Pod 运行在 Kind 中。因此完整 +E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等待依赖 Ready 后 +再创建 `PostgreSQLInstance` 和 `PostgreSQLTenant`: + +```text +创建 Kind + -> 安装 CRD + -> 部署 PostgreSQL/OpenBao fixture,签发含 DNS/IP SAN 的测试证书 + -> 安装 ESO,配置 OpenBao auth/policy 和 ClusterSecretStore + -> 等待依赖 Ready 并写入测试管理凭据 + -> 构建并加载 controller image + -> 部署 controller + -> 创建 Instance + -> 等待 Instance Ready + -> 创建 Tenant + -> 等待 Tenant Ready + -> 验证 registry、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret + -> 分别使用 DNS host 与 IP hostaddr 登录 + -> 删除 Tenant 并分别验证 Retain 与 Delete(含故障点重试) + -> 删除 Kind +``` + +当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动 +PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry +migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需 +后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。 + +## 测试数据与泄漏检查 + +- 只使用显眼的固定 canary 测试密码,测试后扫描日志、Event、Condition、metrics 和 + CR dump,出现 canary 即失败。 +- 每个最终一致性阶段都注入一次中断,重启后验证密码不变且阶段只向前推进。 +- 清空、落后或伪造超前的 `status.phase` 后验证它能从外部事实保守恢复/纠正,且不会 + 跳过任何回读。 +- 为未知同名 database、role、Bao record 和伪造 COMMENT 分别构造 Conflict。 +- Delete 在每个外部删除步骤失败后重试,确认未误删非当前 UID 资源。 +- 迁移测试按 [`migration.md`](migration.md) 完整执行,不以单纯 `pg_restore` 成功代替 + 应用读写和回滚验证。 + +## 故障排查 + +查看依赖状态与日志: + +```sh +docker compose -f hack/dev/compose.yaml ps +docker compose -f hack/dev/compose.yaml logs postgres openbao +``` + +如果 envtest 报端口监听失败,通常是当前执行环境禁止监听回环端口,而非 controller +失败;在 devcontainer 或允许本机监听的 runner 中执行。若 Kind 无法创建,先运行 +`docker info`,确认当前用户可以访问 devcontainer 内的 Docker daemon。 diff --git a/docs/database/domain-instance.md b/docs/database/domain-instance.md new file mode 100644 index 0000000..0b36fc9 --- /dev/null +++ b/docs/database/domain-instance.md @@ -0,0 +1,230 @@ +# Instance 领域对象规格 + +状态:Draft,含已确认决策。日期:2026-09-13。 + +上层合并边界见 [ADR-0008](../decisions/0008-merge-Ayatori Database controller.md)。本文只展开 Instance,不包含 Tenant 的供应 +实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。 + +## 1. 对象职责与生命周期 + +Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、 +根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao, +不查询权限或初始化 registry。 + +本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。** +应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收 +context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。 + +每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。 +管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。 + +身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或 +registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。 +新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。 +下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。 + +## 2. 字段与值对象 + +所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过 +下一轮装载新的 definition 处理,不提供任意 SetPhase/SetReady/SetEndpoint。 + +| 字段 | 类型与内容 | 来源/持久化 | 修改规则 | +| --- | --- | --- | --- | +| identity | InstanceIdentity:UID、name | CR metadata | 本次对象身份内不可变;同名新 UID 是新对象 | +| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 | +| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 | +| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 | +| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 | +| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 | +| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 | +| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 | +| reportedVersion | 可选服务器版本字符串 | status.postgresqlVersion;验证后从服务器更新 | 仅供展示,不能证明连接成功 | +| deleting | 是否已请求删除 | metadata.deletionTimestamp 映射 | 本轮不可变;优先于其他动作 | +| evidence | 可选 CapabilityEvidence | 本轮外部回读;不新增 status 字段 | 重建时始终为空,不能从 Ready Condition 伪造 | + +Endpoint 的构造约束沿用 API:非空 host、合法 IP、1–65535 端口、合法 PostgreSQL +identifier、显式 TLS mode,禁止隐式降级。CredentialReference 包含合法 Secret 名称 +及非空字段名,不包含 namespace 或 Bao path;namespace 由应用层固定为 controller +自身 namespace。这里校验领域值,不在对象里校验整个 controller 部署配置。 + +CapabilityEvidence 包含本轮目标绑定(Instance UID、revision、endpoint、凭据引用)、 +server version、管理能力检查结果、registry 观察结果。registry 结果区分 +Absent/NeedsMigration/Usable;连接失败不能当作 Absent。它不包含密码、token 或 DSN。 + +管理能力要求来自规格中的 role/database/grant/extension 操作,不等价于“能执行 +SHOW server_version”。具体权限探测矩阵需在 PostgreSQL 适配器规格中定义,不能 +让一个没有定义检查内容的布尔值承担验收。 + +不属于 Instance 的字段:Tenant 清单、客户端、连接池、token TTL、CA 文件句柄、 +Kubernetes resourceVersion。resourceVersion 留在应用层作为乐观并发保存的前提。 + +### 扩展支持判定(2026-09-14 已确认方向) + +v1alpha1 按目标 PostgreSQL 实际可安装的扩展列表判断请求,不实现管理员 allowlist。 +allowlist 仅保留为后续可选策略,不接受一个看似生效、实际被忽略的策略字段;现有 +CRD 的 allowedExtensions 应在对应 API 改动中移除,本次只修订文档。 + +应用层查询实际可用扩展并提供与本轮目标绑定的观察;Instance 只做集合判断,不 +访问数据库。不沿用之前提议的字符正则,不自动改大小写或名称;SQL 适配器仍须 +安全引用 identifier。可用列表不是已安装列表,也不保证权限或其他安装前提满足。 + +未观察/查询失败不得当作空集合或不支持;不得用旧目标的列表授权新目标的操作。 +非空请求须属于已观察的可用集合,返回不支持的名称;空请求无需扩展支持判定, +但不绕过 Instance 的其他就绪要求。安装后仍需回读,不能以集合匹配代替安装验证。 +列表变化不触发自动卸载;已有扩展的漂移处理留到 Tenant 用例细化。 + +## 3. 设计签名 + +```text +Reconstitute(identity, revision, definition, checkpointSnapshot, deleting) + -> Instance | InvalidDefinition + +Instance.BeginValidation() -> Outcome +Instance.AssessManagement(observation: CapabilityObservation) -> Outcome +Instance.PlanRegistryPreparation(observation: CapabilityObservation) + -> AlreadyUsable | PreparationAllowed | PreparationDenied +Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome +Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome +Instance.CheckExtensions(requested: ExtensionSet) + -> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved +Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady +Instance.BeginDeletion() -> Outcome +Instance.Snapshot() -> InstanceSnapshot +``` + +Outcome 是正常推进、已知失败或方法前提不成立,不包含重试秒数、Kubernetes patch +或原始驱动错误。InstanceSnapshot 只包含 checkpoint、observedRevision、readiness、 +reportedVersion,不能序列化 evidence。快照与集合访问返回值副本。 + +CapabilityObservation 是不可变的事实输入:目标绑定、服务器版本、管理能力检查项和 +registry 观察结果;各检查项区分成功、失败、未观察,未观察不视为成功。失败只含安全 +类别,不含驱动异常或凭据。对象校验目标绑定与当前身份/配置一致,拒绝不匹配输入, +不改变状态;完整性不足不能产生 Ready。观察结果由应用层收集,对象不能自行证明 +这些事实的真实性或实时性;采集来源、同轮次关联和并发检查由应用层保证。 + +RegistryPreparationResult 为操作失败(目标绑定、安全失败类别)或操作后的完整回读 +观察。单独的“迁移调用成功”不是就绪证据。CapabilityEvidence 是对象接受并判定满足 +要求的观察值,不是调用方传入的 Ready 布尔值。 + +### 构造与恢复 + +Reconstitute 校验期望 definition;无效输入不构造一个可参与用例决策的 Instance。 +入口把 InvalidDefinition 映射成 InvalidSpec,不必为了报告坏 CR 而制造非法领域对象。 +checkpoint 缺失或未知时保守使用 Pending;reportedVersion 和 Ready 都只是旧观察, +evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得通过供应检查。 + +### 方法合同 + +| 方法 | 前置条件/输入 | 行为与状态变化 | 失败语义 | +| --- | --- | --- | --- | +| BeginValidation | 未删除;初次登记、配置变更或需重建 checkpoint | 转 Validating,readiness=Unknown,清空 evidence;不做外部 IO,不推进 observedRevision | deleting 时不启动验证 | +| AssessManagement | 未删除;Validating;目标匹配的观察 | 判定管理访问、metadata、权限是否满足;registry 可用或可安全准备时转 InitializingRegistry,仍为 Unknown;不执行探测 | 失败保持 Validating,NotReady,observedRevision=当前版本 | +| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision | +| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready | +| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 | +| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 | +| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 | +| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 | +| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 | + +领域方法只检查对象状态,不知道 checkpoint 是否已落盘。“已持久化 checkpoint”是 +应用用例执行外部写入的前提。内存字段变成 InitializingRegistry 不代表已保存成功;不能 +在同一轮无条件接着执行迁移。通过用例测试验证此约束,而不是伪造一个内存事务。 + +AssessManagement 成功只是中间步骤,observedRevision 不前移;完成就绪判定或 +明确失败才产生相应有结论结果。旧版本字符串可供诊断,但失败会清空 evidence。 + +Instance 不在本轮暴露 CreateDatabase/DeleteDatabase:Tenant 的供应/销毁授权来自 +Tenant 和 OwnershipClaim,不是从 Instance.Ready 推导。数据库执行能力如何承接 +已授权动作,留到 Tenant 对象规格,不在这里设计第二个万能 service。 + +## 4. 应用层与外部访问边界 + +```text +应用层依赖的适配器能力(不传入 Instance): +InspectManagement(context, target) -> ManagementObservation | AccessFailure +InspectRegistry(context, target) -> RegistryObservation | AccessFailure +EnsureRegistry(context, target) -> Completed | AccessFailure +``` + +应用层在 IO 前绑定目标并关联结果,领域对象在接受观察时检查身份和配置匹配;旧 +endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移, +不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。 + +适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance +管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、 +认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。 +registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。 + +管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret, +ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。 +首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。 +检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池 +并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation +变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和 +刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。 +controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。 + +## 5. 状态转换与初始化走查 + +```text +Pending --BeginValidation/保存--> Validating +Validating --AssessManagement(观察)/保存--> InitializingRegistry +InitializingRegistry --AssessRegistryResult(回读结果)/保存--> Ready +Ready --配置变化或访问失败/保存--> Validating +Ready --registry 需修复/保存--> InitializingRegistry +任意阶段 --删除请求/保存--> Deleting +``` + +1. 入口读取 CR,装配 definition、checkpointSnapshot;客户端不注入领域对象。 +2. 应用层按 checkpoint 协调用例;首次调用 BeginValidation,没有 IO。 +3. 保存 Validating。若保存失败,结束本轮,不执行 registry 写入。 +4. 下一轮应用层调用适配器探测实例,将观察交给 AssessManagement;领域判定通过后 + 保存 InitializingRegistry,保存失败则停止,不进行迁移。 +5. 再下一轮应用层采集前置观察,调用 PlanRegistryPreparation。仅在意图已持久化且 + 领域允许时调用 EnsureRegistry;AlreadyUsable 则跳过写入,PreparationDenied 则 + 保存失败结果并停止。允许的操作完成后回读,交给 AssessRegistryResult 决定能否 + Ready;操作失败也用安全结果交回,不在应用层直接修改 phase。 +6. 入口用原 resourceVersion 前提保存快照;并发变更导致冲突时重新装载,不覆盖新状态。 +7. 后续 Ready 检查先由应用层探测,再调用 AssessReadiness;Tenant 用例同样获取当前事实,不能 + 仅凭另一个 CR 的 Ready Condition 永久缓存授权。实际资源写入仍须处理并发变化。 + +阶段调度和外部操作顺序在应用层;“观察是否满足业务要求、是否允许准备 registry、 +哪些结果算完成、失败退到哪里”在 Instance 方法内。controller 不重复这些规则, +也不直接把 phase 设置成 Ready。领域允许操作并不锁住外部世界,适配器仍须保障幂等 +和并发安全;禁止把旧观察当成永久授权。 + +## 6. 不变量与恢复验收 + +- UID 不随名称复用;不同 UID 的 evidence/结果不可互用。 +- 未完成当前配置的能力回读,不能新产生 Ready,也不能通过供应检查。 +- checkpoint 可以落后或被伪造;每次初始化/供应前都核对事实。status 清空只需重新 + 验证和幂等准备,不删除 registry,更不能重新生成 Tenant 密码。 +- 迁移成功而 status 保存失败:重试回读已存在 registry,安全完成,不重复破坏性写入。 +- registry 在 Ready 后消失:下一次回读撤销 Ready,保存修复意图后才能重新准备。 +- 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用 + 已超时的 IO 上下文而丢失失败状态。 +- 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或 + Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。 + 删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留 + finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于 + 领域 IO,Instance 只根据删除请求禁用供应能力。 +- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被 + finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消 + 已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。 +- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。 +- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。 +- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入 + 得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。 + +上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试 +验证。本文为设计文档,未执行或宣称通过这些测试。 + +## 7. 本轮待评审与后续阻塞项 + +本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。 +管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时 +重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则 +均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 + +批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。 diff --git a/docs/database/domain-model.md b/docs/database/domain-model.md new file mode 100644 index 0000000..606864d --- /dev/null +++ b/docs/database/domain-model.md @@ -0,0 +1,149 @@ +# 领域模型设计草案 + +状态:Draft,含已确认决策。日期:2026-09-13。 + +本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用 +设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以 +[系统规格](specification.md) 为准;下列未决问题不能由实现自行决定。 +PR #6 的代码和已有 registry 表结构是可评估的实现素材,不反向决定领域模型。 + +## 1. 领域与统一语言 + +本系统的领域是“在共享 PostgreSQL 上供应并管理应用租户”,不是数据库服务器运维。 +v1alpha1 先采用一个限界上下文,不把 PostgreSQL、Bao、Kubernetes 各自当成业务上下文。 + +| 术语 | 含义 | 不是什么 | +| --- | --- | --- | +| Instance | 平台登记的外部 PostgreSQL 管理对象及其供应策略 | 连接池、VM 或 controller 单例 | +| Tenant | 一个应用的数据库使用合同及受管资源生命周期 | PostgreSQL database 的别名 | +| Database | 租户数据库的名称、owner、扩展等期望描述与实际观察 | 包含 Bao 登录与连接关闭的操作接口 | +| LoginRole | 同时作为 database owner 和应用登录身份的角色 | 额外的 NOLOGIN owner | +| OwnershipClaim | 某个 Tenant 身份对一组资源名称与凭据位置的所有权声明 | 工作流阶段或仅凭名称推断的归属 | +| CredentialLocation | 固定推导的凭据位置及所有权关联 | 密码本身或用户可任意选择的 KV path | +| CredentialProjection | 把既定凭据交付到目标 Secret 的要求与观察结果 | controller 直接写入明文 Secret | + +UID 表示一次 Kubernetes 对象身份;namespace/name 用于定位,不足以证明归属。 +database OID 是诊断观察值,不充当本系统的租户身份。 + +## 2. 候选聚合边界 + +### Instance:实例能力与供应策略 + +Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察, +以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。 + +其行为包括: + +- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。 +- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。 +- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。 +- 在 registry 初始化完成并回读验证后,接受新的就绪结果。 + +“探测实例”“准备管理 registry”是应用用例协调的外部操作,不是 Instance 的 IO 方法。 +领域对象只接收观察值,负责前提、规则和状态决策;应用层调用适配器获取事实与执行 +获准操作。领域对象不持有或调用外部访问端口、客户端或回调。具体选择见 +[Instance 字段与行为](domain-instance.md),仍处于待评审状态。 + +### Tenant:供应合同与资源生命周期 + +Tenant 是另一个候选聚合根,通过身份引用 Instance,而不是 Instance 的聚合成员。 +操作一个 Tenant 不应要求装载、锁定或保存整个实例的租户集合。 + +Tenant 持有有效的 database/role 名称、请求的扩展、凭据交付目标、删除策略,以及 +已建立的资源绑定。它负责: + +- 检查绑定后的不可变字段、extension 只追加规则。 +- 判断外部部分状态属于本 Tenant、尚不存在,还是与未知资源冲突。 +- 决定是否允许继续供应、何时达到 Ready、是否允许释放受管资源。 +- 按 Retain/Delete 合同限制行为,禁止把保留资源自动认领给同名新 UID。 + +Database、LoginRole 和 CredentialProjection 暂不设独立聚合根或独立 CRUD 用例。 +它们可作为 Tenant 内的资源描述与观察值;有规则才增加行为,不为了“充血”添加方法。 +真实 PostgreSQL database/role 的存在不意味着内存中必须各有一个有身份的实体。 + +聚合边界是业务规则的保护边界,不表示 Tenant 对应的 PostgreSQL、Bao、ESO 资源 +能够一次事务提交。跨系统供应必须允许部分完成。 + +### OwnershipClaim:跨租户唯一性与持久证据 + +名称唯一性不可能只靠某个 Tenant 的内存检查保证。需要一项领域能力,在持久化边界 +原子认领资源;已有 registry 是其适配器候选,仍需结合 catalog 和 Bao metadata 检查。 + +Claim 与 Tenant 关联,但不随 Tenant CR 消失:Retain 后证据必须继续存在。因此不能 +把它仅视为 CR 的附属 status。是否作为独立的小聚合,先以“可独立持久化、保留并保护 +归属不变量的声明”建模;不因此引入新的 CRD。 + +- 同一身份、同一绑定的重复认领可以成功;不同 UID 或不同绑定不能覆盖。 +- Claim 预留名称不等于证明同名外部资源由本 controller 创建。 +- 实际写入仍须核对所有权,不能把先查后建当成并发安全保证。 +- 当前 registry 的数据库事务不能覆盖 Bao;跨实例的凭据路径竞争也不能靠单个 + registry 的唯一约束解决。写入前提与条件创建协议需单独设计和验收。 + +## 3. 领域、用例与适配器的分工 + +| 层 | 承担的职责 | 禁止承揽的职责 | +| --- | --- | --- | +| 领域对象/策略 | 身份、有效合同、归属判断、允许的动作、完成条件 | 外部 IO(包括通过接口间接调用)、解析 CLI、生成 Kubernetes Condition | +| 应用用例 | 装载模型与事实、持久化意图、调用能力、回读、提交结果 | 另写一套绕过领域规则的判断流程 | +| controller 入口 | CR 映射、调度、watch、重试、status/finalizer 写入 | 在 reconcile 中重新定义业务规则 | +| 基础设施适配器 | PostgreSQL、registry、Bao、ESO 的实际读写与并发保障 | 自行决定接管、改密码或扩大删除范围 | +| 启动装配 | 校验部署配置,创建共享客户端、连接管理器及用例依赖 | 把连接生命周期当成 Instance 的业务状态 | + +领域可使用独立的身份、endpoint、identifier、extension 集合等值对象,不依赖 CRD +类型、pgx pool 或 Bao SDK。Kubernetes 对象的存取与 registry 的存取不是一个通用 +`Save(Tenant)` 可以原子完成的事情;不虚构跨系统 Unit of Work。 + +暂不引入事件总线、事件溯源、通用聚合框架或全套 Repository CRUD。领域建模的依据 +是业务规则,而不是接口和目录数量。 + +## 4. 状态与恢复 + +CR `status.phase` 仍是已批准的工作流 checkpoint,不在内存对象或 registry 再建一套 +权威 phase。领域对象可以由 CR 的期望状态、checkpoint 和外部观察重新构造。 + +phase 只决定候选步骤,外部证据决定该步骤是否允许执行、是否已经完成。应用层在 +写操作前保存意图,调用幂等操作后回读,再保存下一 checkpoint。status 写入失败时, +下次从外部事实识别完成结果;不能重发密码,也不能相信伪造的 Ready。 + +业务失败区分 InvalidSpec、ImmutableField、Conflict 等;依赖故障由适配器转换成 +安全的能力失败,应用层决定重试并映射 Condition。凭据不进入模型序列化、status、 +事件或错误明细;只能在实际需要它的执行边界短暂传递。 + +## 5. 用例走查与验收方向 + +| 场景 | 领域判定 | 应用与适配器执行/恢复 | +| --- | --- | --- | +| 登记 Instance | 当前配置的能力要求是否满足 | 读取管理凭据,验证连接与权限,准备并回读 registry;完成后才 Ready | +| 供应 Tenant | Instance 策略、绑定与归属允许供应 | 保存意图,认领资源,先写并回读 Bao 凭据,再创建 role/database,登录验证和 ESO 投射 | +| Bao 写入后进程中断 | 同一身份的部分状态可继续 | 回读原凭据继续,不生成第二份密码 | +| 两个 Tenant 竞争名称 | 只有匹配所有权的一方可继续 | 持久化认领和条件写入裁决竞争,失败方 Conflict,不覆盖资源 | +| Delete 中断 | 已消失资源可视为完成;剩余资源仍须归属正确 | 按规格顺序继续删除,全部回读不存在后才清 registry 和 finalizer | +| Retain 后同名 CR 重建 | 新 UID 不等于原所有者 | Conflict,不恢复管理、不改密码 | + +领域测试验证规则与决策;adapter 测试验证锁、条件写入、SQL 与协议行为;controller +测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用 +就声称实现了最终一致性。 + +## 6. 决策记录与待细化边界 + +1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证 + 物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置 + 的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。 +2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记 + unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子 + 完成两者,也不能在没有回读时声称已写入保留标记。 +3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的 + 管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接 + 从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready; + 首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并 + 重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。 +4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后 + 不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失 + 后恢复;否则供应中途改名称可能产生无人管理的资源。 + +5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应, + 有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer + 与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。 + +Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。 +相关用例在决策批准前不进入实现,不同时实现整套模型。 diff --git a/docs/database/migration.md b/docs/database/migration.md new file mode 100644 index 0000000..a766895 --- /dev/null +++ b/docs/database/migration.md @@ -0,0 +1,125 @@ +# 现有数据库迁移 Runbook + +| 项目 | 内容 | +| --- | --- | +| 状态 | Review;尚未在临时 PostgreSQL 演练 | +| 适用范围 | 任意既有数据库迁移为新建 v1alpha1 Tenant | +| 最后更新 | 2026-09-10 | + +v1alpha1 不接管现有 database、role 或 OpenBao record。本流程通过逻辑 dump/restore 把 +数据迁移到 controller 创建的新资源,保留旧资源作为限时回滚点。 + +以下命令是顺序模板,不可原样复制到真实环境。先把尖括号变量解析成明确值,确认当前 +连接目标,再逐条执行。dump 可能包含敏感业务数据,必须放在加密临时存储且不得提交 Git。 + +## 前置条件 + +- 已验证 PostgreSQL/OpenBao 备份和恢复;记录恢复点。 +- Instance 已 Ready,目标 namespace 存在,ESO ClusterSecretStore Ready。 +- 最终 database/login role 当前由旧应用占用,但改名后的保留名称、新推导的 Bao path + 均不存在。 +- 已记录旧 database owner、grants、extensions、locale/encoding、连接配置和验证清单。 +- 已确认应用可停止写入,并确定回滚窗口和负责人。 +- 已确认旧 login role 不被其他 database/应用共享,且角色改名不会破坏未纳入本次维护 + 的依赖。 + +## 迁移顺序 + +### 1. 盘点与预演 + +```sh +pg_dump --schema-only --no-owner --no-privileges \ + --dbname='' > schema-preview.sql +``` + +检查不受 v1alpha1 管理的对象:额外 roles、跨库依赖、FDW、large objects、订阅、显式 +tablespace、owner/grant 和目标实例不支持的 extension。无法映射为单 database + 单 login +owner 的环境必须先人工简化,不能让 controller 猜测。 + +### 2. 创建一致性 dump + +停止应用写入并确认活跃写事务结束,然后创建最终 custom-format dump: + +```sh +pg_dump --format=custom --no-owner --no-privileges \ + --file='/tenant.dump' \ + --dbname='' +pg_restore --list '/tenant.dump' +``` + +不要删除旧 database/role。记录停写时间、dump checksum 和 PostgreSQL 版本。 + +### 3. 释放最终名称 + +保持应用停写,终止旧 database 的应用连接。连接其他管理 database,以管理员身份把旧 +database 和旧 login role 改为明确的保留名称: + +```sql +ALTER DATABASE RENAME TO _retained_; +ALTER ROLE RENAME TO _retained_; +``` + +identifier 必须由管理员工具安全引用,不能把未经校验的值直接拼入 SQL。PostgreSQL 在 +角色改名时会清除以旧角色名加盐的 MD5 密码;使用 MD5 的旧环境必须在维护前准备安全的 +密码重设/回滚方法。SCRAM verifier 不受角色名改动影响,但仍须实际验证回滚登录。 + +### 4. 创建受管空目标 + +应用 `PostgreSQLTenant`,使用未被占用的 database/loginRole,等待 Ready。确认: + +- registry 记录 UID 正确; +- OpenBao metadata 属于该 Tenant; +- ExternalSecret Ready 且目标 Secret 已投射; +- 新凭据可以通过 DNS host 和 IP hostaddr 分别登录空 database。 + +### 5. Restore + +从 OpenBao 或目标 Secret 安全取得新应用凭据,不要把密码放进 shell history。以新 login +owner 连接目标 database: + +```sh +pg_restore --exit-on-error --no-owner --no-privileges \ + --dbname='' \ + '/tenant.dump' +``` + +extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore +行为幂等;目标实例不支持的 extension 必须在迁移前解决。 + +### 6. 验证并切换 + +- 对比关键 schema、表数、行数/校验和、sequence、function 和 migration version。 +- 用新 login 验证读写、migration 和应用健康检查。 +- 将应用配置切换到新 Secret 或 OpenBao URL,保持旧数据库只读/停写。 +- 观察一个约定窗口,确认错误率、连接数和关键业务功能。 + +### 7. 收尾 + +回滚窗口结束后,按独立变更删除旧 database/role/旧凭据;它们不属于 controller,禁止 +通过 Tenant `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。 + +## 回滚 + +在新目标出现问题且旧资源仍保留时: + +1. 立即停止新目标写入。 +2. 评估切换后是否产生新数据;若有,先决定反向迁移或接受丢弃,不能盲目切回。 +3. 将应用连接切回 retained database/role;若必须恢复原名称,先确保新受管目标已用 + `Delete` 完整清理或改用不同名称,再安全地反向执行 rename。 +4. 恢复旧凭据(MD5 环境可能需要重设),验证旧服务。 +5. 保留失败 Tenant 供排障;选择 Retain 或 Delete 前明确其外部资源后果。 + +若已经删除旧资源,则只能使用已验证备份恢复,不再属于本 runbook 的快速回滚。 + +## 演练验收 + +发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录: + +- 使用的 PostgreSQL major version 和命令版本; +- dump/restore 返回码和对象差异; +- DNS/IP TLS 登录结果; +- ESO 投射与应用启动结果; +- 回滚演练结果; +- 哪些命令或前置检查需要修订。 + +完成演练前,本文不得标记为 `Verified`。 diff --git a/docs/database/operations.md b/docs/database/operations.md new file mode 100644 index 0000000..e98d45b --- /dev/null +++ b/docs/database/operations.md @@ -0,0 +1,76 @@ +# 运维与故障处理 + +| 项目 | 内容 | +| --- | --- | +| 状态 | Review;命令待实现后演练 | +| 最后更新 | 2026-09-10 | + +## 日常检查 + +先看 API 合同,而不是从日志猜状态: + +```sh +kubectl get postgresqlinstances +kubectl get postgresqltenants -A +kubectl get postgresqltenant -n -o yaml +kubectl describe postgresqltenant -n +``` + +随后检查 controller 日志、ExternalSecret/Secret、OpenBao metadata、registry 和 +PostgreSQL catalog。排障时不得把 Secret data 或带 Token 的请求粘贴到 issue/日志。 +`status.phase` 是 controller 状态机 checkpoint,也用于定位当前步骤;`Ready` +Condition/Reason 用于判断对外结果。phase 不能替代外部事实,清空或不一致时应由 +controller 自动重建/纠正。 + +## 常见 Reason + +| Reason | 首要检查 | +| --- | --- | +| `InvalidSpec` / `ImmutableField` | API 字段、identifier、不可变/只追加约束 | +| `DependencyUnavailable` | 网络、DNS、服务状态和超时 | +| `AuthenticationFailed` | 管理凭据、CA、DNS/IP SAN、OpenBao auth | +| `InsufficientPrivileges` | PostgreSQL grants、OpenBao policy、Kubernetes RBAC | +| `InstanceNotReady` | 先恢复所引用 Instance | +| `Conflict` | registry UID、同名 DB/role、OpenBao metadata;禁止直接覆盖 | +| `CredentialProjectionFailed` | ClusterSecretStore、ExternalSecret Condition、目标 Secret | +| `ProvisioningFailed` | `status.phase` 及对应外部资源的回读结果 | + +修复依赖后让正常 reconcile 自动重试。不要通过删除/重建 CR 规避 Conflict;新 UID 只会 +使已有保留资源继续冲突。 + +## Retain 后的资源 + +Retain 删除完成后,database、role、OpenBao record 和 registry 所有权记录仍存在但标记 +unmanaged。v1alpha1 不支持重新关联。需要恢复管理时,使用 [`migration.md`](migration.md) +把数据迁移到一个全新受管名称;不要手工把 registry UID 改成新 CR UID。 + +## Delete 卡住 + +1. 暂停应用写入并记录 Tenant UID、Instance UID、database、role 和 Bao path。 +2. 从 registry 和 OpenBao metadata 独立确认所有权。 +3. 检查删除阶段,修复 PostgreSQL/OpenBao/ESO 依赖,让 controller 继续。 +4. 若依赖永久丢失,列出每个可能残留的 database、role、KV metadata 和 Secret。 +5. 只有确认接受这些残留后,才人工移除 finalizer。 + +最终 finalizer 名称由 API 实现固定后补入命令。人工移除 finalizer不会执行剩余清理, +也不会把外部资源变成可由新 CR 接管的资源。 + +## 备份与恢复 + +- PostgreSQL VM/磁盘备份必须与数据库一致性策略配套;仅复制在线磁盘不自动等于有效 + PostgreSQL 备份。 +- PostgreSQL 备份必须包含管理 database 中的 controller registry。 +- OpenBao 使用独立的受支持备份/快照流程,且恢复点应与 PostgreSQL 尽量接近。 +- Kubernetes 侧备份 CR、controller 配置、ClusterSecretStore 和公开 CA bundle,不备份 + 明文 Secret 作为凭据事实来源。 +- 定期在隔离环境执行恢复演练,验证 registry、KV metadata、应用登录及 Retain/Delete。 + +恢复后先停止 controller,核对 PostgreSQL/OpenBao 时间点与 UID 映射,再启动单副本 +controller 观察;出现一侧存在、一侧缺失时不得手工生成新密码或改 registry,应先按 +Conflict 处理并决定恢复哪一侧。 + +## 升级与紧急停止 + +有疑似越权删除或凭据泄漏时,先把 controller Deployment scale 到 0,保留 CR、registry +和日志证据,再撤销 OpenBao token/role 并限制 PostgreSQL 管理 role。恢复前在隔离环境 +复现并确认不会扩大破坏。一般依赖故障无需 scale down,最终一致性会自动重试。 diff --git a/docs/database/security.md b/docs/database/security.md new file mode 100644 index 0000000..bb6004b --- /dev/null +++ b/docs/database/security.md @@ -0,0 +1,71 @@ +# 安全模型 + +| 项目 | 内容 | +| --- | --- | +| 状态 | Review | +| 最后更新 | 2026-09-10 | + +## 保护目标 + +- 应用密码只存在于 OpenBao、ESO 投射的目标 Secret 和需要使用它的进程内存中。 +- controller 只能修改其 registry 能证明归属当前 Tenant UID 的资源。 +- namespace 租户不能越权管理 Instance、其他 namespace 或 controller 配置。 +- PostgreSQL 和 OpenBao 的网络身份使用受信 CA 验证,不因 DNS 不可用而降级 TLS。 + +## 信任边界 + +Kubernetes 管理员、OpenBao 管理员和 PostgreSQL 管理员是平台信任主体。能读取 Tenant +目标 Secret 或对应 OpenBao path 的主体等同于持有数据库账号。database owner 可以 +改变自己 database 内的对象,因此 COMMENT 不能作为 controller 所有权依据。 + +VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 OpenBao 中的密码;完整 +灾难恢复必须同时保护 PostgreSQL 与 OpenBao,并控制两份备份的访问权限。 + +## 凭据处理 + +- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。 +- 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到 + CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。 +- 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。 +- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。 +- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、 + DSN、Authorization header、密码或完整 OpenBao URL path 作为 metric label。 +- panic、错误包装和测试失败输出必须经过凭据泄漏测试。 + +## TLS + +- homelab 默认 `verify-full`,`disable` 只允许显式开发配置。 +- server 证书同时覆盖 DNS `host` 和 IP `hostaddr`;消费者自行选择连接目标。 +- OpenBao PKI 保管 CA 私钥并负责签发/续期。controller Deployment 只挂载公开 CA + bundle,挂载只读且使用最小文件权限。 +- 证书轮换必须先发布同时信任新旧 CA 的 bundle,再轮换服务端证书,最后移除旧 CA。 + +## 最小权限 + +OpenBao controller identity 只管理固定 tenant base path,不读取管理凭据。管理凭据 +ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者隔离,Tenant 不得 +使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance +不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。 + +PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或 +删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向 +controller role 授予 EXECUTE。controller 不调用 shell 或 `psql` 拼接用户输入。 + +Kubernetes RBAC 应把 cluster-scoped Instance 管理限制给平台管理员。Tenant editor +不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。 + +## 删除保护 + +Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance UID、Tenant UID、名称和 +OpenBao metadata。禁止对未知对象使用 `CASCADE`。删除 finalizer 卡住时只能按 +[`operations.md`](operations.md) 核实外部状态后人工移除;该操作可能遗留资源。 + +## 发布前安全验收 + +- 使用错误 CA、错误 DNS 名和错误 IP 时连接失败;正确 DNS/IP SAN 均成功。 +- namespace 用户不能修改 Instance 或跨 namespace Tenant/ExternalSecret。 +- controller/ESO 的 OpenBao policy 互相隔离,越权请求被拒绝。 +- 应用 login 不能创建 role/database,也不能连接其他租户 database。 +- 日志、Event、Condition、metrics、CR 导出和测试 artifact 不含 canary password/token。 +- 伪造 COMMENT、同名 database/role 或错误 UID metadata 均不能绕过 Conflict。 +- Delete 只销毁 registry 可证明归属当前 Tenant 的资源。 diff --git a/docs/database/specification.md b/docs/database/specification.md new file mode 100644 index 0000000..a13565f --- /dev/null +++ b/docs/database/specification.md @@ -0,0 +1,558 @@ +# PostgreSQL Tenant Operator 系统规格说明书 + +| 项目 | 内容 | +| --- | --- | +| 状态 | Approved | +| 目标 API | `database.ayatori.ddupan.top/v1alpha1` | +| 最后更新 | 2026-09-13 | +| 批准日期 | 2026-09-10 | +| 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 | + +本文档定义系统对用户和外部依赖呈现的行为,是 API、测试和实现共同遵守的合同。 +实现若需要改变本文合同,必须先修改规格并重新获得批准。 + +文中的“必须”“禁止”“应当”“可以”分别对应强制要求、强制限制、推荐行为和可选 +行为。 + +## 1. 背景 + +homelab 中的大部分应用共享一个运行在独立 VM 上的 PostgreSQL DBMS。应用需要各自 +独立的 database、作为 owner 的 login role 和密码,但不需要独立 PostgreSQL 实例。 +目前这些资源依靠人工 SQL 和人工 Secret 管理,难以重复、审计和检测漂移。 + +本系统使用 Kubernetes CRD 作为声明式 API,持续协调外部 PostgreSQL 与 OpenBao: + +```text +PostgreSQLInstance / PostgreSQLTenant + | + v + Ayatori Database controller + | | + v v + PostgreSQL catalog OpenBao KV v2 +``` + +## 2. 目标 + +v1alpha1 必须实现以下目标: + +1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。 +2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。 +3. 根据实例实际可安装扩展列表检查并安装租户申请的 PostgreSQL extension。 +4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。 +5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。 +6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。 +7. 同时输出 PostgreSQL DNS hostname 和 IP address,不假定所有消费者都能使用集群内 + DNS。 +8. 持续检测并修正由本系统管理的非破坏性漂移。 +9. 通过 Kubernetes Condition 报告进度、成功和可操作的失败原因。 +10. 重复 reconcile、controller 重启及外部依赖暂时失败不得重复创建或破坏资源。 +11. 删除 Tenant CR 时默认保留外部资源;显式选择 `Delete` 时提供完整清理路径。 + +## 3. 非目标 + +v1alpha1 不负责: + +- 创建、升级、备份或高可用运行 PostgreSQL DBMS/VM; +- 创建或运维 OpenBao; +- 直接写入包含凭据明文的 Kubernetes Secret;Secret 必须由 ESO 投射; +- 动态凭据、定时或自动密码轮换; +- Web UI、独立 REST API 或 Backstage 插件; +- 跨实例迁移 database; +- schema/table 级别租户、多 login role 或跨租户 grant; +- 删除不属于本系统管理的 database、role、extension 或 OpenBao Secret; +- 接管不是由本系统创建的外部资源; +- 提供生产环境 SLA。 + +## 4. 参与者与事实来源 + +| 对象 | 事实来源 | 说明 | +| --- | --- | --- | +| 期望状态 | Kubernetes CR `spec` | 用户声明的合同 | +| 最近观察结果与当前阶段 | Kubernetes CR `status` | 可以丢失并重建,不是外部事实来源 | +| database/role/grant/extension | PostgreSQL catalog | 每轮 reconcile 必须重新读取 | +| 受管资源所有权与保留标记 | PostgreSQL controller registry | 与受管 DBMS 一起备份和恢复 | +| controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 | +| 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 | +| Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 | +| PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret | + +平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和 +PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理 +`PostgreSQLTenant`。 + +## 5. 资源模型 + +### 5.1 PostgreSQLInstance + +`PostgreSQLInstance` 是 cluster-scoped 资源,表示一个已经存在、可由 controller +管理的 PostgreSQL server。 + +它必须声明: + +- PostgreSQL host、port 和管理连接使用的 database; +- PostgreSQL host address,供无法解析 DNS 的消费者使用; +- TLS mode; +- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。 + +可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance +中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装 +权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。 + +实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全 +建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。 + +Instance 身份和 endpoint 以管理员声明为准。修改 endpoint 不验证是否仍是原物理 +服务器或原 registry,不增加服务器/安装身份绑定检查;但旧配置观察失效,必须按 +新配置重新检查连接和管理能力。新 CR 按新 Instance 处理,不自动接管旧 UID 的租户 +资源。controller 不迁移旧服务器上的数据,也不清理旧目标,影响由管理员负责评估。 + +Instance 开始受管时即添加 finalizer,成功保存后才参与供应,不等发现 Tenant 后 +再补加。删除期间停止新供应;仍有引用它的 Tenant(包括正在删除的 Tenant)时保留 +finalizer,无引用后才移除。不级联删除 Tenant 或任何外部数据库、角色、凭据。 +引用检查失败不得当作无引用。首版不引入跨对象锁或准入控制:finalizer 不禁止同时 +创建 Tenant CR,新 Tenant 遇到正在删除或已不存在的 Instance 时不得开始供应。 +这不保证列表检查、CR 创建和在途外部操作之间的原子性;不是严格的跨对象事务。 + +### 5.2 PostgreSQLTenant + +`PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应: + +- 一个 `PostgreSQLInstance`; +- 一个 database; +- 一个同时作为 database owner、供应用使用的 `LOGIN` role; +- 零个或多个 extension; +- 一个 OpenBao KV v2 凭据位置; +- 一个同 namespace ExternalSecret 及其目标 Kubernetes Secret。 + +Tenant 的 namespace 用于 Kubernetes RBAC 和身份识别,不代表 PostgreSQL schema。 +同一 Instance 中的 database 和 role 名称全局唯一。 + +## 6. 标识与默认值 + +以下是 v1alpha1 的标识合同: + +| 字段 | 默认值 | 约束 | +| --- | --- | --- | +| Instance port | `5432` | 1–65535 | +| Instance host address | 无 | 必须是合法 IPv4 或 IPv6 address | +| 管理 database | `postgres` | 合法 PostgreSQL identifier | +| TLS mode | `verify-full` | 禁止隐式降级 | +| Tenant database | `metadata.name` | 同一 Instance 全局唯一 | +| login role | `metadata.name` | 同一 Instance 全局唯一 | +| deletion policy | `Retain` | `Retain` 或 `Delete` | + +Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不得超过 241 个字符,确保 +派生的 ExternalSecret/Secret 默认名称 +`--postgresql` 不超过 Kubernetes 253 字符限制。 + +固定默认值由 CRD defaulting 写入。依赖 `metadata.name` 或 `instanceRef` 的 database、 +login role、ExternalSecret/Secret 名称属于 controller 语义默认值:省略字段不会被 admission +回写,controller 必须始终计算同一个 effective value,并通过 status 的 database、 +loginRole、credential reference 以及实际资源展示。 +v1alpha1 不为此引入 mutating webhook。 + +database 和 role 名称必须作为 PostgreSQL identifier 参数安全引用,禁止通过字符串 +拼接执行。名称校验必须拒绝空字符串、NUL 和超过 PostgreSQL identifier 长度限制的 +值,并统一限制为小写字母、数字和下划线。 + +Tenant 首次成功后,`instanceRef`、database、login role 和凭据位置必须 +不可变。修改这些字段不是 rename 或 migration,API 必须拒绝或报告明确的 +`ImmutableField`。 + +## 7. PostgreSQL 权限合同 + +建议的 v1alpha1 权限模型如下: + +1. database 必须由 login role 拥有。 +2. login role 必须是 `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`。 +3. 必须撤销 `PUBLIC` 对租户 database 的连接权限,再显式允许 login role 连接。 +4. controller 不得修改其他 database 或无关 role 的权限。 +5. controller 只保证请求的 extension 存在;移除 extension 不得自动执行 + `DROP EXTENSION`。 + +这意味着应用可以在自己的 database 内执行 schema migration,但不能创建其他 +database、role 或访问其他租户。v1alpha1 不创建只有形式意义、却未隔离运行时权限的 +额外 `NOLOGIN` owner。若以后应用能分别使用 migration 和 runtime 凭据,再通过新的 +权限 profile 引入 owner/migrator/runtime 角色模型。 + +## 8. OpenBao 凭据合同 + +### 8.1 Controller 自身认证 + +controller 必须使用 Kubernetes auth 登录 OpenBao。controller 使用的 OpenBao API +address、提供给消费者的 OpenBao API address、auth mount、auth role 和 KV v2 mount +属于部署配置,不属于任何 CR。两个 API address 可以相同;若 controller 使用集群内 +地址而外部消费者不能解析,则必须单独配置 consumer address。生产部署的 KV mount +默认为 `kv`;开发环境可以配置为 OpenBao dev server 默认的 `secret`。长期 OpenBao +Token 禁止写入 Deployment、CR 或镜像。 + +### 8.2 管理凭据 + +`PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及 +用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。 +平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读 +Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。 + +Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问 +PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。 +首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码 +变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关 +字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际 +故障报告,不能用旧连接的成功结果证明新凭据可用。 +controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧 +凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。 +Tenant 凭据管理仍直接依赖 Bao。 + +### 8.3 租户凭据 + +Tenant 不声明凭据 path。controller 根据部署级 KV mount、base path 和 Tenant 的 +namespace/name 推导唯一的 mount-relative path。base path 来自 controller 启动参数 +`--openbao-tenant-base-path`,默认 `postgresql-tenants`。最终路径为 +`//`。推导结果禁止以 `/` 开头,禁止包含空路径段、 +`.`、`..`,也禁止把 KV v2 HTTP API 的 `data` 或 `metadata` 层编码进路径。 + +新 Tenant 的凭据建立顺序必须可从任意中断点恢复: + +1. 验证 Instance、名称、extension 和目标 OpenBao 路径; +2. 确认目标 database、role 和 OpenBao 记录不存在,或能够验证为同一 Tenant + 已创建的部分状态; +3. 生成密码; +4. 先创建带 controller 所有权 metadata 的 OpenBao KV v2 记录; +5. 从 OpenBao 重新读取凭据; +6. 使用该凭据创建作为 database owner 的 login role 和其他 PostgreSQL 资源; +7. 用 login role 实际连接目标 database; +8. 全部验证成功后将 Tenant 标记 Ready。 + +若第 4 步成功、后续 PostgreSQL 操作失败,下一轮必须读取同一份 OpenBao 凭据继续, +不得生成第二个密码。若 PostgreSQL 先存在而 OpenBao 记录不存在,controller 必须报告 +Conflict,不得擅自重置已有 role 密码。 + +controller 必须在 PostgreSQL 管理 database 的专用 registry schema 中持久保存可验证的 +Instance UID、Tenant UID 与 namespace/name 关联,不能只依赖会丢失的 CR status 判断 +资源所有权。registry 必须可回读且不得改变数据库授权语义;database 或 role COMMENT +不能作为权威所有权记录。 + +默认写入字段固定为: + +```text +username +password +database +host +hostaddr +port +sslmode +``` + +这些字段是 controller 的规范化输出合同。controller 不生成包含密码的 URI、JDBC URL +或应用专用键名。应用通过 ExternalSecret template、Helm values 或自身配置把原子字段 +映射为 `DATABASE_URL`、独立环境变量或配置文件;因此 URI escaping 和应用特有格式也 +由消费方负责。`host` 是 DNS 名称,`hostaddr` 是可直接连接的 IP;消费者自行选择其 +支持且可达的连接目标。PostgreSQL server 证书必须同时包含与 `host` 匹配的 DNS SAN +和与 `hostaddr` 匹配的 IP SAN,使两种目标都能在 `verify-full` 下独立完成身份验证。 + +### 8.4 凭据输出与 ExternalSecret + +controller 必须根据部署级 base path 推导 Tenant 的 KV path,Tenant 不能选择 mount 或 +任意远端路径。ExternalSecret 固定命名为 +`--postgresql`。Tenant 可以通过 +`spec.credential.secretName` 指定目标 Kubernetes Secret 名称;省略时使用同一默认名。 +自定义名称只需是合法 Kubernetes Secret 名称,不限制命名内容;两者均与 Tenant 位于 +同一 namespace。 + +controller 必须创建同 namespace ExternalSecret,从固定的 ClusterSecretStore 读取七个 +原子字段。ExternalSecret 及目标 Secret 的名称通过 Tenant status 暴露。controller +不得直接读取 OpenBao 密码后写入 Kubernetes Secret。 + +Tenant status 还必须提供完整、可由外部消费者使用的 OpenBao KV v2 API URL。URL 可以 +包含 consumer API address、mount 和 secret path,但不得包含 Token、密码或其他认证 +信息。默认 `kubectl get` 表格显示目标 Secret 名称;完整 OpenBao URL 通过 +`kubectl get postgresqltenant -o yaml` 获取,避免表格列过长。 + +OpenBao metadata 必须能够标识 Tenant UID、namespace/name 和 Instance,使 controller +区分自己的残留记录与外部记录。任何凭据值都不得进入日志、Event、Condition、metric +label、trace、CR spec/status 或测试快照。 + +## 9. Reconcile 行为 + +系统采用最终一致性模型。Kubernetes、PostgreSQL、OpenBao 和 ESO 可以短暂处于不同 +阶段;controller 不尝试实现跨系统事务,而是以 Kubernetes CR `status.phase` 作为 +工作流 checkpoint,通过幂等外部操作和每轮回读验证最终收敛。 + +两个 CR 的状态机权威记录都在 `status.phase`。controller 根据 phase 选择下一项候选 +动作,但 phase 不能替代外部状态检查:执行前后仍须回读 PostgreSQL catalog、registry、 +OpenBao 和 Kubernetes/ESO。外部写入成功但 status 更新失败时,下一轮必须识别已完成 +事实并推进 phase,不得重复生成密码或报告虚假冲突。 + +status 丢失时,controller 必须从 registry 的所有权记录和各外部系统实际状态保守重建 +phase。若 status 被伪造或领先于实际状态,controller 必须纠正到安全阶段并补齐资源, +不能跳过验证。registry 不保存或驱动协调 phase。 + +Instance phase 按当前 generation 表示连接与初始化进度: + +```text +Pending -> Validating -> InitializingRegistry -> Ready +(any phase) --------------------------------> Deleting +``` + +spec generation 改变后可以从 `Ready` 回到 `Validating`。Tenant phase 如下: + +```text +Pending -> Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated + -> ExternalSecretCreated -> CredentialProjected -> Ready -> Deleting +``` + +失败不增加 `Failed` phase;phase 保留在无法推进的步骤,由 `Ready=False` 的 Reason 和 +message 表达 `Conflict`、认证失败或依赖不可用。Retain 删除完成后 CR 已不存在,因此 +没有持久的 `Retained` phase。 + +每轮 Tenant reconcile 必须按以下逻辑执行: + +```text +读取 Tenant + -> 读取 Instance + -> 校验不可变字段与输入 + -> 检查 Instance Ready + -> 读取 OpenBao 与 PostgreSQL 实际状态 + -> 检测冲突或部分完成状态 + -> 执行非破坏性补齐 + -> 使用应用凭据验证登录 + -> 创建并验证 ExternalSecret/Secret 投射 + -> 回读实际状态 + -> 更新 status +``` + +要求: + +- 所有步骤必须幂等; +- 每个外部写入前必须先在 CR status 持久化足够的操作意图,写入后必须回读并推进 + `status.phase`; +- 暂时性网络、锁和依赖错误必须重试; +- 输入错误、资源冲突和禁止操作不得忙循环重试,只在 generation 或依赖状态变化后 + 重试; +- 未知外部资源不得被修改、接管或删除; +- 用户从 `spec.extensions` 移除 extension 时不得执行卸载,必须报告该字段在 v1alpha1 + 中只允许追加; +- controller 重启不得影响已经签发的应用密码; +- `status` 丢失后必须可以从 registry、PostgreSQL、OpenBao 和 Kubernetes/ESO 重建。 + +## 10. Condition 合同 + +两个资源都必须提供唯一的 `Ready` Condition。可以增加辅助 Condition,但调用方只需 +依赖 `Ready`。 + +| 状态 | 含义 | +| --- | --- | +| `Ready=Unknown` | 正在首次观察或 reconcile,尚无结论 | +| `Ready=False` | 当前 generation 未达到合同要求 | +| `Ready=True` | 当前 generation 已回读验证成功 | + +Condition 必须带正确的 `observedGeneration`。资源自身的 +`status.observedGeneration` 只在当前 generation 完成一次有结论的 reconcile 后更新。 + +最低 Reason 集合: + +| Reason | 适用资源 | 含义 | +| --- | --- | --- | +| `Reconciling` | 两者 | 尚在处理 | +| `Ready` | 两者 | 当前 generation 已验证 | +| `InvalidSpec` | 两者 | 输入不满足规格 | +| `DependencyUnavailable` | 两者 | PostgreSQL 或 OpenBao 暂时不可用 | +| `AuthenticationFailed` | Instance | 管理凭据或 TLS 验证失败 | +| `InsufficientPrivileges` | Instance | 管理 role 缺少必要权限 | +| `InstanceNotReady` | Tenant | 引用的 Instance 未 Ready | +| `Conflict` | Tenant | 目标名称或 OpenBao 路径已被其他主体占用 | +| `ProvisioningFailed` | Tenant | 可重试的创建/验证失败 | +| `CredentialProjectionFailed` | Tenant | ESO 或目标 Secret 未达到期望状态 | + +Condition message 必须适合人类排障,但禁止包含连接串密码、Token 或完整 Secret 数据。 + +## 11. 删除与保留 + +### 11.1 Retain + +`Retain` 是默认策略: + +- 删除 Tenant CR 不得删除 database、role、extension 或 OpenBao 记录; +- controller 不得因外部依赖不可用而永久阻止 Retain CR 删除; +- 保留资源必须继续携带原 Tenant UID 和 namespace/name 的所有权记录,但在 CR 删除后 + 明确处于 unmanaged 状态; +- 重新创建同名 Tenant 会产生新的 UID,必须因已有资源不属于新 UID 而报告 Conflict; +- v1alpha1 不提供重新关联、import 或 adoption;恢复管理必须使用第 12 节的迁移流程, + 或等待后续版本定义显式纳管协议。 + +### 11.2 Delete + +用户在创建 Tenant 时显式设置 `deletionPolicy: Delete`,表示删除 CR 时授权永久清理 +该 Tenant 的外部资源。controller 必须使用 finalizer,并按以下顺序处理: + +1. 再次验证 database、role 和 OpenBao 记录都属于当前 Tenant UID; +2. 删除 ExternalSecret,并确认目标 Kubernetes Secret 已删除; +3. 禁止该 login role 建立新连接; +4. 终止该 database 的现有连接; +5. 删除 database,database 内 extension 随之删除; +6. 删除 login role; +7. 删除 OpenBao KV 记录及其可恢复版本; +8. 回读确认外部资源均不存在; +9. 删除 controller registry 记录; +10. 移除 finalizer,允许 Kubernetes 删除 CR。 + +任一步失败都必须保持 finalizer 并从安全检查开始重试。controller 禁止使用 +`CASCADE` 删除无法证明属于该 Tenant 的依赖对象。若 Instance 或 OpenBao 永久丢失, +管理员可以在核实外部状态后手工移除 finalizer;该逃生操作必须在运维 runbook 中明确 +标记为可能遗留资源。 + +v1alpha1 不自动检查备份,也不承诺恢复被 `Delete` 删除的数据。显式选择 Delete 的 +用户承担数据销毁语义;默认 Retain 用于避免普通误删。 + +## 12. 现有环境迁移 + +v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回滚的迁移 runbook。对每 +个现有应用租户,推荐的停机迁移顺序是: + +1. 盘点 database、role、owner、grant 和 extension,并完成可恢复备份; +2. 创建逻辑备份,必须使用可映射到新 owner 的格式,避免恢复旧 role ownership; +3. 停止应用写入并确认没有活动写事务; +4. 完成最终逻辑备份; +5. 将旧 database 和 role 重命名为带迁移时间戳的保留名称,释放最终名称; +6. 创建 `PostgreSQLTenant`,由 controller 创建最终 database、role 和 OpenBao 凭据; +7. 等待 Tenant Ready; +8. 以新 owner 恢复逻辑备份,并验证 row count、schema、extension 和应用权限; +9. 让 ESO 投射新凭据,重启或重新部署应用; +10. 验证应用读写后结束维护窗口; +11. 保留旧 database、role 和备份直到回滚窗口结束,再由管理员手工清理。 + +回滚时停止新应用写入、恢复原名称或连接配置,并重新使用旧凭据。迁移工具不得把旧 +密码、管理凭据或 dump 文件提交到 Git。真实命令、锁定方式和各现有应用验证项见 +[`migration.md`](migration.md),并必须在实现首个可用版本前通过临时 PostgreSQL 实例 +演练。 + +## 13. 安全要求 + +1. 所有 PostgreSQL 与 OpenBao 网络访问必须支持超时和 context cancellation。 +2. homelab 部署必须通过 Deployment 挂载的共享 CA bundle 验证 TLS server identity; + 该 bundle 的信任根来自 OpenBao PKI,但不得包含 CA 私钥。Instance 默认使用 + `verify-full`,其 host 必须与服务器证书名称匹配。开发环境可以显式使用 `disable` + 明文连接。 +3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL + superuser;若 extension 安装需要额外权限,必须单独记录例外。 +4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。 + 管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身 + namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。 +5. namespace 用户不得修改 cluster-scoped Instance。 +6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。 +7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。 +8. 错误包装、结构化日志和 tracing 必须经过 Secret 泄露测试。 + +详细威胁模型和部署 policy 见 [`security.md`](security.md)。 + +## 14. 可观测性要求 + +v1alpha1 至少必须提供: + +- Kubernetes Events:开始 provisioning、成功及需要人工处理的失败; +- 结构化日志:resource namespace/name、Instance、generation、阶段和错误类别; +- controller-runtime 默认 reconcile metrics; +- 不包含 database、role、OpenBao path 等无界用户输入的低基数失败分类 metric。 + +日志和 metrics 的存在不能代替 Condition;Condition 是 API 使用者判断状态的主要方式。 + +## 15. 验收标准 + +实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖: + +1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。 +2. PostgreSQL 管理能力不可用时 Instance Ready=False,恢复后自动变为 Ready;已有 + 管理凭据可正常使用时,Bao/ESO 故障不单独影响 Instance Ready。首次装配缺少有效 + 管理 Secret 时不能 Ready;Tenant 的 Bao 操作失败按其自身依赖故障报告。 +3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao + 记录。 +4. 应用凭据可以实际连接且不能创建其他 database/role。 +5. 相同 generation 重复 reconcile 不改变密码、不重复创建资源。 +6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。 +7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致 + Conflict,且不修改已有资源。 +8. 目标实例实际不支持的 extension 在供应外部写入前被拒绝;扩展列表查询失败时 + 按依赖故障处理,不报告为不支持。安装结果仍须回读验证。 +9. status 被清空后可以从两个外部事实来源重建。 +10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告 + Conflict。 +11. 日志、Event、Condition、metric 和 CR 中不存在生成的密码或管理凭据。 +12. 两个 namespace 对同一 Instance 申请相同名称时,只有第一个成功,第二个报告 + Conflict。 +13. 删除 Delete Tenant 时,任一步骤失败都可重试,且最终删除 database、login role、 + OpenBao KV 历史和 finalizer。 +14. 使用迁移 runbook 可以把一个现有 database 转移到新建的受管 database,并在回滚 + 窗口内恢复旧服务。 +15. Tenant 只有在 ExternalSecret Ready、目标 Secret 存在且应用凭据实际可登录后才 + Ready。 +16. Tenant status 同时提供 Kubernetes Secret reference 和不含认证信息的 OpenBao API + URL。 +17. DNS 不可用时,使用输出的 `hostaddr` 可以连接 PostgreSQL;server 证书同时覆盖 + `host` 的 DNS SAN 和 `hostaddr` 的 IP SAN,两种连接目标均可通过 `verify-full`。 +18. 两个 CR 的 `status.phase` 都能反映当前协调步骤;清空 status 后可以从外部事实重建, + 且伪造或过期 phase 不会使 controller 跳过验证或外部操作。 + +单元测试验证纯决策逻辑,adapter 集成测试使用 Docker PostgreSQL/OpenBao,controller +集成测试使用 envtest,完整网络路径使用 Kind E2E。 + +## 16. 已确认决策 + +- v1alpha1 使用一个同时作为 database owner 的 login role,不创建额外 NOLOGIN owner。 +- v1alpha1 不接管任意现有资源,但必须提供并演练 dump/restore 迁移路径。 +- v1alpha1 同时实现默认 `Retain` 和显式 `Delete`;Delete 必须有 finalizer、所有权验证 + 和完整清理路径。 +- OpenBao KV v2 mount 和 base path 是 controller 部署配置,mount 默认 `kv`,base path + 由 `--openbao-tenant-base-path` 配置并默认 `postgresql-tenants`;Tenant 不能选择 mount + 或任意远端 path,controller 根据 namespace/name 推导记录路径。 +- 租户 KV 记录固定写入 `username/password/database/host/hostaddr/port/sslmode` 七个 + 原子字段; + controller 不生成连接 URI,应用负责映射和拼装自身配置。 +- PostgreSQL TLS 使用 controller Deployment 挂载的共享 CA bundle。OpenBao PKI 是 + CA 权威并继续签发、续期 PostgreSQL server 证书;controller 只消费公开 trust + bundle,不接触 CA 私钥。bundle 可以由 ConfigMap 或现有证书同步机制投射,不允许 + Tenant 或 Instance 选择其他 CA;开发环境可以显式使用 `sslMode: disable`。 +- 每个 PostgreSQLInstance 在其管理 database 中维护 controller 专用 registry schema。 + registry 是受管资源所有权、安装身份和 Retain 后 unmanaged 标记的权威记录;两个 + CR 的 `status.phase` 是 controller 状态机的权威 checkpoint,Instance status 不聚合 + Tenant 清单。 +- PostgreSQL database 和 role identifier 必须匹配 `^[a-z][a-z0-9_]{0,62}$`,不支持 + 需要双引号的大小写或特殊字符名称。 +- External Secrets Operator 是 v1alpha1 的运行依赖。controller 管理同 namespace + ExternalSecret,但不直接写明文 Secret;Tenant status 同时输出目标 Secret reference + 和供非 Kubernetes 消费者使用的 OpenBao API URL。 +- PostgreSQLInstance 同时声明 DNS `host` 和 IP `hostaddr`;PostgreSQL server 证书必须 + 同时包含对应 DNS SAN 和 IP SAN,消费者自行选择连接目标。 + +## 17. 批准状态 + +2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员 +allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。 + +2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret, +管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。 +此项是已批准行为,现有 API types 与实现尚待后续修改。 + +具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和 +实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威 +checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。 + +## 18. 与当前脚手架的已知差异 + +当前 API skeleton 至少需要以下调整: + +- 删除 Tenant 自选 OpenBao path 的能力,改由部署级 mount、base path 和 Tenant + identity 推导,并修正当前包含 `kv/` 前缀的示例; +- 增加 controller 部署级 OpenBao KV mount 和 TLS 配置; +- 增加部署级 OpenBao consumer address、ClusterSecretStore 和 KV base path 配置; +- 删除独立 `ownerRole` 字段,使 login role 成为 database owner; +- 为 Instance 增加 `hostaddr`,为 Tenant 增加目标 Secret 配置及 Secret/Bao URL 输出 + status; +- 按已确认的 identifier 合同收紧校验; +- 增加 PostgreSQL controller registry,记录基于 UID 的所有权、安装身份和保留状态; +- 修正凭据 type 中遗留的 rotation 注释; +- 使 Condition、不可变字段和 extension 追加语义具备 API 校验或明确的 reconcile + 结果。 + +这些是规格批准后的实现工作,不属于本规格本身。 diff --git a/internal/database/domain/instance/credential_reference.go b/internal/database/domain/instance/credential_reference.go new file mode 100644 index 0000000..00351a3 --- /dev/null +++ b/internal/database/domain/instance/credential_reference.go @@ -0,0 +1,67 @@ +/* +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 instance + +import ( + "errors" + "regexp" +) + +// CredentialReferenceValues contains effective field mappings, not secret data. +// The application supplies defaults and fixes the namespace to the controller's. +// Namespace and provider-specific paths are deliberately not selectable here. +type CredentialReferenceValues struct { + Name string + UsernameKey string + PasswordKey string +} + +// CredentialReference is an immutable reference to a management Secret. +// Its zero value is invalid; aggregate construction must Validate incoming values. +type CredentialReference struct { + values CredentialReferenceValues +} + +// Instance and Secret names share the DNS subdomain syntax and 253-character limit. +var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) { + reference := CredentialReference{values: values} + if err := reference.Validate(); err != nil { + return CredentialReference{}, err + } + return reference, nil +} + +// Values returns a copy of the reference, never secret contents. +func (r CredentialReference) Values() CredentialReferenceValues { return r.values } + +// Validate enforces reference invariants without accessing Kubernetes or OpenBao. +// Checking that the referenced Secret contains nonempty credentials is an application +// responsibility. Errors omit input values and no implicit defaults are applied. +func (r CredentialReference) Validate() error { + if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) { + return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters") + } + if r.values.UsernameKey == "" { + return errors.New("management Secret username field is required") + } + if r.values.PasswordKey == "" { + return errors.New("management Secret password field is required") + } + return nil +} diff --git a/internal/database/domain/instance/credential_reference_test.go b/internal/database/domain/instance/credential_reference_test.go new file mode 100644 index 0000000..190138d --- /dev/null +++ b/internal/database/domain/instance/credential_reference_test.go @@ -0,0 +1,119 @@ +/* +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 instance_test + +import ( + "strings" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +func validCredentialReference() instance.CredentialReferenceValues { + return instance.CredentialReferenceValues{ + Name: "shared-postgresql-admin", UsernameKey: "username", PasswordKey: "password", + } +} + +// Acceptance: docs/database/domain-instance.md §2. References carry names, never credentials or IO. +func TestCredentialReferenceRejectsInvalidValues(t *testing.T) { + cases := []struct { + name string + change func(*instance.CredentialReferenceValues) + }{ + {"empty name", func(v *instance.CredentialReferenceValues) { v.Name = "" }}, + {"uppercase", func(v *instance.CredentialReferenceValues) { v.Name = "Admin" }}, + {"underscore", func(v *instance.CredentialReferenceValues) { v.Name = "pg_admin" }}, + {"leading hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "-admin" }}, + {"trailing hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "admin-" }}, + {"empty label", func(v *instance.CredentialReferenceValues) { v.Name = "pg..admin" }}, + {"trailing dot", func(v *instance.CredentialReferenceValues) { v.Name = "pg." }}, + {"namespace or path", func(v *instance.CredentialReferenceValues) { v.Name = "system/admin" }}, + {"whitespace", func(v *instance.CredentialReferenceValues) { v.Name = " admin" }}, + {"too long", func(v *instance.CredentialReferenceValues) { v.Name = strings.Repeat("a", 254) }}, + {"empty username key", func(v *instance.CredentialReferenceValues) { v.UsernameKey = "" }}, + {"empty password key", func(v *instance.CredentialReferenceValues) { v.PasswordKey = "" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + values := validCredentialReference() + tc.change(&values) + reference, err := instance.NewCredentialReference(values) + if err == nil { + t.Fatal("invalid credential reference accepted") + } + if reference != (instance.CredentialReference{}) { + t.Fatal("constructor returned a partial reference on failure") + } + }) + } +} + +func TestCredentialReferencePreservesExplicitValues(t *testing.T) { + for _, name := range []string{"a", "1", "pg.admin-1", strings.Repeat("a", 253)} { + values := validCredentialReference() + values.Name = name + values.UsernameKey = "PG_USER" + values.PasswordKey = "pg.password" + reference, err := instance.NewCredentialReference(values) + if err != nil { + t.Fatal(err) + } + if reference.Values() != values { + t.Fatal("constructor changed the explicit field mapping") + } + if err := reference.Validate(); err != nil { + t.Fatal(err) + } + } +} + +func TestCredentialReferenceIsAnImmutableComparableValue(t *testing.T) { + values := validCredentialReference() + reference, err := instance.NewCredentialReference(values) + if err != nil { + t.Fatal(err) + } + same, err := instance.NewCredentialReference(values) + if err != nil { + t.Fatal(err) + } + if reference != same { + t.Fatal("identical references must compare equal") + } + values.Name = "different" + snapshot := reference.Values() + snapshot.PasswordKey = "different-key" + if reference.Values() != validCredentialReference() { + t.Fatal("caller mutated reference through a copy") + } + if err := (instance.CredentialReference{}).Validate(); err == nil { + t.Fatal("zero reference must be invalid") + } +} + +func TestCredentialReferenceErrorOmitsInput(t *testing.T) { + values := validCredentialReference() + values.Name = "canary-sensitive/input" + _, err := instance.NewCredentialReference(values) + if err == nil { + t.Fatal("invalid reference accepted") + } + if strings.Contains(err.Error(), "canary") { + t.Fatal("error included input") + } +} diff --git a/internal/database/domain/instance/endpoint.go b/internal/database/domain/instance/endpoint.go new file mode 100644 index 0000000..15648e2 --- /dev/null +++ b/internal/database/domain/instance/endpoint.go @@ -0,0 +1,89 @@ +/* +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 instance contains the pure domain model of a registered PostgreSQL instance. +// It does not depend on Kubernetes types, database drivers or credential providers. +package instance + +import ( + "errors" + "net/netip" + "regexp" +) + +// TLSMode is an explicit transport policy, not a driver-specific default. +type TLSMode string + +const ( + TLSDisable TLSMode = "disable" + TLSRequire TLSMode = "require" + TLSVerifyCA TLSMode = "verify-ca" + TLSVerifyFull TLSMode = "verify-full" +) + +// EndpointValues carries explicit, effective values across the application boundary. +// Defaults are supplied by the API/application mapping, never silently by the domain. +type EndpointValues struct { + Host string + HostAddr string + Port int + ManagementDatabase string + TLSMode TLSMode +} + +// Endpoint is an immutable connection target. Equality compares its declared values, +// not physical server identity. Its zero value is invalid; aggregate construction +// must Validate incoming endpoints, even if callers bypass NewEndpoint. +type Endpoint struct { + values EndpointValues +} + +var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + +func NewEndpoint(values EndpointValues) (Endpoint, error) { + endpoint := Endpoint{values: values} + if err := endpoint.Validate(); err != nil { + return Endpoint{}, err + } + return endpoint, nil +} + +// Values returns a copy, without exposing mutable state. +func (e Endpoint) Values() EndpointValues { return e.values } + +// Validate checks local invariants only; it does not resolve DNS or perform IO. +// Errors intentionally omit input values. +func (e Endpoint) Validate() error { + if e.values.Host == "" { + return errors.New("endpoint host is required") + } + address, err := netip.ParseAddr(e.values.HostAddr) + if err != nil || address.Zone() != "" { + return errors.New("endpoint host address must be an IPv4 or IPv6 address") + } + if e.values.Port < 1 || e.values.Port > 65535 { + return errors.New("endpoint port must be between 1 and 65535") + } + if !identifier.MatchString(e.values.ManagementDatabase) { + return errors.New("endpoint management database must be a valid PostgreSQL identifier") + } + switch e.values.TLSMode { + case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull: + return nil + default: + return errors.New("endpoint TLS mode must be explicitly supported") + } +} diff --git a/internal/database/domain/instance/endpoint_test.go b/internal/database/domain/instance/endpoint_test.go new file mode 100644 index 0000000..854b4dd --- /dev/null +++ b/internal/database/domain/instance/endpoint_test.go @@ -0,0 +1,118 @@ +/* +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 instance_test + +import ( + "strings" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +func validEndpoint() instance.EndpointValues { + return instance.EndpointValues{ + Host: "postgres.home.arpa", HostAddr: "192.0.2.10", Port: 5432, + ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull, + } +} + +// Acceptance: docs/database/domain-instance.md §2, explicit values and no implicit TLS downgrade. +func TestEndpointRejectsInvalidValues(t *testing.T) { + cases := []struct { + name string + change func(*instance.EndpointValues) + }{ + {"empty host", func(v *instance.EndpointValues) { v.Host = "" }}, + {"missing address", func(v *instance.EndpointValues) { v.HostAddr = "" }}, + {"DNS instead of IP", func(v *instance.EndpointValues) { v.HostAddr = "postgres.home.arpa" }}, + {"invalid IP", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.999" }}, + {"address with port", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.10:5432" }}, + {"scoped address", func(v *instance.EndpointValues) { v.HostAddr = "fe80::1%eth0" }}, + {"zero port", func(v *instance.EndpointValues) { v.Port = 0 }}, + {"negative port", func(v *instance.EndpointValues) { v.Port = -1 }}, + {"large port", func(v *instance.EndpointValues) { v.Port = 65536 }}, + {"empty database", func(v *instance.EndpointValues) { v.ManagementDatabase = "" }}, + {"uppercase database", func(v *instance.EndpointValues) { v.ManagementDatabase = "Postgres" }}, + {"leading digit", func(v *instance.EndpointValues) { v.ManagementDatabase = "1postgres" }}, + {"punctuation", func(v *instance.EndpointValues) { v.ManagementDatabase = "post-gres" }}, + {"NUL", func(v *instance.EndpointValues) { v.ManagementDatabase = "post\x00gres" }}, + {"long identifier", func(v *instance.EndpointValues) { v.ManagementDatabase = strings.Repeat("a", 64) }}, + {"missing TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "" }}, + {"unsupported TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "prefer" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + values := validEndpoint() + tc.change(&values) + endpoint, err := instance.NewEndpoint(values) + if err == nil { + t.Fatal("invalid endpoint accepted") + } + if endpoint != (instance.Endpoint{}) { + t.Fatal("constructor returned a partial endpoint on failure") + } + }) + } +} + +func TestEndpointPreservesValidValues(t *testing.T) { + for _, mode := range []instance.TLSMode{ + instance.TLSDisable, instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull, + } { + for _, address := range []string{"192.0.2.10", "2001:db8::10"} { + for _, port := range []int{1, 65535} { + values := validEndpoint() + values.TLSMode, values.HostAddr, values.Port = mode, address, port + values.ManagementDatabase = "a" + strings.Repeat("_", 62) + endpoint, err := instance.NewEndpoint(values) + if err != nil { + t.Fatal(err) + } + if endpoint.Values() != values { + t.Fatal("constructor changed explicit values") + } + if err := endpoint.Validate(); err != nil { + t.Fatal(err) + } + } + } + } +} + +func TestEndpointIsAnImmutableComparableValue(t *testing.T) { + values := validEndpoint() + endpoint, err := instance.NewEndpoint(values) + if err != nil { + t.Fatal(err) + } + same, err := instance.NewEndpoint(values) + if err != nil { + t.Fatal(err) + } + if endpoint != same { + t.Fatal("identical endpoint values must compare equal") + } + values.Host = "changed.example" + snapshot := endpoint.Values() + snapshot.Host = values.Host + if endpoint.Values().Host == snapshot.Host { + t.Fatal("caller mutated endpoint through a copy") + } + if err := (instance.Endpoint{}).Validate(); err == nil { + t.Fatal("zero endpoint must not be valid") + } +} diff --git a/internal/database/domain/instance/extensions.go b/internal/database/domain/instance/extensions.go new file mode 100644 index 0000000..7428130 --- /dev/null +++ b/internal/database/domain/instance/extensions.go @@ -0,0 +1,84 @@ +/* +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 instance + +import "slices" + +// ExtensionSet is an immutable set of exact names. Zero represents the empty set. +// It does not impose identifier syntax or claim that a server supports any name. +type ExtensionSet struct { + names []string +} + +func NewExtensionSet(names []string) ExtensionSet { + copied := slices.Clone(names) + slices.Sort(copied) + return ExtensionSet{names: slices.Compact(copied)} +} + +// Names returns a sorted, deduplicated copy. +func (s ExtensionSet) Names() []string { return slices.Clone(s.names) } + +type ExtensionDecision string + +const ( + ExtensionsAccepted ExtensionDecision = "Accepted" + ExtensionsUnsupported ExtensionDecision = "ExtensionsUnsupported" + ExtensionSupportUnobserved ExtensionDecision = "ExtensionSupportUnobserved" +) + +// ExtensionCheck reports support only, not readiness or permission to install. +// Unsupported is a detached, sorted list and is populated only for known support. +type ExtensionCheck struct { + Decision ExtensionDecision + Unsupported []string +} + +// ExtensionSupport is the extension-list component of an Instance observation. +// Zero means unobserved, not an observed empty list. Target/revision binding and +// invalidation belong to the containing Instance observation, not this set value. +type ExtensionSupport struct { + observed bool + available ExtensionSet +} + +// ObserveExtensionSupport records a successfully read list, including an empty one. +// A failed query must not call this constructor with an empty list: the application +// must propagate the dependency failure and leave support unobserved. +func ObserveExtensionSupport(available []string) ExtensionSupport { + return ExtensionSupport{observed: true, available: NewExtensionSet(available)} +} + +// Check performs no IO and cannot install or remove extensions. +func (s ExtensionSupport) Check(requested ExtensionSet) ExtensionCheck { + if len(requested.names) == 0 { + return ExtensionCheck{Decision: ExtensionsAccepted} + } + if !s.observed { + return ExtensionCheck{Decision: ExtensionSupportUnobserved} + } + var unsupported []string + for _, name := range requested.names { + if _, found := slices.BinarySearch(s.available.names, name); !found { + unsupported = append(unsupported, name) + } + } + if len(unsupported) != 0 { + return ExtensionCheck{Decision: ExtensionsUnsupported, Unsupported: unsupported} + } + return ExtensionCheck{Decision: ExtensionsAccepted} +} diff --git a/internal/database/domain/instance/extensions_test.go b/internal/database/domain/instance/extensions_test.go new file mode 100644 index 0000000..2d4694c --- /dev/null +++ b/internal/database/domain/instance/extensions_test.go @@ -0,0 +1,107 @@ +/* +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 instance_test + +import ( + "slices" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +const ( + testUUID = "uuid-ossp" + testTrigram = "pg_trgm" + testVector = "vector" + testChanged = "changed" +) + +// Acceptance: docs/database/domain-instance.md, extension support is based on observations, +// not a name regexp or an administrator allowlist. +func TestExtensionSupportDecisions(t *testing.T) { + available := instance.ObserveExtensionSupport([]string{testTrigram, testUUID}) + cases := []struct { + name string + support instance.ExtensionSupport + requested []string + want instance.ExtensionDecision + unsupported []string + }{ + {"unobserved", instance.ExtensionSupport{}, []string{testTrigram}, instance.ExtensionSupportUnobserved, nil}, + {"observed empty", instance.ObserveExtensionSupport(nil), []string{testTrigram}, instance.ExtensionsUnsupported, []string{testTrigram}}, + {"empty request", instance.ExtensionSupport{}, nil, instance.ExtensionsAccepted, nil}, + {"supported", available, []string{testUUID, testTrigram, testTrigram}, instance.ExtensionsAccepted, nil}, + {"unsupported", available, []string{testVector, "hstore", testVector, testTrigram}, + instance.ExtensionsUnsupported, []string{"hstore", testVector}}, + {"exact names", available, []string{"PG_TRGM"}, instance.ExtensionsUnsupported, []string{"PG_TRGM"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := tc.support.Check(instance.NewExtensionSet(tc.requested)) + if result.Decision != tc.want || !slices.Equal(result.Unsupported, tc.unsupported) { + t.Fatalf("Check() = %v, want %v / %v", result, tc.want, tc.unsupported) + } + }) + } +} + +func TestExtensionSetCopiesAndCanonicalizesNames(t *testing.T) { + input := []string{testUUID, testTrigram, testUUID} + set := instance.NewExtensionSet(input) + input[0] = testChanged + names := set.Names() + want := []string{testTrigram, testUUID} + if !slices.Equal(names, want) { + t.Fatalf("Names() = %v, want %v", names, want) + } + names[0] = testChanged + if !slices.Equal(set.Names(), want) { + t.Fatal("returned slice mutated set") + } + if len((instance.ExtensionSet{}).Names()) != 0 { + t.Fatal("zero set must be empty") + } + // Names are preserved exactly; actual server support, not a local regexp, is decisive. + unusual := []string{"Vendor.Extension", testUUID} + if result := instance.ObserveExtensionSupport(unusual).Check(instance.NewExtensionSet(unusual)); result.Decision != instance.ExtensionsAccepted { + t.Fatal("imposed a local name restriction") + } +} + +func TestExtensionSupportCopiesObservationAndResults(t *testing.T) { + input := []string{testTrigram} + support := instance.ObserveExtensionSupport(input) + input[0] = testVector + requested := instance.NewExtensionSet([]string{testTrigram, testVector}) + result := support.Check(requested) + if !slices.Equal(result.Unsupported, []string{testVector}) { + t.Fatal("input mutation changed observation") + } + result.Unsupported[0] = testChanged + again := support.Check(requested) + if !slices.Equal(again.Unsupported, []string{testVector}) { + t.Fatal("result mutation changed subsequent decision") + } + // Replacing an observation does not mutate the old value or produce uninstall actions. + empty := instance.ObserveExtensionSupport(nil) + if empty.Check(requested).Decision != instance.ExtensionsUnsupported { + t.Fatal("empty observation ignored") + } + if support.Check(instance.NewExtensionSet([]string{testTrigram})).Decision != instance.ExtensionsAccepted { + t.Fatal("new observation mutated old value") + } +} diff --git a/internal/database/domain/instance/identity.go b/internal/database/domain/instance/identity.go new file mode 100644 index 0000000..c09cb79 --- /dev/null +++ b/internal/database/domain/instance/identity.go @@ -0,0 +1,73 @@ +/* +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 instance + +import "errors" + +// Identity identifies one registration, not a physical PostgreSQL server. +// UID is opaque; a recreated resource with the same name has a different identity. +// Its zero value is invalid and must be rejected when constructing an aggregate. +type Identity struct { + uid string + name string +} + +func NewIdentity(uid, name string) (Identity, error) { + identity := Identity{uid: uid, name: name} + if err := identity.Validate(); err != nil { + return Identity{}, err + } + return identity, nil +} + +func (i Identity) UID() string { return i.uid } +func (i Identity) Name() string { return i.name } + +// Validate checks registration values without looking up any external identity. +func (i Identity) Validate() error { + if i.uid == "" { + return errors.New("instance UID is required") + } + if len(i.name) > 253 || !dnsSubdomainName.MatchString(i.name) { + return errors.New("instance name must be a valid DNS subdomain of at most 253 characters") + } + return nil +} + +// Revision is a positive configuration generation, separate from Identity. +// Zero is invalid for desired configuration; an unobserved status generation of +// zero must be represented separately when the aggregate is implemented. +type Revision struct { + value int64 +} + +func NewRevision(value int64) (Revision, error) { + revision := Revision{value: value} + if err := revision.Validate(); err != nil { + return Revision{}, err + } + return revision, nil +} + +func (r Revision) Value() int64 { return r.value } + +func (r Revision) Validate() error { + if r.value <= 0 { + return errors.New("instance revision must be positive") + } + return nil +} diff --git a/internal/database/domain/instance/identity_test.go b/internal/database/domain/instance/identity_test.go new file mode 100644 index 0000000..031292e --- /dev/null +++ b/internal/database/domain/instance/identity_test.go @@ -0,0 +1,102 @@ +/* +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 instance_test + +import ( + "math" + "strings" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +// Acceptance: docs/database/domain-instance.md §2: registration identity is UID + name, +// independent of the configuration revision or physical PostgreSQL server. +func TestIdentityDistinguishesRecreatedInstances(t *testing.T) { + first, err := instance.NewIdentity("opaque-uid-1", "shared") + if err != nil { + t.Fatal(err) + } + same, err := instance.NewIdentity("opaque-uid-1", "shared") + if err != nil { + t.Fatal(err) + } + recreated, err := instance.NewIdentity("opaque-uid-2", "shared") + if err != nil { + t.Fatal(err) + } + if first != same || first == recreated { + t.Fatal("identity must distinguish same-name registrations by UID") + } + if first.UID() != "opaque-uid-1" || first.Name() != "shared" { + t.Fatal("identity changed declared values") + } + if err := first.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestIdentityValidation(t *testing.T) { + for _, name := range []string{"", "Shared", "shared_name", "ns/shared", "-shared", "pg..shared", strings.Repeat("a", 254)} { + identity, err := instance.NewIdentity("uid", name) + if err == nil || identity != (instance.Identity{}) { + t.Fatal("invalid name accepted or partial identity returned") + } + } + if _, err := instance.NewIdentity("", "shared"); err == nil { + t.Fatal("empty UID accepted") + } + if err := (instance.Identity{}).Validate(); err == nil { + t.Fatal("zero identity accepted") + } + for _, name := range []string{"a", "1", "pg.shared-1", strings.Repeat("a", 253)} { + if _, err := instance.NewIdentity("opaque-not-a-uuid", name); err != nil { + t.Fatal(err) + } + } +} + +func TestRevisionRequiresPositiveValue(t *testing.T) { + for _, value := range []int64{math.MinInt64, -1, 0} { + revision, err := instance.NewRevision(value) + if err == nil || revision != (instance.Revision{}) { + t.Fatal("invalid revision accepted or partial value returned") + } + } + for _, value := range []int64{1, 2, math.MaxInt64} { + revision, err := instance.NewRevision(value) + if err != nil { + t.Fatal(err) + } + if revision.Value() != value { + t.Fatal("revision changed declared value") + } + if err := revision.Validate(); err != nil { + t.Fatal(err) + } + same, err := instance.NewRevision(value) + if err != nil { + t.Fatal(err) + } + if revision != same { + t.Fatal("identical revisions must compare equal") + } + } + if err := (instance.Revision{}).Validate(); err == nil { + t.Fatal("zero revision accepted") + } +} diff --git a/internal/database/domain/instance/instance.go b/internal/database/domain/instance/instance.go new file mode 100644 index 0000000..3eff6dd --- /dev/null +++ b/internal/database/domain/instance/instance.go @@ -0,0 +1,101 @@ +/* +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 instance + +import "errors" + +// Phase is a workflow checkpoint, never evidence of external resource state. +type Phase string + +const ( + PhasePending Phase = "Pending" + PhaseValidating Phase = "Validating" + PhaseInitializingRegistry Phase = "InitializingRegistry" + PhaseReady Phase = "Ready" + PhaseDeleting Phase = "Deleting" +) + +type Readiness string + +const ( + Unknown Readiness = "Unknown" + Ready Readiness = "Ready" + NotReady Readiness = "NotReady" +) + +// Snapshot contains persisted observations only, without credentials or live evidence. +// Failure detail mapping will be added with capability assessment, not intent transitions. +type Snapshot struct { + Phase Phase + ObservedRevision int64 + Readiness Readiness + ReportedVersion string +} + +// Instance protects registration state and pure lifecycle transitions. +// Reconstitution does not establish live capability evidence, even for a Ready snapshot. +// This initial slice deliberately exposes no operation that authorizes provisioning. +type Instance struct { + target ObservationTarget + snapshot Snapshot + deleting bool +} + +func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) { + if err := target.Validate(); err != nil { + return nil, err + } + switch snapshot.Phase { + case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting: + default: + snapshot.Phase = PhasePending + snapshot.Readiness = Unknown + } + return &Instance{target: target, snapshot: snapshot, deleting: deleting}, nil +} + +func (i *Instance) Target() ObservationTarget { return i.target } + +// Snapshot returns a detached value. Persisting it remains the application's job. +func (i *Instance) Snapshot() Snapshot { return i.snapshot } + +// BeginValidation records intent only; it does not claim a concluded observation. +func (i *Instance) BeginValidation() error { + if err := i.target.Validate(); err != nil { + return err + } + if i.deleting { + return errors.New("cannot begin validation after deletion was requested") + } + i.snapshot.Phase = PhaseValidating + i.snapshot.Readiness = Unknown + return nil +} + +// BeginDeletion stops the lifecycle from accepting validation. It does not delete +// resources, inspect Tenant references, close connections or modify finalizers. +func (i *Instance) BeginDeletion() error { + if err := i.target.Validate(); err != nil { + return err + } + if !i.deleting { + return errors.New("cannot begin deletion without a deletion request") + } + i.snapshot.Phase = PhaseDeleting + i.snapshot.Readiness = Unknown + return nil +} diff --git a/internal/database/domain/instance/instance_test.go b/internal/database/domain/instance/instance_test.go new file mode 100644 index 0000000..62226e2 --- /dev/null +++ b/internal/database/domain/instance/instance_test.go @@ -0,0 +1,134 @@ +/* +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 instance_test + +import ( + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +func lifecycleInstance(t *testing.T, snapshot instance.Snapshot, deleting bool) *instance.Instance { + t.Helper() + identity, revision, definition := targetParts(t) + target, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + value, err := instance.Reconstitute(target, snapshot, deleting) + if err != nil { + t.Fatal(err) + } + return value +} + +// 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.PhaseReady, instance.PhaseDeleting, + } { + snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"} + value := lifecycleInstance(t, snapshot, false) + if value.Snapshot() != snapshot { + t.Fatal("known checkpoint was not preserved") + } + // A snapshot is detached; it is not a setter on the aggregate. + copy := value.Snapshot() + copy.Phase = instance.PhasePending + copy.ReportedVersion = "changed" + if value.Snapshot() != snapshot { + t.Fatal("snapshot mutation changed aggregate") + } + } + for _, phase := range []instance.Phase{"", "unknown"} { + value := lifecycleInstance(t, instance.Snapshot{Phase: phase, Readiness: instance.Ready}, false) + if value.Snapshot().Phase != instance.PhasePending || value.Snapshot().Readiness != instance.Unknown { + t.Fatal("missing or unknown checkpoint did not restart conservatively") + } + } + if value, err := instance.Reconstitute(instance.ObservationTarget{}, instance.Snapshot{}, false); err == nil || value != nil { + t.Fatal("invalid target reconstructed an aggregate") + } +} + +func TestBeginValidationPreservesObservedRevision(t *testing.T) { + snapshot := instance.Snapshot{ + Phase: instance.PhaseReady, ObservedRevision: 0, Readiness: instance.Ready, ReportedVersion: "17", + } + value := lifecycleInstance(t, snapshot, false) + target := value.Target() + for range 2 { + if err := value.BeginValidation(); err != nil { + t.Fatal(err) + } + got := value.Snapshot() + if got.Phase != instance.PhaseValidating || got.Readiness != instance.Unknown || + got.ObservedRevision != snapshot.ObservedRevision || got.ReportedVersion != snapshot.ReportedVersion { + t.Fatal("recording validation intent claimed a completed observation or erased diagnostic version") + } + } + if value.Target() != target { + t.Fatal("lifecycle action mutated identity or configuration") + } +} + +func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) { + snapshot := instance.Snapshot{Phase: instance.PhaseReady, Readiness: instance.Ready, ObservedRevision: 1} + active := lifecycleInstance(t, snapshot, false) + if err := active.BeginDeletion(); err == nil { + t.Fatal("deletion without a request accepted") + } + if active.Snapshot() != snapshot { + t.Fatal("rejected deletion mutated state") + } + for _, phase := range []instance.Phase{ + instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry, + instance.PhaseReady, instance.PhaseDeleting, + } { + snapshot.Phase = phase + value := lifecycleInstance(t, snapshot, true) + if err := value.BeginValidation(); err == nil { + t.Fatal("validation accepted after deletion request") + } + if value.Snapshot() != snapshot { + t.Fatal("rejected validation mutated state") + } + for range 2 { + if err := value.BeginDeletion(); err != nil { + t.Fatal(err) + } + if got := value.Snapshot(); got.Phase != instance.PhaseDeleting || got.Readiness != instance.Unknown || + got.ObservedRevision != snapshot.ObservedRevision { + t.Fatal("incorrect deletion checkpoint") + } + } + } +} + +func TestZeroInstanceCannotTransition(t *testing.T) { + var value instance.Instance + if err := value.BeginValidation(); err == nil { + t.Fatal("zero instance started validation") + } + if err := value.BeginDeletion(); err == nil { + t.Fatal("zero instance started deletion") + } + if value.Snapshot() != (instance.Snapshot{}) { + t.Fatal("invalid transition changed zero instance") + } +} diff --git a/internal/database/domain/instance/observation_target.go b/internal/database/domain/instance/observation_target.go new file mode 100644 index 0000000..c77ac41 --- /dev/null +++ b/internal/database/domain/instance/observation_target.go @@ -0,0 +1,81 @@ +/* +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 instance + +// Definition is the immutable effective configuration of an Instance. +// Available extensions are observations, not part of the declared configuration. +type Definition struct { + endpoint Endpoint + adminCredential CredentialReference +} + +func NewDefinition(endpoint Endpoint, adminCredential CredentialReference) (Definition, error) { + definition := Definition{endpoint: endpoint, adminCredential: adminCredential} + if err := definition.Validate(); err != nil { + return Definition{}, err + } + return definition, nil +} + +func (d Definition) Endpoint() Endpoint { return d.endpoint } +func (d Definition) AdminCredential() CredentialReference { return d.adminCredential } + +// Validate rejects invalid zero-value components even when constructors were bypassed. +func (d Definition) Validate() error { + if err := d.endpoint.Validate(); err != nil { + return err + } + return d.adminCredential.Validate() +} + +// ObservationTarget binds facts to a registration and its declared configuration. +// It does not identify a physical PostgreSQL server or prove observation freshness. +// Secret content refresh and same-target observation freshness remain application +// responsibilities; no credentials or Secret contents are carried by this value. +type ObservationTarget struct { + identity Identity + revision Revision + definition Definition +} + +func NewObservationTarget(identity Identity, revision Revision, definition Definition) (ObservationTarget, error) { + target := ObservationTarget{identity: identity, revision: revision, definition: definition} + if err := target.Validate(); err != nil { + return ObservationTarget{}, err + } + return target, nil +} + +func (t ObservationTarget) Identity() Identity { return t.identity } +func (t ObservationTarget) Revision() Revision { return t.revision } +func (t ObservationTarget) Definition() Definition { return t.definition } + +func (t ObservationTarget) Validate() error { + if err := t.identity.Validate(); err != nil { + return err + } + if err := t.revision.Validate(); err != nil { + return err + } + return t.definition.Validate() +} + +// Matches rejects invalid targets before comparing values. Matching is necessary, +// but not sufficient, for the aggregate to accept a fresh capability observation. +func (t ObservationTarget) Matches(other ObservationTarget) bool { + return t.Validate() == nil && other.Validate() == nil && t == other +} diff --git a/internal/database/domain/instance/observation_target_test.go b/internal/database/domain/instance/observation_target_test.go new file mode 100644 index 0000000..985b41a --- /dev/null +++ b/internal/database/domain/instance/observation_target_test.go @@ -0,0 +1,165 @@ +/* +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 instance_test + +import ( + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +func targetParts(t *testing.T) (instance.Identity, instance.Revision, instance.Definition) { + t.Helper() + identity, err := instance.NewIdentity("uid-1", "shared") + if err != nil { + t.Fatal(err) + } + revision, err := instance.NewRevision(1) + if err != nil { + t.Fatal(err) + } + endpoint, err := instance.NewEndpoint(validEndpoint()) + if err != nil { + t.Fatal(err) + } + credential, err := instance.NewCredentialReference(validCredentialReference()) + if err != nil { + t.Fatal(err) + } + definition, err := instance.NewDefinition(endpoint, credential) + if err != nil { + t.Fatal(err) + } + return identity, revision, definition +} + +func TestDefinitionRejectsInvalidComponents(t *testing.T) { + _, _, definition := targetParts(t) + cases := []struct { + endpoint instance.Endpoint + credential instance.CredentialReference + }{ + {instance.Endpoint{}, definition.AdminCredential()}, + {definition.Endpoint(), instance.CredentialReference{}}, + } + for _, tc := range cases { + value, err := instance.NewDefinition(tc.endpoint, tc.credential) + if err == nil || value != (instance.Definition{}) { + t.Fatal("invalid component accepted or partial value returned") + } + } + if err := (instance.Definition{}).Validate(); err == nil { + t.Fatal("zero definition accepted") + } +} + +func TestObservationTargetRejectsInvalidComponents(t *testing.T) { + identity, revision, definition := targetParts(t) + cases := []struct { + identity instance.Identity + revision instance.Revision + definition instance.Definition + }{ + {instance.Identity{}, revision, definition}, + {identity, instance.Revision{}, definition}, + {identity, revision, instance.Definition{}}, + } + for _, tc := range cases { + value, err := instance.NewObservationTarget(tc.identity, tc.revision, tc.definition) + if err == nil || value != (instance.ObservationTarget{}) { + t.Fatal("invalid component accepted or partial target returned") + } + } + zero := instance.ObservationTarget{} + if err := zero.Validate(); err == nil { + t.Fatal("zero target accepted") + } + if zero.Matches(zero) { + t.Fatal("two invalid targets must not authorize observation reuse") + } +} + +// Acceptance: docs/database/domain-instance.md §2/§6, observations cannot cross target bindings. +func TestObservationTargetMatchesOnlySameBinding(t *testing.T) { + identity, revision, definition := targetParts(t) + original, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + same, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + if !original.Matches(same) || original.Identity() != identity || + original.Revision() != revision || original.Definition() != definition { + t.Fatal("target did not preserve its declared binding") + } + changedIdentity, err := instance.NewIdentity("uid-2", identity.Name()) + if err != nil { + t.Fatal(err) + } + changedRevision, err := instance.NewRevision(2) + if err != nil { + t.Fatal(err) + } + for _, parts := range []struct { + identity instance.Identity + revision instance.Revision + }{{changedIdentity, revision}, {identity, changedRevision}} { + changed, err := instance.NewObservationTarget(parts.identity, parts.revision, definition) + if err != nil { + t.Fatal(err) + } + if original.Matches(changed) || changed.Matches(original) { + t.Fatal("different registration or revision matched") + } + } + endpointValues := definition.Endpoint().Values() + endpointValues.Host = "other.example" + endpoint, err := instance.NewEndpoint(endpointValues) + if err != nil { + t.Fatal(err) + } + credentialValues := definition.AdminCredential().Values() + credentialValues.PasswordKey = "replacement" + credential, err := instance.NewCredentialReference(credentialValues) + if err != nil { + t.Fatal(err) + } + for _, components := range []struct { + endpoint instance.Endpoint + credential instance.CredentialReference + }{{endpoint, definition.AdminCredential()}, {definition.Endpoint(), credential}} { + changedDefinition, err := instance.NewDefinition(components.endpoint, components.credential) + if err != nil { + t.Fatal(err) + } + changed, err := instance.NewObservationTarget(identity, revision, changedDefinition) + if err != nil { + t.Fatal(err) + } + if original.Matches(changed) { + t.Fatal("changed definition matched even with the same revision") + } + } + if original.Matches(instance.ObservationTarget{}) { + t.Fatal("valid target matched zero target") + } + if !original.Matches(same) { + t.Fatal("constructing changed targets mutated the original") + } +} -- 2.54.0 From 5a7b38ad268fb5492511f571ad9b444fffbc3f07 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 20:33:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Instance=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E8=83=BD=E5=8A=9B=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/database/README.md | 5 +- internal/database/domain/instance/instance.go | 30 ++++- .../instance/instance_extensions_test.go | 105 ++++++++++++++++++ 3 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 internal/database/domain/instance/instance_extensions_test.go diff --git a/docs/database/README.md b/docs/database/README.md index c6a22cc..2855d14 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -20,8 +20,9 @@ Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只 `instance_extensions_test.go` 未进入本切片。 代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新; -领域行为未在本切片中扩展。后续 Ready/observation 行为应在 Ayatori 内先更新合同与测试,再继续 -实现,不能把旧运行链路接回该模型。 +首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新 +验证或删除时失效,且支持判定不授权 Tenant provisioning。其余 Ready/observation 行为仍应先更新 +合同与测试再实现,不能把旧运行链路接回该模型。 ## 边界 diff --git a/internal/database/domain/instance/instance.go b/internal/database/domain/instance/instance.go index 3eff6dd..ef5ff8b 100644 --- a/internal/database/domain/instance/instance.go +++ b/internal/database/domain/instance/instance.go @@ -50,9 +50,10 @@ type Snapshot struct { // Reconstitution does not establish live capability evidence, even for a Ready snapshot. // This initial slice deliberately exposes no operation that authorizes provisioning. type Instance struct { - target ObservationTarget - snapshot Snapshot - deleting bool + target ObservationTarget + snapshot Snapshot + deleting bool + extensions ExtensionSupport } func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) { @@ -83,6 +84,7 @@ func (i *Instance) BeginValidation() error { } i.snapshot.Phase = PhaseValidating i.snapshot.Readiness = Unknown + i.extensions = ExtensionSupport{} return nil } @@ -97,5 +99,27 @@ func (i *Instance) BeginDeletion() error { } i.snapshot.Phase = PhaseDeleting i.snapshot.Readiness = Unknown + i.extensions = ExtensionSupport{} return nil } + +// ObserveExtensions accepts facts only for this registration and configuration. +// Unobserved support clears a previous list after a failed read; the application +// reports the dependency failure separately. This does not establish readiness. +// Same-target freshness and Secret refresh are enforced by the application. +func (i *Instance) ObserveExtensions(target ObservationTarget, support ExtensionSupport) error { + if !i.target.Matches(target) { + return errors.New("extension observation target does not match instance") + } + if i.deleting { + return errors.New("cannot accept extension observations after deletion was requested") + } + i.extensions = support + return nil +} + +// CheckExtensions checks support only; Accepted is not authorization to provision. +// The aggregate does not perform IO, mutate its snapshot or uninstall extensions. +func (i *Instance) CheckExtensions(requested ExtensionSet) ExtensionCheck { + return i.extensions.Check(requested) +} diff --git a/internal/database/domain/instance/instance_extensions_test.go b/internal/database/domain/instance/instance_extensions_test.go new file mode 100644 index 0000000..92f012f --- /dev/null +++ b/internal/database/domain/instance/instance_extensions_test.go @@ -0,0 +1,105 @@ +/* +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 instance_test + +import ( + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +// Acceptance: docs/database/domain-instance.md §2/§3, extension observations are +// bound to the current registration and definition and never authorize provisioning. +func TestInstanceAcceptsExtensionObservationForCurrentTarget(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + target := value.Target() + snapshot := value.Snapshot() + + if err := value.ObserveExtensions(target, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionsAccepted { + t.Fatalf("CheckExtensions() = %v, want accepted", got) + } + if value.Snapshot() != snapshot { + t.Fatal("extension observation changed the persisted lifecycle snapshot") + } +} + +func TestInstanceRejectsExtensionObservationForDifferentTarget(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + identity, _, definition := targetParts(t) + revision, err := instance.NewRevision(value.Target().Revision().Value() + 1) + if err != nil { + t.Fatal(err) + } + different, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + + if err := value.ObserveExtensions(different, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + t.Fatal("observation for a different target was accepted") + } + if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("rejected observation changed support: %v", got) + } +} + +func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing.T) { + requested := instance.NewExtensionSet([]string{"pg_trgm"}) + + t.Run("validation", func(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, false) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if err := value.BeginValidation(); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("validation retained stale support: %v", got) + } + }) + + t.Run("deletion", func(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, true) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + t.Fatal("deleting instance accepted a new observation") + } + if err := value.BeginDeletion(); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("deletion retained extension support: %v", got) + } + }) +} + +func TestInstanceCanExplicitlyInvalidateExtensionObservation(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + requested := instance.NewExtensionSet([]string{"pg_trgm"}) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if err := value.ObserveExtensions(value.Target(), instance.ExtensionSupport{}); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("explicit invalidation retained stale support: %v", got) + } +} -- 2.54.0 From 813b4341d1fdba268a14d9cc8de2501cc5e5600e Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Mon, 21 Sep 2026 07:33:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=E5=A4=8D=E7=94=A8=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=A7=82=E6=B5=8B=E6=B5=8B=E8=AF=95=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instance/instance_extensions_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/database/domain/instance/instance_extensions_test.go b/internal/database/domain/instance/instance_extensions_test.go index 92f012f..d92ddf4 100644 --- a/internal/database/domain/instance/instance_extensions_test.go +++ b/internal/database/domain/instance/instance_extensions_test.go @@ -29,10 +29,10 @@ func TestInstanceAcceptsExtensionObservationForCurrentTarget(t *testing.T) { target := value.Target() snapshot := value.Snapshot() - if err := value.ObserveExtensions(target, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + if err := value.ObserveExtensions(target, instance.ObserveExtensionSupport([]string{testTrigram})); err != nil { t.Fatal(err) } - if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionsAccepted { + if got := value.CheckExtensions(instance.NewExtensionSet([]string{testTrigram})); got.Decision != instance.ExtensionsAccepted { t.Fatalf("CheckExtensions() = %v, want accepted", got) } if value.Snapshot() != snapshot { @@ -52,20 +52,20 @@ func TestInstanceRejectsExtensionObservationForDifferentTarget(t *testing.T) { t.Fatal(err) } - if err := value.ObserveExtensions(different, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + if err := value.ObserveExtensions(different, instance.ObserveExtensionSupport([]string{testTrigram})); err == nil { t.Fatal("observation for a different target was accepted") } - if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionSupportUnobserved { + if got := value.CheckExtensions(instance.NewExtensionSet([]string{testTrigram})); got.Decision != instance.ExtensionSupportUnobserved { t.Fatalf("rejected observation changed support: %v", got) } } func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing.T) { - requested := instance.NewExtensionSet([]string{"pg_trgm"}) + requested := instance.NewExtensionSet([]string{testTrigram}) t.Run("validation", func(t *testing.T) { value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, false) - if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err != nil { t.Fatal(err) } if err := value.BeginValidation(); err != nil { @@ -78,7 +78,7 @@ func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing. t.Run("deletion", func(t *testing.T) { value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, true) - if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err == nil { t.Fatal("deleting instance accepted a new observation") } if err := value.BeginDeletion(); err != nil { @@ -92,8 +92,8 @@ func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing. func TestInstanceCanExplicitlyInvalidateExtensionObservation(t *testing.T) { value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) - requested := instance.NewExtensionSet([]string{"pg_trgm"}) - if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + requested := instance.NewExtensionSet([]string{testTrigram}) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err != nil { t.Fatal(err) } if err := value.ObserveExtensions(value.Target(), instance.ExtensionSupport{}); err != nil { -- 2.54.0