10 Commits
Author SHA1 Message Date
panxiao81 ff21c5bfba docs: 保留 DBaaS 已批准设计合同
Verify / test (pull_request) Successful in 7m34s
Verify / lint (pull_request) Successful in 8m8s
2026-09-20 20:30:39 +00:00
panxiao81 c004aca1cc docs: 统一 Database API 到 Ayatori 域
Verify / test (pull_request) Successful in 6m7s
Verify / lint (pull_request) Successful in 6m32s
2026-09-20 20:20:49 +00:00
panxiao81 85b61a6a23 docs: 允许 Database 模块无兼容负担重构
Verify / test (pull_request) Successful in 6m19s
Verify / lint (pull_request) Successful in 6m43s
2026-09-20 20:11:41 +00:00
panxiao81 4206cfbab7 docs: 记录 Compute 方向与 Database 模块合并
Verify / test (pull_request) Successful in 8m2s
Verify / lint (pull_request) Successful in 9m34s
2026-09-20 19:59:53 +00:00
panxiao81 09a0a5665f docs: 解耦内置 API 与上游实现组件
Verify / test (pull_request) Successful in 8m25s
Verify / lint (pull_request) Successful in 9m0s
2026-09-20 19:42:39 +00:00
panxiao81 d9d99b2f9b docs: 按实际管理缺口限定产品范围 2026-09-20 19:18:12 +00:00
panxiao81 3e21de5942 docs: 明确 API machinery 与领域控制循环边界 2026-09-20 19:02:06 +00:00
panxiao81 f76978ca17 docs: require integration tests for controllers
Verify / test (pull_request) Successful in 11m35s
Verify / lint (pull_request) Successful in 11m58s
2026-09-18 18:33:11 +00:00
panxiao81 06bc54e3cf test: exercise controller with envtest
Verify / test (pull_request) Successful in 8m22s
Verify / lint (pull_request) Successful in 9m37s
2026-09-18 18:30:14 +00:00
panxiao81 86effb72a8 feat: execute jobs on Kubernetes
Verify / test (pull_request) Successful in 7m25s
Verify / lint (pull_request) Successful in 8m9s
2026-09-18 18:20:10 +00:00
56 changed files with 1132 additions and 5029 deletions
-76
View File
@@ -1,76 +0,0 @@
---
name: homelab-knowledge
description: Query and maintain the shared homelab-wiki when working on homelab services, infrastructure, architecture, operations, or current service status. Use it to gather existing context before work and to keep durable knowledge synchronized after relevant changes; do not use it for unrelated software work or as a substitute for commit and PR history.
---
# Homelab Knowledge
Use `homelab-wiki` as the shared long-lived knowledge base for people and agents. Search it directly with `rg`; do not introduce a search index, vector database, or generated copy of the wiki.
## Locate the wiki
Resolve the checkout in this order:
1. `$HOMELAB_WIKI_PATH`, when set.
2. A sibling directory named `homelab-wiki` next to the current repository.
3. `/home/panxiao81/homelab-wiki` when it exists.
If no checkout is available, report that constraint. Do not silently skip the knowledge step, clone a repository, or create a replacement wiki without the user's authorization.
Before using the wiki, read its `AGENTS.md` completely. For edits, also read `README.md` and `CONTRIBUTING.md` completely and follow any more specific instructions associated with the target page.
## Gather context
At the beginning of a homelab task:
1. Derive search terms from the component name, service aliases, hostnames, Kubernetes resources, configuration keys, error text, and task intent.
2. Use `rg -n -i` in the wiki to find candidate pages. Prefer several precise searches over reading the whole repository.
3. Follow the wiki's task index, service index, architecture constraints, source records, and verification conflicts when they are relevant.
4. Read the closest authoritative pages and their material links before making decisions. Also read the corresponding source repository README or runbook when changing an implementation.
5. Distinguish documented design, declared configuration, deployment history, live verification, and work currently in progress. Do not present one as another.
For questions about current project or service status, first obtain the maintainer's current-work and ticket context as required by the wiki, unless the conversation already provides that authorization and scope. Reading documentation does not authorize live-system inspection.
Answer read-only questions from the evidence found. Include paths or links that let the user verify important claims, and state when evidence may be stale or conflicting.
## Maintain knowledge after changes
For any code, configuration, infrastructure, or operational change, perform a documentation-impact check before declaring the task complete.
Update the wiki in the same task when the change affects durable knowledge such as:
- service purpose, lifecycle, entry point, authentication, permissions, dependencies, or first-use path;
- architecture boundaries or accepted constraints;
- deployment ownership or persistent operating behavior;
- troubleshooting, recovery, verification, or maintenance procedures;
- the addition, replacement, or retirement of a service.
Keep one-time progress, implementation narration, and release-by-release history in commits, PRs, or tickets. Do not copy them into the wiki unless they change a durable stage summary. Implementation-specific parameters may remain in the source repository README or runbook when the wiki convention says to link rather than duplicate them.
When editing:
1. Inspect both the source-repository diff and the wiki working tree before writing. Preserve unrelated user changes in both repositories.
2. Update the page closest to the fact first, then only the navigation, indexes, constraints, or verification records that the wiki rules require.
3. Preserve evidence metadata. Never advance `last_verified` without performing the stated live verification; ordinary review may update only fields permitted by the wiki.
4. Link related source commits, PRs, or paths when available. Clearly mark uncommitted sources and unfinished cross-repository synchronization.
5. Record conflicts rather than resolving them by assumption. Ask before live inspection or before choosing among materially conflicting current-state claims.
6. Keep credentials, tokens, private keys, Terraform state, secret values, and sensitive command output out of documentation. Never read or copy known sensitive files merely to improve the wiki.
Wiki edits are a separate repository change. Do not commit, push, open a PR, or modify a live system unless the user has authorized that action.
## Verify and report
After editing the wiki, run from its root:
```bash
python3 scripts/check_docs.py
git diff --check
```
If the checker itself changed, also run:
```bash
python3 -m unittest discover -s tests -v
```
In the final response, report source-repository changes and wiki changes separately, including validation performed and anything still awaiting verification or cross-repository linkage. If no wiki update was needed, state the concrete reason; do not merely say that documentation was unaffected.
+3 -31
View File
@@ -1,9 +1,9 @@
name: Verify name: Verify
on: on:
# 合并前完成全量验证,合并到 main 后不重复运行同一套检查。
pull_request: pull_request:
workflow_dispatch: push:
branches: [main]
jobs: jobs:
test: test:
@@ -40,32 +40,4 @@ jobs:
cache: true cache: true
- name: Lint - name: Lint
run: | run: make lint
make lint
make lint-database-integration
database-integration:
runs-on: [self-hosted, pod]
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417
with:
go-version-file: go.mod
cache: true
# Runner 提供本 job 可用的 Docker;workflow 只验证,不重复启动 daemon。
- name: Verify Docker availability
shell: bash
run: |
set -euo pipefail
docker version
docker info --format 'Server={{.ServerVersion}} StorageDriver={{.Driver}}'
- name: Test Database integration with real backends
run: make test-database-integration
-8
View File
@@ -67,14 +67,6 @@ test: manifests generate fmt vet setup-envtest ## Run tests.
lint: golangci-lint ## Run golangci-lint linter lint: golangci-lint ## Run golangci-lint linter
"$(GOLANGCI_LINT)" run "$(GOLANGCI_LINT)" run
.PHONY: test-database-integration
test-database-integration: setup-envtest ## 使用临时 API server 与独立 PostgreSQL 容器验证凭据读取和连接更新。
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -tags=integration -race -count=1 ./internal/database/...
.PHONY: lint-database-integration
lint-database-integration: golangci-lint ## 检查集成测试构建标签下的 Database 代码。
"$(GOLANGCI_LINT)" run --build-tags=integration ./internal/database/...
.PHONY: lint-fix .PHONY: lint-fix
lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
"$(GOLANGCI_LINT)" run --fix "$(GOLANGCI_LINT)" run --fix
-1
View File
@@ -34,7 +34,6 @@ Ayatori 是 `ddupan.top` homelab 的内部基础设施控制平面。它以 Kube
- [ADR-0006:按实际管理缺口扩展资源 API](docs/decisions/0006-demand-driven-resource-scope.md) - [ADR-0006:按实际管理缺口扩展资源 API](docs/decisions/0006-demand-driven-resource-scope.md)
- [ADR-0007:复用 Node API 建立按需实现的 Compute 能力](docs/decisions/0007-compute-node-and-vm-boundary.md) - [ADR-0007:复用 Node API 建立按需实现的 Compute 能力](docs/decisions/0007-compute-node-and-vm-boundary.md)
- [ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块](docs/decisions/0008-merge-postgresql-tenant-operator.md) - [ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块](docs/decisions/0008-merge-postgresql-tenant-operator.md)
- [ADR-0009:分离 Database 资源与 Tenant 申请](docs/decisions/0009-database-resource-and-claim.md)
## 当前状态 ## 当前状态
+8
View File
@@ -20,6 +20,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1" executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/controller"
// +kubebuilder:scaffold:imports // +kubebuilder:scaffold:imports
) )
@@ -165,6 +166,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if err := (&controller.JobReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "Failed to create controller", "controller", "Job")
os.Exit(1)
}
// +kubebuilder:scaffold:builder // +kubebuilder:scaffold:builder
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+53 -6
View File
@@ -1,11 +1,58 @@
---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole kind: ClusterRole
metadata: metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: manager-role name: manager-role
rules: rules:
- apiGroups: [""] - apiGroups:
resources: ["pods"] - ""
verbs: ["get", "list", "watch"] resources:
- namespaces
- serviceaccounts
verbs:
- get
- list
- watch
- apiGroups:
- batch
resources:
- jobs
verbs:
- create
- delete
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses
- kubernetesexecutionparameters
verbs:
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs
verbs:
- get
- list
- patch
- update
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs/finalizers
verbs:
- update
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs/status
verbs:
- get
- patch
- update
-5
View File
@@ -26,11 +26,6 @@ cloud-init 设备、bridge/VLAN 映射和默认 placement 由平台维护。
## 生命周期基线 ## 生命周期基线
新增资源、绑定、回收或恢复语义前,先引用 Kubernetes 官方对应资源设计与成熟 controller
模式,说明采用部分及有意偏离的原因。不要仅沿用字段名称而忽略生命周期与权限边界。
例如 Database 借鉴 PV/PVC 的资源与申请分离、排他绑定和 Retain,但不引入 CSI 协议、
存储调度或额外 registry;见 [ADR-0009](../decisions/0009-database-resource-and-claim.md)。
所有受管资源必须定义: 所有受管资源必须定义:
- `observedGeneration` - `observedGeneration`
-13
View File
@@ -1,18 +1,5 @@
# 环境与发布 # 环境与发布
## CI 验证入口
Verify 工作流在 PR 上执行全量测试、lint 和 Database 集成测试;合并到 main 后不通过 push
事件重复运行。需要排障或验证直接推送的紧急修复时,可通过 workflow_dispatch 手动运行。
此约定不减少检查项目,也不修改分支保护设置;常规变更必须经过 PR,直接推送 main 不会自动验证。
Gitea 的 PR 工作流验证分支 head,而不是合并预览提交,见
[官方事件说明](https://docs.gitea.com/usage/actions/faq/)。合并前必须确认最新 head 检查通过,
且与当前 main 合并不会引入未经验证的内容组合;基线有实质变化时先更新分支并重验。
只改变基线引用且目标文件树不变时,不需要为了合并提交的 SHA 不同重复全量验证。
## 环境与制品晋级
Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时 Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时
没有生产制品需要承载,提前维护第二套环境没有收益。 没有生产制品需要承载,提前维护第二套环境没有收益。
-120
View File
@@ -1,120 +0,0 @@
# Database 模块
Database 是 Ayatori 首批实际产品领域之一。当前已包含 Instance 领域基础、管理凭据连接与
metadata 观察切片,尚未完成 Database API/controller 和 Tenant 供应链路。
## 当前设计(2026-09-24)
采用 Instance → Database → Tenant 的资源与申请模型;Database 独立存在,支持显式导入、
Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership registry 及任意 status
丢失自动恢复所有权的要求。未知同名资源或创建结果不确定时,清楚报告 Conflict 并人工处理。
依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见
[系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。
registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理
能力观察直接判定 Ready。新增 Database 资源、绑定、导入、角色/凭据管理边界与回收链路尚未实现。wiki 同步位置见
`homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。
## 来源基线
完整设计合同及首批领域模型与测试提取自原 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、目录和链接适配;2026-09-24 经维护者
批准的资源/申请分离修订明确替代 registry、自动恢复与原 Retain 合同,其余适用的安全约束保留。
迁移只使用该 commit 中已提交的文件。源仓库
`feature/instance-extension-observations` 工作树中的 `instance.go` 修改与
`instance_extensions_test.go` 未进入本切片。
代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新;
首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新
验证或删除时失效,且支持判定不授权 Tenant provisioning。已有 Ready 切片曾按原合同加入
registry 准备决策;这一依赖现已从代码移除,不能把旧运行链路接回模型。
各层验证边界见 [Instance 领域规格](domain-instance.md)。
## 边界
- 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。
- CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。
- Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。
- 当前代码只检查 Instance 供应前置条件,不授予 Tenant 所有权或外部写入权限,也不表示
Database API 已经可用。
## 管理凭据与连接切片
`application.InstanceService` 适配自原项目固定基线
[`internal/instance/service.go`](https://git.ddupan.top/panxiao81/postgresql-tenant-operator/src/commit/dae546e58efa1be81e930861c87f7fb13bb12113/internal/instance/service.go),
保留 CredentialReader、Connector、Database 的装配边界及串行操作/释放规则。
具体连接池完全由 pgxpool v5.11.0 提供,PostgreSQL adapter 不拥有凭据缓存、轮换流程或任意观测回调。
相对源基线有两项按已批准合同作出的必要修改:管理凭据从固定 controller namespace 的
Kubernetes Secret 直接读取;每轮比较有效用户名、密码,检测到变化即关闭旧连接并重新装配。
Secret metadata 和无关字段变化不重建连接。观测后再次读取 Secret,中途有效值变化则丢弃结果,
不把旧连接的成功作为新凭据有效的证据;这不构成跨 Kubernetes/PostgreSQL 的原子事务。
Instance UID、endpoint 或凭据引用变化也会释放旧连接;Forget/Close 只释放本地资源。
当前通过 `ObserveMetadata` 读取服务器版本和可用扩展,`ObserveVersion` 只是其版本读取便捷入口,
不能产生完整 CapabilityObservation 或 Ready。
controller 接入、Secret watch、finalizer 与真实权限检查仍待后续切片;并发 CR 更新
必须由调用者通过 resourceVersion 校验。应用层沿用源实现的串行处理,本阶段未引入新的调度框架。
运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部
DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、
namespace 边界、有效值轮换、metadata 无关变化、中途轮换、重建/重试、并发读取、Forget/Close
与 TLS DNS/IP SAN、错误 CA/主机名和禁止明文降级。CI 使用 Pod runner 执行,由 runner 提供
可用的 Docker,workflow 只做预检、不自行启动 daemon;不依赖 VM。普通 lint 之外还检查
integration 标签代码。领域单测、真实 API 行为与真实 PostgreSQL 行为分别验收,不以本切片
替代整个 Instance controller 的集成验收。
## 服务器 metadata 与扩展观测
SQL adapter 通过一条只读语句读取 `pg_catalog.current_setting('server_version')` 和
`pg_catalog.pg_available_extensions`,避免从已安装列表推断可用列表,且不依赖可修改的
`search_path`。查询失败丢弃整份结果;成功返回空列表与尚未观察严格区分。
参考 PostgreSQL 的 [pg_available_extensions](https://www.postgresql.org/docs/18/view-pg-available-extensions.html)
与 [CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html) 合同:可用列表
表示服务器提供的扩展,不证明管理账号有安装权限,也不保证依赖和其他安装前提满足。
`InstanceService` 保留原有 CredentialReader → Connector → Database 边界,复用同一个
凭据读取、连接刷新和串行释放流程,不新增连接池封装或任意查询回调。每次重新查询 metadata,
并在 Secret 有效值回读一致后生成不可变的 `MetadataObservation`,绑定本次 target(含当前
generation),不绑定建池时的旧 target。结果不包含凭据,扩展集合不与 driver 的可变 slice 共享。
凭据中途变化、读取失败或查询失败时,返回零值观察并释放连接,不复用旧的扩展列表。
调用方可将 `Target()` 与 `Extensions()` 交给 Instance 的 `ObserveExtensions`;应用调用链
仍负责同轮次使用,不能持久化或跨轮缓存这份证据。metadata 读取不安装扩展、不初始化 registry、
不设置 Ready,也不授予 Tenant 写权限。管理权限矩阵及 controller 的
checkpoint/status/finalizer 链路仍是后续切片。
真实 API server + PostgreSQL 测试验证未安装扩展可被观察、名称保持大小写、search_path 遮蔽
不改变查询来源、低权限账号读取、权限撤回失败与恢复、Secret 中途变化丢弃扩展结果。
单元测试补充成功空列表、查询附带部分数据时丢弃、结果与可变 slice 隔离、每轮重新读取和
generation 变化时的目标绑定;原凭据/TLS/超时/并发测试沿同一 metadata 路径继续运行。
## Registry 撤除
原 `adapter/postgresql/registry`、SQL 迁移、registry 专属集成测试及 tern 依赖已删除;
未提交的 inspection 实验也已撤除。来源仍可在 Git 历史追溯,没有删除外部 PostgreSQL 对象。
Instance 不再具有 InitializingRegistry 阶段、RegistryState、准备决策或回读方法;
首次完整管理能力观察即可完成验证,重验失败则撤销本轮就绪证据。
保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力
在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。
这不等于管理权限探测矩阵或三资源 controller 已实现。
## 设计入口
- [系统规格](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;
在对应实现切片完成前,不应把其中命令理解为当前仓库已经可执行的入口。
-120
View File
@@ -1,120 +0,0 @@
# v1alpha1 API 合同
| 项目 | 内容 |
| --- | --- |
| 状态 | 三资源模型已批准;新增字段与绑定协议待评审 |
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-24 |
以 [系统规格](specification.md) 与
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。尚未实现新 API,
本页不提供可直接 apply 的三资源 YAML,以免把工作名称和未决字段当作已发布合同。
## 通用约定
- Instance 是 cluster-scoped;Tenant 是 namespaced;Database scope 待字段评审。
- 每类资源提供唯一的 Ready Condition、observedGeneration;phase 用于进度展示,
不能单独作为写权限或所有权证明。
- 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。
- controller 管理 status;资源侧管理与回收权限不得随 Tenant editor 权限自动授予。
- 固定默认值使用 CRD defaulting,跨字段/不可变校验使用 CEL 或 controller;
并发更新使用 resourceVersion,不增加 mutating webhook 或跨系统事务。
- database/role identifier 继续匹配 `^[a-z][a-z0-9_]{0,62}$`。
- 动态申请的 database/loginRole 省略时继续以 Tenant metadata.name 为语义默认值;
显式导入资源使用实际目标,不从新 Tenant 名称重新推导。最终互斥字段须经 API 评审。
- 投射仍位于 Tenant namespace,ExternalSecret 默认命名沿用
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可由申请指定,省略时同名。
有效 Instance 名称与 Tenant 名称合计不超过 241 字符;完整名称须满足 API 名称校验。
- 任何 spec/status/错误不得出现密码、Token 或完整秘密响应。
## PostgreSQLInstance
保留管理入口字段:
| 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 中的键名 |
管理 Secret 固定在 controller namespace,不接受 namespace 或 Bao path。
管理员维护其 ExternalSecret,controller 只读;有效用户名/密码变化时重验连接。
endpoint 变化使旧观察失效,不迁移旧服务器的数据,不自动接管旧 UID 的资源。
Status 保留 observedGeneration、postgresqlVersion 和 conditions;
phase 为 Pending、Validating、Ready、Deleting,移除 InitializingRegistry。
Ready 要求连接、metadata 与所需管理权限,不要求 registry。
实例实际可用扩展来自查询,不提供 allowedExtensions 配置。
开始受管时保存 finalizer;删除时阻止新供应,并检查 Database(含 Released)及未绑定
Tenant 引用。无引用才解除,不级联删除资源;查询失败不能视为无引用。
## Database 资源(工作 Kind:PostgreSQLDatabase)
以下是字段职责,不是已批准的 JSON schema:
| 内容 | 合同 |
| --- | --- |
| `instanceRef` | Database 自身必填;定位来源并记录绑定的 Instance UID,不依赖 Tenant 补齐 |
| 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 |
| 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 |
| 申请预留/绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份 |
| 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 |
| 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 |
概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。
Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。
导入的初始检查只读;存在不等于 Ready,也不等于有权交付给任意 Tenant。
默认保留不隐含密码、owner、授权或删除的变更许可。
## PostgreSQLTenant
保留用户申请与交付职责:
- 动态申请描述 Instance、所需数据库/登录角色、扩展与目标 Secret;
或显式引用管理员已登记的 Database。后者从 Database 获取 Instance,不重复指定来源;
两条路径互斥,具体 schema 在 API 切片固定。
- 绑定前固定有效需求,绑定/开始供应后不能通过改引用或名称迁移资源。
- extensions 成功后只允许追加,不自动卸载。
- status 展示绑定 Database 身份、Ready、交付 Secret 引用及不含认证信息的 OpenBao URL。
- Tenant 删除释放申请,按照 Database 的回收策略处理,不独立持有最终删除授权。
旧 Tenant `spec.deletionPolicy` 不再作为最终资源回收策略;旧字段表、供应 phase 枚举及
仅按 Tenant namespace/name 派生凭据路径的规则不再是实现合同。已有代码没有兼容负担,
不保留两套相互覆盖的策略字段。
## 绑定协议评审要求
实现前必须明确:
1. 资源 scope 与 typed reference 格式,管理员预留/导入与普通申请者的 RBAC 边界。
2. 资源侧排他记录的写入点、双向绑定顺序、API 冲突重试与单边完成恢复。
3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。
4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。
5. Database 的 role/凭据管理范围、稳定凭据定位、旧访问处置与投射清理顺序。
6. 删除开始后的策略固定点和 Tenant/Database finalizer 配合。
该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。
未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。
## Conditions
每类资源至少提供唯一的 Ready;同类型 Condition 不重复,维护 lastTransitionTime 与
observedGeneration。最低安全错误分类见 [系统规格](specification.md#10-conditions-与可观测性)。
Conflict 必须说明目标、步骤、已确认与不确定部分及人工核实建议;不能建议清空 status、
伪造 Ready 或改密码绕过。可恢复依赖故障退避重试,冲突不忙循环。
具体 Reason 在 API 切片固定,Released 不得误报为可立即交付。
## 实现验收
CRD defaulting、CEL、status subresource、resourceVersion、并发绑定、重启与依赖 watch
使用真实 API server 验证;ownerReference/namespace 删除的实际 GC 使用测试集群。
类型、CRD、sample 和 contract tests 必须一起对齐,不以 fake client 替代 API 语义。
-58
View File
@@ -1,58 +0,0 @@
# Database 系统架构
本页解释 [系统规格](specification.md) 的组件边界;资源与申请分离的依据见
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。设计已确认,运行链路尚未完成。
## 资源与后端
```text
Kubernetes API
Instance ──引用── Database ──排他绑定── Tenant
|
Ayatori Database controllers
| | |
PostgreSQL OpenBao ExternalSecret
catalog |
ESO → Secret
```
Kubernetes 保存声明、绑定与操作进度;PostgreSQL 保存实际数据库状态;OpenBao 保存应用凭据。
不在 PostgreSQL 中再建立管理 registry。Instance 是管理入口而非 CSI 协议实现,adapter 是薄访问层。
Instance 验证当前目标的管理能力,不供应 Tenant 数据库,不因 registry 缺失初始化任何 schema。
Database 用例负责独立资源的供应、显式导入、保留与回收;Tenant 用例负责申请、绑定与凭据交付。
这是用例职责,不要求为每一步新增一个 controller 或通用控制循环。
## 协调与恢复
通过 Kubernetes API 的 resourceVersion 保护并发更新,watch 推动依赖恢复;外部操作前保存
资源与意图,执行后观察并记录结果。数据库实际操作仍须处理后端竞态,列表检查不是唯一约束。
可靠确认的步骤允许幂等继续;外部创建与进度写入之间的失败若导致归属不确定,则停止写入并
报告 Conflict。不创建第二套所有权存储,不承诺跨系统事务或任意 status 丢失自动认领。
错误必须提供人工可用的步骤、资源与结果确定性信息,但不泄漏秘密。
## 绑定、导入与回收
Database 独立于 Tenant 存在,不能以会导致级联删除的 ownerReference 连接两者。
Instance 删除检查 Database 引用,包括 Released 资源,不直接清理数据库。
动态供应和管理员导入使用同一种资源记录。导入验证初始只读;未知同名数据库仍为冲突。
Retain 保留资源及旧绑定身份;重新绑定必须经过人工数据和访问权限处置,不自动分配。
Delete 由资源侧明确授权,在 finalizer 保护下按管理范围清理并逐步回读。
角色、凭据与投射的具体管理字段和清理顺序仍需 API 评审,不能用 PV 类比代替数据库权限设计。
## 保持的访问边界
管理 Secret 固定在 controller namespace,管理员维护其 ExternalSecret;controller 只读。
有效值变化刷新管理连接,不直接访问 Bao 获取管理凭据,不自行实现连接池。
应用凭据写 OpenBao,由 ESO 投射;controller 不直接写明文 Secret。
TLS、DNS/IP SAN、七键凭据输出和最小权限合同继续适用。
## 导航
- [API 合同与待细化字段](api-reference.md)
- [领域模型](domain-model.md)与[Instance 规格](domain-instance.md)
- [部署](deployment.md)、[安全](security.md)、[开发测试](development.md)
- [导入与迁移](migration.md)、[运维](operations.md)
-132
View File
@@ -1,132 +0,0 @@
# 部署与配置
> 本页迁入作为 Database 模块的目标部署合同。Ayatori manager flags、manifests 与发布装配尚未
> 实现;当前行为以修订后的系统规格为准,本页不能直接用于部署。
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao |
| 最后更新 | 2026-09-24 |
本文定义 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 配置合同
以下是尚待实现的部署配置合同,凭据定位随三资源 API 继续细化。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 不能选择任意凭据路径。凭据必须能随 Database 保留并安全交付给被授权的新 Tenant;
原 `<base-path>/<namespace>/<metadata.name>` 定位规则不再直接作为新 API 合同。
稳定位置与导入关联方式待 API 评审;consumer URL 仍使用无认证信息的 KV v2 API URL。
base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空段、`.`、`..`、
`data`/`metadata` API 层。ExternalSecret 固定命名为
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可由 Tenant 指定,但名称必须
满足 Kubernetes Secret 名称校验,不限制命名内容,默认与 ExternalSecret 同名。
配置变化不得隐式迁移既有凭据。修改 KV mount/base path 或 consumer address 前必须
停止 controller、评估现有 Database 与绑定,并走明确迁移。资源记录应能定位原凭据,
不能根据新部署参数静默切换;不再使用 registry 保存安装身份。
## PostgreSQL 管理 role
生产部署禁止使用 superuser。管理 role 至少需要:
- 连接管理 database、读取必要 catalog;
- 创建/修改受管 login role;
- 创建 database 并指定 owner;
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
- 连接租户 database 并创建实例实际支持、租户申请的 extension;
- `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 和 Retain 时的
保留/清理须与凭据交付协议一起确定,不能把投射关系等同于 Database 的 GC 关系。目标
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
`sslmode`。
## Kubernetes RBAC
- controller 按用例读/写 Instance、Database、Tenant 及其 status/finalizer 和 Event。
- Database 不设置随 Tenant 级联删除的 ownerReference;导入、预留、回收和重新绑定授权限管理员。
- 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 数据与 OpenBao,
先在隔离 Kind 环境运行 E2E。禁止在同一组 CR 上同时运行两个 controller 版本。若新版本
在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或凭据定位迁移时,
必须先写独立升级规格和回滚步骤。
## 上线验证
```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) 的备份/逃生检查。
-255
View File
@@ -1,255 +0,0 @@
# 开发与测试环境
> 本页迁入作为 Database 模块的测试分层与 fixture 合同。旧项目的 Make target、devcontainer
> 和脚手架版本尚未适配 Ayatori;实现时应复用 Ayatori 现有工具链,并保持这里定义的测试边界。
## 当前设计验收(2026-09-24)
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 将资源生命周期从 Tenant 中分离。
新增验收矩阵见 [系统规格](specification.md#11-验收)。registry 实现、专属测试与迁移依赖已撤除,不继续 schema 审计或自动所有权恢复切片。
| 层次 | 本次设计要求 |
| --- | --- |
| 纯领域 | Instance 无 registry 就绪判定、排他绑定、Released 不自动复用、管理范围与冲突规则 |
| envtest | 三资源 schema/status、RBAC、resourceVersion 并发、绑定单边更新/重启、依赖 watch、finalizer |
| 真实 PostgreSQL/OpenBao | 同名不修改、显式导入只读验证、创建不确定报冲突、可靠步骤幂等、Delete 故障重试 |
| 测试集群 | Tenant/namespace 删除不 GC Database、ESO 交付/释放、人工重新绑定前的旧访问处置 |
需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、
Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。
envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。
新增资源 API 尚未实现,本轮没有完成或运行这些新增行为测试。
## Ayatori 已接入的凭据与 metadata 切片测试
本节命令已在 Ayatori 接入;以下历史 Compose/Kind 操作仍属于迁入的目标合同。
```sh
make test
make lint
make lint-database-integration
make test-database-integration
```
最后一项要求本机 Docker 可用。它启动 envtest 的真实 API server/etcd 和固定镜像摘要的临时
PostgreSQL 容器,随机绑定回环端口,不读取 kubeconfig,也不接受指向现有数据库的 DSN。
每个凭据场景使用独立环境;测试清理仅关闭自己的进程和按确切 ID 删除自己的容器。
TLS 测试在临时目录生成一次性证书与私钥,不使用生产 CA。
当前覆盖固定 namespace 的 Secret 读取与 RBAC、缺失/无效凭据恢复、有效凭据变化后的重连、
无关字段更新不重连、中途轮换时丢弃观察、会话重建、并发读取、本地连接释放和 TLS 验证。
快速测试、lint 和 Database 集成测试均使用 Pod runner。按维护者于 2026-09-21 更新的接口
约定,runner 提供默认可用的 Docker;workflow 通过 `docker version` 和 `docker info` 预检,
不自行启动 daemon、不强制 storage driver 或覆盖 Docker endpoint。该约定的 CI 验收依赖
runner 后端修复上线,不能从本地测试通过推断远端已经可用。
fixture 启动失败会保留退出错误与 stderr,并遮蔽测试密码,
以区分缺少命令、daemon 不可达、权限和镜像拉取失败。
metadata 测试验证版本与可用扩展的只读查询,包括未安装扩展、大小写保持、search_path 遮蔽、
低权限账号读取、catalog 访问被撤回后的失败与恢复,以及凭据中途变化时同时丢弃版本和扩展。
权限撤回只修改每个场景自建 PostgreSQL 容器的 ACL;不连接现有服务。
可用列表不等于安装权限,这些检查不替代后续的完整管理权限矩阵或 Instance Ready 验收。
Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的
未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。
这些测试尚不包含 Instance CRD/controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。版本查询成功不意味着 Instance Ready。
本项目同时依赖 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`、三资源的
状态与绑定、不可变字段、extension 只追加、冲突和外部错误分类。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:[email protected]: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。这是旧环境设计,不是当前 Ayatori 入口;当前 fixture 不接受外部 DSN,
使用本页前部的 `make test-database-integration`,禁止把测试指向真实 homelab database。
Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问
Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络,
并通过 `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
-> 验证 Database 绑定、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret
-> 分别使用 DNS host 与 IP hostaddr 登录
-> 删除 Tenant 并分别验证 Retain 与 Delete(含故障点重试)
-> 删除 Kind
```
以上 Compose/Kind 流程来自旧项目的环境设计,不是 Ayatori 已实现的运行状态。
Ayatori 尚未完成 Instance/Database/Tenant controller 链路;当前可执行的切片命令以本页
前部为准,不能从旧脚手架或 adapter 测试推断完整生命周期已经通过。
## 测试数据与泄漏检查
- 只使用显眼的固定 canary 测试密码,测试后扫描日志、Event、Condition、metrics 和
CR dump,出现 canary 即失败。
- 每个写入阶段注入中断:已确认步骤继续且密码不变;结果不确定则停止并明确报告 Conflict。
- 缺少绑定/进度记录时不得凭同名外部对象恢复所有权;过期 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。
-88
View File
@@ -1,88 +0,0 @@
# Instance 领域对象规格
日期:2026-09-24。资源模型修订依据
[ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以
[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,完整应用/controller 链路尚未接入。
## 职责
Instance 是登记的 PostgreSQL 管理入口,只接收观察、判断规则,不直接或通过回调执行 IO。
应用层读 Secret、调用 adapter、关联目标与观察,再将结果交给领域判定。
领域不持有客户端、连接池、context 或完整 Database/Tenant 集合。
每轮由 CR 重建;连接可由应用层复用,但旧连接、旧 Ready 不是本轮能力证据。
身份与 endpoint 以管理员声明为准,变更使观察失效,不验证物理服务器连续性,
不迁移旧数据,不自动授权旧 UID 资源的操作。
## 字段与观察
| 内容 | 合同 |
| --- | --- |
| identity | Instance UID/name;同名新 UID 是新对象 |
| revision | 当前 generation,不能与旧观察混用 |
| definition | endpoint 与管理 Secret 引用,不含明文 |
| checkpoint | Pending、Validating、Ready、Deleting |
| observedRevision/readiness/version | 映射 CR status,只表示进度或最近结果 |
| availableExtensions | 本轮实际可用集合;未观察与空集合不同 |
| evidence | 本轮目标、版本与管理能力检查结果,不持久化为永久授权 |
| deleting | 删除请求;禁止新的供应 |
CapabilityObservation 包含目标(UID、generation、endpoint、凭据引用)、server version、
连接/metadata/role/database/grant/extension 管理检查项。各项区分成功、失败和未观察,
不包含 registry 状态、凭据或驱动错误。缺项或目标不匹配不得 Ready。
实际可用扩展不等于安装权限,不做 allowlist,不改大小写;查询失败不当作不支持,
列表变化不自动卸载。安装必须由实际操作及回读验证。
resourceVersion 留在应用层处理 API 并发,不是领域版本或物理数据库身份。
## 行为
- Reconstitute 校验 definition,重建 checkpoint,丢弃旧 evidence。
- BeginValidation 清空能力证据并进入 Validating。
- AssessManagement/AssessReadiness 根据本轮完整观察判断 Ready 或安全失败;
方法的具体合并方式在实现重构时决定,不保留无意义的中间初始化阶段。
- CheckExtensions 判定请求集合,不能单独授权供应。
- RequireProvisioningReady 检查本轮能力与删除状态;不授予 Database 所有权。
- BeginDeletion 禁止新供应,不执行外部删除。
- Snapshot 返回值副本,不序列化 evidence 或秘密。
撤销 PlanRegistryPreparation、AssessRegistryResult、RegistryPreparationResult、
RegistryState 与 InitializingRegistry 的设计需求。不能用始终返回 Usable 的兼容层绕过旧逻辑。
## 应用与连接边界
管理凭据来自 controller namespace 的 Secret;管理员维护 ExternalSecret,ESO 同步。
Instance 不直接访问 Bao。有效用户名/密码变化时应用层释放旧连接、用新值装配并重验;
metadata/无关字段变化不重建。采集途中有效值变化必须丢弃观察,不因 generation 没变而复用。
controller 不修改 PostgreSQL 密码、管理 Secret 或 Bao 管理凭据。
InspectManagement 是观察能力,不包含 schema 初始化/迁移。只读 metadata 查询仍不足以
证明管理权限;真实权限矩阵由 adapter 定义和测试。连接复用由 pgxpool 提供,不自建池。
已可用管理凭据下,Bao/ESO 当前故障不单独撤销 Instance Ready。
## 状态与删除
```text
Pending → Validating → Ready
Ready → Validating(配置/凭据变更或能力失效)
任意阶段 → Deleting
```
应用层保存意图、获取观察、领域判定、按 resourceVersion 保存结果;
保存冲突重新装载,不能覆盖较新配置。Instance 观察本身不创建外部资源,
初始 status 缺失可重新探测,不由此推出 Database 所有权可自动重建。
受管前先保存 finalizer。删除期间查询 Database 引用(包括 Released/删除中)及未绑定
Tenant;存在引用或查询失败都等待,无引用才解除。不得级联删除数据库或凭据。
finalizer 不阻止并发申请 CR 创建;新请求见 Instance 删除中/不存在时不得供应。
不引入跨对象锁,不声称列表与删除之间存在原子事务。
## 验收与实现差距
领域单测覆盖缺项、旧配置、错误目标、扩展空集合与未观察、重验与删除禁用。
真实 API server 验证 Secret、resourceVersion、watch、finalizer;真实 PostgreSQL 验证
权限、TLS、凭据更新和查询失败,不以领域布尔值或 server_version 查询代替管理权限验收。
Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察
直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项
在这两个入口的失败与恢复,但完整权限探测、Instance controller 和三资源生命周期尚未完成。
-76
View File
@@ -1,76 +0,0 @@
# Database 领域模型
状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-24。
行为以 [系统规格](specification.md) 为准;决策依据见
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。
## 统一语言与关系
| 术语 | 含义 |
| --- | --- |
| Instance | 平台登记的 PostgreSQL 资源来源与管理入口 |
| Database | 独立存在的数据库资源,保存目标、管理范围、绑定与回收策略 |
| Tenant | 用户对数据库的申请与使用合同 |
| Binding | Database 与 Tenant 的排他关联,不是独立 Claim 或 registry |
| LoginRole | 当前单数据库场景中兼任 owner 的登录角色 |
| CredentialLocation | 与资源生命周期一致的凭据定位,不是密码 |
| CredentialProjection | 面向当前使用者的凭据投射要求与观察 |
Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。
Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。
Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。
Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。
释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。
Database 不再是 Tenant 内的无独立生命周期描述。原 OwnershipClaim 不再作为独立领域能力:
排他绑定是资源自身的不变量,Kubernetes 保存记录,不另建 PostgreSQL 所有权存储。
## 职责
Instance 只接收观察并判断当前连接、metadata、管理权限和扩展支持,不访问 IO、不初始化
registry。管理凭据来源和连接刷新由应用层协调,连接池由 pgxpool 实现。见
[Instance 规格](domain-instance.md)。
Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前
必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。
Tenant 表达申请与交付要求;显式选择已有资源不自动授权使用。绑定后检查数据库满足要求、
应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。
LoginRole 与 CredentialLocation 的生命周期必须随独立资源保留,不能因为 Tenant 消失就
失去定位或未经授权被删除;具体字段归属及导入时的管理范围需继续评审。不要因此新增 Role、
Credential 或 Claim CRD。当前首版仍是单 database + 单 login owner。
## 生命周期与恢复
- 动态创建与显式导入最终形成同一种 Database 资源,但导入本身不允许改密、改 owner 或删除。
- Retain 后 Database 保持 Released 与旧绑定身份,人工确认数据、权限和凭据后才可重新绑定。
- 回收策略属于资源侧;Tenant 与 Database 不是可随申请级联 GC 的父子关系。
- 绑定 UID 防止同名新申请继承权限。双向记录的单边写入不代表绑定完成。
- 普通失败按 reconcile 重试;可靠确认的步骤幂等继续;不确定创建/未知同名对象报告 Conflict。
- Kubernetes status 是持久进度和观察,不是外部事实,也不是 controller 内存。
不引入“status 任意丢失后自动恢复所有权”的附加要求。
## 分层
| 层 | 责任 |
| --- | --- |
| 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO |
| 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 |
| controller | watch/调度、映射、conditions/status/finalizer;不重写领域规则 |
| adapter | Kubernetes、PostgreSQL、OpenBao、ESO 的具体访问与安全错误分类 |
| 装配 | 客户端与成熟连接池的生命周期,不是领域状态 |
不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。
resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。
## API 切片前需明确
- Database 的 scope、引用格式、谁可以预留/绑定/释放,以及绑定字段和更新顺序。
- 资源侧回收策略与 Tenant/Database finalizer 配合。
- 导入时角色/凭据的管理范围,稳定凭据定位、旧使用者撤权及新投射授权。
- 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。
- 管理员确认冲突、解除旧绑定的具体可审计操作入口。
这些细节不阻止已确认的三资源设计,但必须先于对应 API 与生命周期实现获得评审。
-146
View File
@@ -1,146 +0,0 @@
# 现有数据库导入与迁移 Runbook
| 项目 | 内容 |
| --- | --- |
| 状态 | Review;尚未在临时 PostgreSQL 演练 |
| 适用范围 | 管理员显式导入,或通过 dump/restore 迁移到新资源 |
| 最后更新 | 2026-09-24 |
当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。
导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API 尚未实现,以下导入步骤
是验收要求而非可直接执行的命令。
## 显式导入与保留资源复用
1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。
2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。
3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。
4. 管理员授权目标 Tenant;controller 验证资源与申请符合要求并建立排他绑定。
5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。
Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到
人工处理完成,不仅靠清空 claimRef 或修改 UID 授予新使用权。
导入失败时原数据库应保持不变;撤回登记不得触发 Delete。绑定后的回退按 Retain 释放,
检查新投射与访问授权的影响,不能承诺撤回 CR 自动恢复此前所有外部访问状态。
## 可选的 dump/restore 路径
不适合直接导入、需要改变 owner/权限模型或移动数据时,可使用下述逻辑迁移流程。
它不是纳管现有数据库的唯一路径;保留旧资源作为限时回滚点。
以下命令是顺序模板,不可原样复制到真实环境。先把尖括号变量解析成明确值,确认当前
连接目标,再逐条执行。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='<old-admin-connection>' > 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='<secure-temp>/tenant.dump' \
--dbname='<old-admin-connection>'
pg_restore --list '<secure-temp>/tenant.dump'
```
不要删除旧 database/role。记录停写时间、dump checksum 和 PostgreSQL 版本。
### 3. 释放最终名称
保持应用停写,终止旧 database 的应用连接。连接其他管理 database,以管理员身份把旧
database 和旧 login role 改为明确的保留名称:
```sql
ALTER DATABASE <old_database> RENAME TO <old_database>_retained_<timestamp>;
ALTER ROLE <old_login_role> RENAME TO <old_login_role>_retained_<timestamp>;
```
identifier 必须由管理员工具安全引用,不能把未经校验的值直接拼入 SQL。PostgreSQL 在
角色改名时会清除以旧角色名加盐的 MD5 密码;使用 MD5 的旧环境必须在维护前准备安全的
密码重设/回滚方法。SCRAM verifier 不受角色名改动影响,但仍须实际验证回滚登录。
### 4. 创建受管空目标
应用 `PostgreSQLTenant`,使用未被占用的 database/loginRole,等待 Ready。确认:
- Database/Instance 身份及 Tenant 排他绑定正确;
- OpenBao 凭据位置与 Database 管理范围及当前交付授权一致;
- 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='<new-application-connection>' \
'<secure-temp>/tenant.dump'
```
extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore
行为幂等;目标实例不支持的 extension 必须在迁移前解决。
### 6. 验证并切换
- 对比关键 schema、表数、行数/校验和、sequence、function 和 migration version。
- 用新 login 验证读写、migration 和应用健康检查。
- 将应用配置切换到新 Secret 或 OpenBao URL,保持旧数据库只读/停写。
- 观察一个约定窗口,确认错误率、连接数和关键业务功能。
### 7. 收尾
回滚窗口结束后,按独立变更删除旧 database/role/旧凭据;它们不属于 controller,禁止
通过新 Database 的 `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。
## 回滚
在新目标出现问题且旧资源仍保留时:
1. 立即停止新目标写入。
2. 评估切换后是否产生新数据;若有,先决定反向迁移或接受丢弃,不能盲目切回。
3. 将应用连接切回 retained database/role;若必须恢复原名称,先确保新受管目标已用
资源侧 `Delete` 完整清理或改用不同名称,再安全地反向执行 rename。
4. 恢复旧凭据(MD5 环境可能需要重设),验证旧服务。
5. 保留失败 Tenant 供排障;修改 Database 的 Retain/Delete 策略前明确其外部资源后果。
若已经删除旧资源,则只能使用已验证备份恢复,不再属于本 runbook 的快速回滚。
## 演练验收
发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录:
- 显式导入与 Released 重新绑定的授权、旧访问处置和失败不修改原资源;
- 使用的 PostgreSQL major version 和命令版本;
- dump/restore 返回码和对象差异;
- DNS/IP TLS 登录结果;
- ESO 投射与应用启动结果;
- 回滚演练结果;
- 哪些命令或前置检查需要修订。
完成演练前,本文不得标记为 `Verified`。
-67
View File
@@ -1,67 +0,0 @@
# 运维与故障处理
状态:设计合同,操作入口待 API 实现与隔离环境演练。日期:2026-09-24。
依据 [系统规格](specification.md),不再查询或维护 PostgreSQL registry。
## 日常检查
先看 Instance、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration,
再核对 PostgreSQL catalog、OpenBao metadata、ExternalSecret 与 Secret 投射状态。
具体 kubectl 资源名、finalizer 名称与人工确认字段在 API 实现后补齐,不提供猜测的 patch 命令。
不得把 Secret data、密码或带 Token 的请求粘贴到 issue/日志。
## 故障分类
| Reason/状态 | 首要检查 |
| --- | --- |
| InvalidSpec / ImmutableField | 请求、名称、不可变目标与只追加扩展约束 |
| DependencyUnavailable | 网络、DNS、服务状态和超时;恢复后退避重试 |
| AuthenticationFailed | 管理 Secret、TLS、OpenBao auth |
| InsufficientPrivileges | PostgreSQL/OpenBao 权限与 Kubernetes RBAC |
| InstanceNotReady | 当前目标的管理能力,不检查 registry |
| Conflict | 绑定 UID、未知同名资源、失败步骤与外部结果确定性 |
| CredentialProjectionFailed | 授权的凭据位置、Store、ESO 与目标 Secret |
| Released | 资源已保留,不代表可直接交给另一个 Tenant |
## 创建不确定或同名冲突
1. 保留 CR、绑定与安全诊断,不清空 status、不反复删除重建申请。
2. 核对确切 Instance/database/role 和凭据位置;区分已确认完成与结果不确定的操作。
3. 使用只读检查确认资源内容、使用者和权限,不通过重设密码来“验证归属”。
4. 管理员决定清理确定的残留后重试,或显式导入保留资源;涉及删除需另有明确授权。
5. 记录处理依据,再按 API 的受控入口恢复协调。
普通依赖故障可以自动继续,未知归属不得因后端恢复就自动认领。
controller 重启保留 Kubernetes 中的记录,不需要恢复第二套 registry。
## Retain 与重新绑定
Tenant 删除后 Database 及实际资源保留,进入 Released,保存旧绑定身份。
不要删除 Database 对象来“释放名称”,也不要只修改 UID 或 Ready 强行交付。
管理员先确认数据是否允许交给新使用者、旧角色是否共享、旧账号访问如何撤销或保留、
新使用者如何获得凭据,以及原 ExternalSecret/Secret 的处置。删除 Secret 不会撤销已持有密码
的 PostgreSQL 访问。完成这些处置后,才通过显式授权重新绑定;不自动回到可分配状态。
具体凭据关联与解除绑定字段尚待 API 评审,当前不能宣称已有可执行恢复命令。
## Delete 卡住
核对 Database/Instance/绑定身份、资源侧 Delete 授权及实际管理范围,修复相关依赖,
让 controller 从已确认的步骤继续。不要删除共享角色或未纳管凭据,不使用扩大范围的 CASCADE。
依赖永久丢失时列出每个可能残留的数据库、角色、凭据与投射。只有管理员接受残留与后续处置
责任后才人工移除确切对象的 finalizer。该操作不会完成清理,也不会授权新申请使用残留资源。
## 备份与恢复
分别备份 PostgreSQL 数据、Kubernetes 资源与绑定记录、OpenBao 数据及必要配置。
不再要求备份专用 registry。只复制在线磁盘不等于有效数据库备份;秘密备份必须加密并限制访问。
灾难恢复先暂停 controller,核对三者恢复点、UID、外部目标与凭据的一致性,再恢复协调。
不一致时按 Conflict 人工处理,不承诺仅凭外部同名数据库重建丢失绑定。
在隔离环境演练登录、导入、Retain、重新绑定与 Delete 后才能标记验证通过。
## 紧急停止
疑似越权删除或秘密泄漏时暂停 controller,保留 CR 与脱敏证据,限制相关管理身份权限,
在隔离环境复现并确认修复后恢复。一般依赖失败不需要停机。
-75
View File
@@ -1,75 +0,0 @@
# 安全模型
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 最后更新 | 2026-09-24 |
## 保护目标
- 应用密码只存在于 OpenBao、ESO 投射的目标 Secret 和需要使用它的进程内存中。
- controller 只能在 Database 已确认的管理范围和当前绑定/操作授权内修改资源;未知同名对象报冲突。
- namespace 租户不能越权管理 Instance、Database 导入/回收、其他 namespace 或 controller 配置。
- PostgreSQL 和 OpenBao 的网络身份使用受信 CA 验证,不因 DNS 不可用而降级 TLS。
## 信任边界
Kubernetes 管理员、OpenBao 管理员和 PostgreSQL 管理员是平台信任主体。能读取 Tenant
目标 Secret 或对应 OpenBao path 的主体等同于持有数据库账号。database owner 可以
改变自己 database 内的对象,因此 COMMENT 不能作为 controller 所有权依据。
数据库备份包含业务数据;Kubernetes 保存资源与绑定记录;OpenBao 保存应用凭据。
完整灾难恢复必须分别保护三者并核对恢复点,不依靠数据库内 registry 重建绑定。
## 凭据处理
- 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 应把 Instance 管理、Database 导入、预留、重新绑定授权和回收限制给平台管理员。
知道 Database 名称不等于有权使用;Tenant editor
不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。
## 删除保护
资源侧 Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance/Database UID、
绑定、实际目标及角色/凭据管理范围;CR 中记录了意图不等于外部对象由本系统创建。
禁止对未知对象使用 `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 只销毁 Database 已确认管理范围内且获得删除授权的资源。
- 导入检查不改密码/owner,Released 不自动授权新使用者;旧访问处理后才能重新交付。
- 导入默认 Retain;角色/凭据的管理与删除范围未明确时不得扩大操作范围。
-207
View File
@@ -1,207 +0,0 @@
# Ayatori Database 系统规格
| 项目 | 内容 |
| --- | --- |
| 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 |
| 目标 API | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-24 |
| 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) |
本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry
持久所有权、任意 status 丢失自动恢复的设计。批准设计不表示实现已完成。
未决字段不能由实现自行补成新产品约定。
## 1. 范围
在已存在的 PostgreSQL 实例上供应独立数据库、一个兼任 owner 的 login role、申请的扩展及
应用凭据;支持管理员显式导入已有数据库。Kubernetes API 管理声明与绑定,OpenBao 保存
应用凭据,ESO 向应用 namespace 投射 Secret。
不运行 PostgreSQL、VM、存储、备份或 OpenBao;不提供跨实例数据迁移、自动密码轮换、
多角色权限产品或跨系统事务。备份与数据恢复仍由管理员负责。
## 2. 资源与职责
```text
Instance
└─ Database × N 独立持久资源
└─ Tenant × 0..1 排他绑定的用户申请
```
| 资源 | 职责 | 不承担的职责 |
| --- | --- | --- |
| Instance | 登记实例、管理连接、能力与供应前置条件 | 持有租户集合、保存所有权表 |
| Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 |
| Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 |
Instance 保持 cluster-scoped,Tenant 保持 namespaced。Database 的工作名称是
PostgreSQLDatabase;scope、字段拼写和管理角色/凭据的具体边界待 API 评审。
单 database + 单 login owner 的首版使用场景不变,不新增独立 Role 或 Claim CRD。
Database 自身必须声明 `instanceRef`,手工登记时同时指定实际数据库名;无需先存在 Tenant,
即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database;
选择已有 Database 的 Tenant 从资源获取 Instance,不重复指定另一份来源。资源与实例的归属
独立于使用绑定,Tenant 删除后仍保留;修改引用不是数据库迁移。
参考 [Kubernetes PV/PVC](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 的
资源/申请分离与绑定生命周期,不复制存储调度和 CSI 协议。资源与申请的关系不是 GC 所有关系。
## 3. 身份与事实来源
- Kubernetes spec 保存声明;受保护的资源绑定记录与 status 保存身份关联、操作进度和观察。
它们通过 API 持久化,不因 controller 重启而消失。
- PostgreSQL catalog 是 database、role、grant、extension 实际状态的来源。
- OpenBao 是应用凭据的事实来源;ESO 状态和目标 Secret 存在性说明投射结果。
- Instance、Database、Tenant 以 UID 区分对象身份;namespace/name 用于定位,
同名新 UID 不继承旧绑定。数据库 OID 仅供诊断,不是永久身份或删除授权。
- 不新增 PostgreSQL 所有权表、安装身份表或 Retain 墓碑;COMMENT 也不能授权认领。
- 记录操作意图不等于外部操作成功,phase 不等于外部所有权;执行前后仍须观察实际状态。
## 4. Instance 合同
Instance 声明 host、hostaddr、port(默认 5432)、管理 database(默认 postgres)、
TLS mode(默认 verify-full)及 controller namespace 的管理 Secret 名称和字段映射。
禁止隐式 TLS 降级。凭据引用不接受自选 namespace 或 OpenBao path。
管理员维护 ExternalSecret,由 ESO 同步管理 Secret;controller 只读,不修改管理密码,
不直接从 Bao 取管理凭据。有效用户名或密码变化时释放旧连接并重验;仅 metadata 或无关
字段变化不重建。中途凭据变化必须丢弃旧观察。已有有效管理凭据时,Bao/ESO 故障本身
不撤销 Instance Ready;首次缺少有效 Secret 时不能 Ready。
Ready 要求当前目标的连接、服务器 metadata 和所需管理能力检查通过,不要求创建、
迁移或读取 registry,也不证明备份或高可用。阶段简化为 Pending → Validating → Ready,
删除进入 Deleting。实际扩展可用列表不等于安装权限;查询失败不等于不支持。
endpoint 变更由管理员负责评估,不验证物理服务器连续性,不迁移或清理旧目标;
旧观察失效。新 UID 不接管旧资源。Instance 开始受管前保存 finalizer;删除时停止新供应,
只要有引用它的 Database(包括 Released/删除中)或尚未绑定的 Tenant 就等待。
查询失败不视为无引用;无引用才解除 finalizer,不级联删除任何业务资源。
引用检查不是跨对象事务,正在删除或不存在的 Instance 不允许开始新的供应/绑定。
## 5. 动态供应与排他绑定
1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。
2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。
3. 建立带 UID 的排他预留/绑定,避免两个 Tenant 同时使用同一 Database。
4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。
5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。
绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。
每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。
绑定 API 写入采用 resourceVersion 并发控制;双向记录不原子,单边完成不得授予使用权限。
具体字段、写入顺序与重启恢复协议须在 API 切片定义,并由真实 API server 验证。
不同 Database 记录请求同一外部名称仍可能竞争,不能仅靠 Kubernetes 中的列表检查保证
PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。
不为此新增跨系统锁或 registry。管理员也不得把同一物理数据库登记成多个可绑定资源。
## 6. 显式导入
管理员创建资源声明,明确 Instance、已有数据库和允许管理的范围,构成导入授权。
初始检查只读验证存在性、owner、角色权限和扩展等是否匹配;不匹配报告清楚的差异,
不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。
未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。
Tenant 显式引用已登记资源仍需通过绑定资格与授权检查;知道资源名称不等于有权使用。
资源侧预留/授权的具体 API 和已有凭据的安全关联入口待细化,完成前不能宣称可用。
## 7. Retain、重新绑定与 Delete
回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。
Database 不得设置会让它随 Tenant 消失的 ownerReference。
### Retain
- 保留 Database 对象、外部数据以及与资源关联的角色和凭据,不自动删除或重置。
- Tenant 释放后 Database 进入 Released,保留旧绑定身份用于诊断和防止自动复用。
Released 不是 Available,不再向原申请交付新状态,也不自动分配给同名新 Tenant。
- 保留策略不要求 PostgreSQL/Bao 在线才能完成申请释放,但必须先将释放关系安全记录到
Kubernetes;API 写入失败时不能宣称释放完成。仍有在途操作时不得跳过必要协调。
- 管理员检查数据、旧账号访问与凭据后,显式授权重新绑定。保留数据的复用可以不清空数据,
但必须由管理员确认新使用者应获得这些数据及旧使用者的权限处置。
- 删除旧投射 Secret 或解除绑定不等于撤销 PostgreSQL 访问;Retain 不承诺自动撤权。
ExternalSecret/Secret 的保留与清理细节需随凭据交付协议明确。
### Delete
必须由有权限的主体在资源侧明确授权,并核对 Database 身份、绑定、实际对象和管理范围。
在相关 finalizer 保护下清理投射、阻止新登录、处理已有连接、删除 database,再按已确认的
独占管理范围清理 role 与凭据;共享或未纳管的对象不得删除,禁止扩大 CASCADE 范围。
每步回读,失败保持进度与 finalizer;确认已删除的对象可幂等跳过,未知同名对象不能继续删除。
数据库被使用时,直接删除 Database 不得绕过绑定保护。资源已释放后才按策略处理。
具体 Tenant/Database finalizer 配合与投射清理顺序须经 API 设计及故障注入验收。
## 8. 幂等、失败与人工处理
普通依赖故障退避重试。已持久确认且仍与观察一致的步骤可以幂等继续;controller 重启
不重新生成密码,不重复创建已确认资源。
外部创建成功但记录尚未保存,或超时导致结果不确定时,若不能可靠确认归属,报告
Conflict 并停止相关写入;不得仅凭名称相同、曾记录意图或字段相似自动认领。
失败恢复不承诺全部自动完成,也不实现队列模拟事务。
status 缺失不假定发生于正常重启。Instance 可重新探测能力;Database/Tenant 缺少绑定或
操作确认时不能从外部同名对象推导所有权。按冲突/灾难恢复处理,不自动重建所有权表。
人工处理必须能看到:请求与资源身份、目标 Instance/database/role、失败步骤、已确认完成
与结果不确定的操作、冲突原因、下一步核实建议。保留现场,不自动删除疑似残留或改密。
管理员核实后可清理确定的残留再重试,或走显式导入;不能通过伪造 Ready/清空 status 强行继续。
## 9. 权限、凭据与扩展
动态供应继续使用一个兼任 database owner 的 LOGIN role;应用角色不得具备 superuser、
CREATEDB、CREATEROLE 或 replication 权限。撤销 PUBLIC CONNECT,再授予目标角色;
不修改无关数据库和角色。identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`,SQL 安全引用。
请求扩展按实例实际可用集合判断,成功后只追加,不自动 DROP EXTENSION。
可用列表查询失败按依赖错误处理;实际安装仍检查权限与结果。
应用密码使用安全随机源,只写 OpenBao;已有可信凭据可复用,不因失败生成第二份密码。
新建时先安全保存并回读凭据,再创建角色;凭据写入本身结果不确定也适用人工冲突规则。
Kubernetes 应用由 ESO 投射同 namespace Secret,controller 不直接写明文 Secret。
凭据仍输出 username/password/database/host/hostaddr/port/sslmode 七键,不生成带密码 URI。
Tenant status 提供 Secret 引用与无认证信息的 OpenBao API URL。
mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenant namespace/name 固定
推导路径的规则需修订为可支持资源保留与重新绑定的定位协议,本轮不定字段或新路径格式。
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
见 [安全模型](security.md)。这些安全约束继续适用。
## 10. Conditions 与可观测性
三类资源均提供唯一的 Ready Condition 及 observedGeneration,phase 只辅助表示阶段。
Ready=True 必须有当前目标的实际验证;Database 已绑定不等于 Tenant 凭据交付已经完成。
至少区分 Reconciling、InvalidSpec、ImmutableField、DependencyUnavailable、
AuthenticationFailed、InsufficientPrivileges、InstanceNotReady、Conflict、
ProvisioningFailed、CredentialProjectionFailed。Released 应明确显示未可供重新绑定,
具体 Condition Reason 由 API 细化,不假定仅靠 phase 判断授权。
使用结构化日志、Events 和低基数失败分类 metrics;Condition 是面向使用者的主要诊断入口。
禁止在任何 CR、Event、日志、metric、trace 或测试输出中出现密码、Token、完整秘密响应。
## 11. 验收
| 场景 | 必须验证的结果 |
| --- | --- |
| Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 |
| 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 |
| 显式导入 | 无数据/密码/owner 隐式修改;错误目标及未经授权申请被拒绝 |
| 同名未知资源 | Conflict,原数据库/角色/凭据不变 |
| 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 |
| controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 |
| Retain 与 namespace/Tenant 删除 | Database 不被 GC,外部数据保留,Released 不自动复用 |
| 人工重新绑定 | 旧 UID 不继承使用权;确认数据及凭据/旧访问处置后才能交付 |
| Delete 每步中断 | finalizer 保留,可重试,不误删未知/共享/未纳管对象 |
| 依赖稍后出现/权限恢复 | 安全重试,过期观察不授权写入 |
| status/备份恢复不一致 | 不凭同名推导归属,明确人工处理范围 |
| 泄漏与权限 | canary 不出现在输出;namespace 用户不能导入/回收他人资源 |
纯规则用单元测试;schema/CEL/status/watch/resourceVersion/绑定事件链用 envtest;
真实 PostgreSQL/OpenBao 验证后端行为;GC、ESO 与完整交付用具备相应控制器的测试集群。
envtest 不运行 GC/ESO,不能据此宣称这两类验收完成。详细测试与实现差距见
[开发文档](development.md)。导入不是数据迁移,dump/restore 仍是可选路径,见
[迁移文档](migration.md)。
@@ -3,10 +3,6 @@
- 状态:Accepted - 状态:Accepted
- 日期:2026-09-20 - 日期:2026-09-20
2026-09-24 修订:[ADR-0009](0009-database-resource-and-claim.md) 已明确替代本文对 ownership
registry、任意 status 丢失自动恢复及原 Retain 合同的沿用要求。Database 合并归属、来源保护、
无旧部署兼容负担及其他仍适用的安全边界继续有效;以下保留当时迁移决策的历史背景。
## 背景 ## 背景
独立仓库 `postgresql-tenant-operator` 已经为 homelab 共享 PostgreSQL 设计了 独立仓库 `postgresql-tenant-operator` 已经为 homelab 共享 PostgreSQL 设计了
@@ -1,65 +0,0 @@
# ADR-0009:分离 Database 资源与 Tenant 申请
- 状态:Accepted(资源模型与生命周期);API 字段细节待评审
- 日期:2026-09-24
- 依据:维护者本轮设计讨论;本决定部分替代 [ADR-0008](0008-merge-postgresql-tenant-operator.md)
对 ownership registry、自动恢复与 Retain 的沿用要求,不改变 Database 合并归属。
## 问题
原模型让 Tenant 同时表示用户申请和外部资源生命周期,又用 PostgreSQL registry 在 Tenant
删除后保留所有权。它把简单的一对多关系扩展成额外持久化协议,并为“外部创建成功但 status
未写入”承诺自动认领恢复。这里并没有自动恢复所有不确定结果的产品要求;清楚报告冲突、
保留现场并允许人工处理是可接受的合同。
Kubernetes status 存储在 API/etcd 中,不是 controller 重启即丢失的内存。外部操作与 API
写入之间确实存在失败窗口,但不因此引入第二套所有权数据库或模拟跨系统事务。
## 参考与取舍
参考 Kubernetes 官方 [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/):
资源独立于申请存在,绑定排他;Retain 释放后需要人工处理;已有资源可以静态登记。
同时参考 [owner references](https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/)
与 [finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/) 的生命周期边界。
采用这些模式,不直接使用 PV/PVC 类型,不实现 CSI 协议,不引入 StorageClass、容量匹配、
调度器、通用 Claim、事件总线或额外 registry。Instance 是资源来源与管理入口,PostgreSQL
adapter 承担类似驱动的访问职责;Instance 本身不是 CSI 驱动。
## 决策
- Instance 对应多个独立 Database;每个 Database 同时最多绑定一个 Tenant,Tenant 最多绑定
一个 Database。Tenant 是用户申请,不再直接承担持久资源的全部生命周期。
- Database 是新增 Kubernetes 资源;本文采用 PostgreSQLDatabase 作为工作名称,具体字段、
scope 与短名称在 API 评审中确定,不从类比自动推导。
- 动态供应先建立资源记录;已有数据库只能由管理员显式登记导入。仅发现同名数据库不是授权。
- Database 自带 instanceRef,手工登记不依赖 Tenant。动态申请由 Tenant 选择 Instance;
引用已有 Database 的 Tenant 从资源获取 Instance,不重复声明另一份来源。
- Retain 默认保留 Database 与外部资源;Tenant 删除后资源进入 Released,保留旧绑定身份,
不自动重新分配。管理员处理数据、账号权限与凭据后,才可授权重新绑定。
- 回收策略在 Database 一侧。Delete 必须具备明确管理范围、删除授权、finalizer 和回读;
导入不隐含授权改密码、改 owner、撤权或删除。
- Kubernetes CR 保存资源身份、绑定与操作进度,PostgreSQL catalog 保存实际数据库状态;
不再维护 PostgreSQL ownership registry,不把 registry 初始化或回读作为 Instance Ready 条件。
- 普通依赖失败继续 reconcile;同名未知资源、创建结果无法确认时报告 Conflict,停止对相关
资源的危险操作,提供人工诊断。已有可靠记录支持的幂等步骤可以继续,不将每次重启都变成冲突。
- status 不承诺在任意删除后自动重建所有权。灾难恢复按备份与人工核实处理。
- Database 不受 Tenant 的级联 GC 控制;Instance 删除也不能级联删除 Database 或业务数据。
## 保留的合同与未决项
管理 Secret 来源、凭据变化后刷新连接、TLS、实际扩展观察、OpenBao 应用凭据与 ESO 投射、
最小权限和分层集成验证继续适用。单 database、单 login owner 的首版使用场景不变。
Database 对 role/凭据的具体管理边界、持久凭据定位与重新授权方式、资源 scope、绑定字段、
预留及并发绑定的 API 更新协议需在 API/实现切片前细化。旧的 Tenant namespace/name 固定
凭据路径不能未经评估直接用于跨 Tenant 重新绑定;不为填满字段表而默认授权搬迁或改密。
## 实施边界
本次只修订设计。现有 registry adapter、Instance 中的 registry 判定及相关测试是待撤换的旧实现,
不是新合同的前提;未提交的 registry inspection 不继续接入。后续按新规格撤除这些依赖,
再实现 Database 资源、绑定、导入与回收的纵向切片,不保留未部署实现的兼容层。
详见 [系统规格](../database/specification.md)、[领域模型](../database/domain-model.md)、
[API 设计状态](../database/api-reference.md)和[测试合同](../database/development.md)。
+2 -3
View File
@@ -51,10 +51,9 @@
- 用户集群只暴露 worker node,控制面完全由平台托管。 - 用户集群只暴露 worker node,控制面完全由平台托管。
- 本节记录候选实现边界,不构成路线图承诺。 - 本节记录候选实现边界,不构成路线图承诺。
## 后续 Compute 验收场景 ## 首个业务里程碑
Database 等首批资源优先落地。Compute 开始实施后,以 Laptop Rebuild Readiness 验证节点 完成 Laptop Rebuild Readiness:
生命周期与恢复能力;该场景不作为首批 Database、LoadBalancer 或 Bucket 的交付前置条件:
1. 临时节点加入。 1. 临时节点加入。
2. laptop 上的 workload 被重建、迁移或形成可执行人工任务。 2. laptop 上的 workload 被重建、迁移或形成可执行人工任务。
+2 -7
View File
@@ -3,7 +3,6 @@ module git.ddupan.top/panxiao81/ayatori
go 1.27.1 go 1.27.1
require ( require (
github.com/jackc/pgx/v5 v5.11.0
k8s.io/api v0.37.0 k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0 k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0 k8s.io/client-go v0.37.0
@@ -12,7 +11,6 @@ require (
require ( require (
cel.dev/expr v0.25.1 // indirect cel.dev/expr v0.25.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect
@@ -46,9 +44,6 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -73,14 +68,14 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+17 -20
View File
@@ -1,7 +1,7 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -91,18 +91,14 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF2
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -130,6 +126,8 @@ github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLA
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -140,7 +138,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -173,13 +170,12 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -190,12 +186,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
@@ -209,11 +205,12 @@ google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4=
+132
View File
@@ -0,0 +1,132 @@
package kubernetes
import (
"fmt"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
ControllerName = "execution.ayatori.ddupan.top/kubernetes"
ReferenceType = "Job"
JobUIDLabel = "execution.ayatori.ddupan.top/job-uid"
)
var ayatoriJobGVK = schema.GroupVersionKind{
Group: executionv1alpha1.GroupVersion.Group,
Version: executionv1alpha1.GroupVersion.Version,
Kind: "Job",
}
// BuildJob translates the stable execution API into the Kubernetes adapter's
// backend object. It intentionally does not accept or expose a PodSpec.
func BuildJob(
job *executionv1alpha1.Job,
parameters *executionv1alpha1.KubernetesExecutionParameters,
resources executionv1alpha1.ExecutionResourceRequirements,
) *batchv1.Job {
backoffLimit := int32(0)
controller := true
blockOwnerDeletion := true
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
return &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: job.Name,
Namespace: job.Namespace,
Labels: map[string]string{
JobUIDLabel: string(job.UID),
},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: ayatoriJobGVK.GroupVersion().String(),
Kind: ayatoriJobGVK.Kind,
Name: job.Name,
UID: job.UID,
Controller: &controller,
BlockOwnerDeletion: &blockOwnerDeletion,
}},
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoffLimit,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{JobUIDLabel: string(job.UID)}},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: parameters.Spec.ServiceAccountName,
RuntimeClassName: optionalString(parameters.Spec.RuntimeClassName),
NodeSelector: parameters.Spec.Scheduling.NodeSelector,
Tolerations: parameters.Spec.Scheduling.Tolerations,
SecurityContext: parameters.Spec.PodSecurityContext,
ImagePullSecrets: job.Spec.Task.ImagePullSecrets,
Containers: []corev1.Container{{
Name: "task",
Image: job.Spec.Task.Image,
ImagePullPolicy: parameters.Spec.ImagePullPolicy,
Command: job.Spec.Task.Command,
Args: job.Spec.Task.Args,
WorkingDir: job.Spec.Task.WorkingDir,
Env: environment(job.Spec.Task.Env),
Resources: resourceRequirements(resources),
}},
},
},
},
}
}
func ValidateOwnership(owner *executionv1alpha1.Job, backend *batchv1.Job) error {
if backend.Labels[JobUIDLabel] != string(owner.UID) {
return fmt.Errorf("backend Job %s/%s is not owned by Ayatori Job UID %s", backend.Namespace, backend.Name, owner.UID)
}
return nil
}
func environment(values []executionv1alpha1.EnvVar) []corev1.EnvVar {
result := make([]corev1.EnvVar, 0, len(values))
for _, value := range values {
env := corev1.EnvVar{Name: value.Name}
if value.Value != nil {
env.Value = *value.Value
}
if value.ValueFrom != nil {
env.ValueFrom = &corev1.EnvVarSource{
SecretKeyRef: value.ValueFrom.SecretKeyRef,
ConfigMapKeyRef: value.ValueFrom.ConfigMapKeyRef,
}
}
result = append(result, env)
}
return result
}
func resourceRequirements(resources executionv1alpha1.ExecutionResourceRequirements) corev1.ResourceRequirements {
return corev1.ResourceRequirements{
Requests: resourceList(resources.Requests),
Limits: resourceList(resources.Limits),
}
}
func resourceList(values executionv1alpha1.ResourceValues) corev1.ResourceList {
result := corev1.ResourceList{}
if values.CPU != nil {
result[corev1.ResourceCPU] = values.CPU.DeepCopy()
}
if values.Memory != nil {
result[corev1.ResourceMemory] = values.Memory.DeepCopy()
}
if len(result) == 0 {
return nil
}
return result
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
+71
View File
@@ -0,0 +1,71 @@
package kubernetes
import (
"testing"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
func TestBuildJob(t *testing.T) {
literal := "world"
cpuRequest := resource.MustParse("100m")
memoryLimit := resource.MustParse("128Mi")
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
job := &executionv1alpha1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "hello", Namespace: "ci", UID: types.UID("job-uid")},
Spec: executionv1alpha1.JobSpec{Task: executionv1alpha1.TaskSpec{
Image: "alpine:3.22", Command: []string{"echo"}, Args: []string{"hello"},
Env: []executionv1alpha1.EnvVar{
{Name: "TARGET", Value: &literal},
{Name: "TOKEN", ValueFrom: &executionv1alpha1.EnvVarSource{
//nolint:modernize // LocalObjectReference is an embedded Kubernetes API field.
SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "token"}, Key: "value"},
}},
},
}},
}
parameters := &executionv1alpha1.KubernetesExecutionParameters{Spec: executionv1alpha1.KubernetesExecutionParametersSpec{
ServiceAccountName: "runner", RuntimeClassName: "runc", ImagePullPolicy: corev1.PullIfNotPresent,
Scheduling: executionv1alpha1.KubernetesSchedulingParameters{NodeSelector: map[string]string{"role": "execution"}},
}}
resources := executionv1alpha1.ExecutionResourceRequirements{
Requests: executionv1alpha1.ResourceValues{CPU: &cpuRequest},
Limits: executionv1alpha1.ResourceValues{Memory: &memoryLimit},
}
backend := BuildJob(job, parameters, resources)
pod := backend.Spec.Template.Spec
if backend.Spec.BackoffLimit == nil || *backend.Spec.BackoffLimit != 0 {
t.Fatalf("backoffLimit = %v, want 0", backend.Spec.BackoffLimit)
}
if pod.RestartPolicy != corev1.RestartPolicyNever || pod.ServiceAccountName != "runner" {
t.Fatalf("unexpected pod execution policy: %#v", pod)
}
if pod.RuntimeClassName == nil || *pod.RuntimeClassName != "runc" {
t.Fatalf("runtimeClassName = %v, want runc", pod.RuntimeClassName)
}
container := pod.Containers[0]
if container.Resources.Requests.Cpu().Cmp(cpuRequest) != 0 || container.Resources.Limits.Memory().Cmp(memoryLimit) != 0 {
t.Fatalf("resources were not mapped: %#v", container.Resources)
}
if container.Env[1].ValueFrom == nil || container.Env[1].ValueFrom.SecretKeyRef.Name != "token" {
t.Fatalf("secret reference was not preserved: %#v", container.Env[1])
}
if backend.Labels[JobUIDLabel] != "job-uid" || backend.OwnerReferences[0].UID != job.UID {
t.Fatalf("ownership identity was not preserved: %#v", backend.ObjectMeta)
}
}
func TestValidateOwnership(t *testing.T) {
job := &executionv1alpha1.Job{}
job.UID = types.UID("expected")
backend := BuildJob(job, &executionv1alpha1.KubernetesExecutionParameters{}, executionv1alpha1.ExecutionResourceRequirements{})
backend.Labels[JobUIDLabel] = "different"
if err := ValidateOwnership(job, backend); err == nil {
t.Fatal("ValidateOwnership() succeeded for a different Job UID")
}
}
+397
View File
@@ -0,0 +1,397 @@
package controller
import (
"context"
"fmt"
"slices"
"time"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
const (
jobFinalizer = "execution.ayatori.ddupan.top/job-cleanup"
reasonResultUnknown = "ResultUnknown"
)
// JobReconciler executes Ayatori Jobs using supported adapters.
type JobReconciler struct {
client.Client
Now func() time.Time
}
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs/finalizers,verbs=update
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobclasses;kubernetesexecutionparameters,verbs=get;list;watch
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete
// +kubebuilder:rbac:groups="",resources=namespaces;serviceaccounts,verbs=get;list;watch
func (r *JobReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
job := &executionv1alpha1.Job{}
if err := r.Get(ctx, request.NamespacedName, job); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !job.DeletionTimestamp.IsZero() {
return ctrl.Result{}, r.finalize(ctx, job)
}
if isTerminal(job) {
return ctrl.Result{}, nil
}
if job.Spec.DesiredState == executionv1alpha1.JobDesiredStateCancelled {
return ctrl.Result{}, r.cancel(ctx, job)
}
if !containsString(job.Finalizers, jobFinalizer) {
job.Finalizers = append(job.Finalizers, jobFinalizer)
if err := r.Update(ctx, job); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
if job.Status.Execution != nil {
return ctrl.Result{}, r.observeExisting(ctx, job)
}
class, parameters, resources, waiting, err := r.resolve(ctx, job)
if err != nil {
return ctrl.Result{}, err
}
if waiting {
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
backend := &batchv1.Job{}
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
err = r.Get(ctx, key, backend)
if apierrors.IsNotFound(err) {
backend = kubernetesadapter.BuildJob(job, parameters, resources)
if err := r.Create(ctx, backend); err != nil {
return ctrl.Result{}, err
}
logger.Info("Created Kubernetes backend Job", "backend", key)
return ctrl.Result{}, r.markScheduled(ctx, job, class, parameters, resources, backend)
}
if err != nil {
return ctrl.Result{}, err
}
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
return ctrl.Result{}, r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionScheduled, Status: metav1.ConditionFalse,
Reason: "BackendConflict", Message: err.Error(),
})
}
if !conditionTrue(job.Status.Conditions, executionv1alpha1.JobConditionScheduled) {
return ctrl.Result{}, r.markScheduled(ctx, job, class, parameters, resources, backend)
}
return ctrl.Result{}, r.observe(ctx, job, backend)
}
func (r *JobReconciler) observeExisting(ctx context.Context, job *executionv1alpha1.Job) error {
if job.Status.Execution.Adapter != "kubernetes" {
return r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
Reason: reasonResultUnknown, Message: fmt.Sprintf("adapter %q is not available", job.Status.Execution.Adapter),
})
}
backend := &batchv1.Job{}
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
if err := r.Get(ctx, key, backend); err != nil {
if apierrors.IsNotFound(err) {
return r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
Reason: reasonResultUnknown, Message: "Kubernetes backend Job is missing",
})
}
return err
}
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
return r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
Reason: reasonResultUnknown, Message: err.Error(),
})
}
return r.observe(ctx, job, backend)
}
func (r *JobReconciler) resolve(
ctx context.Context,
job *executionv1alpha1.Job,
) (*executionv1alpha1.JobClass, *executionv1alpha1.KubernetesExecutionParameters, executionv1alpha1.ExecutionResourceRequirements, bool, error) {
if job.Spec.JobClassName == "" {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "NoDefaultJobClass", "spec.jobClassName is required in the first implementation slice")
}
class := &executionv1alpha1.JobClass{}
if err := r.Get(ctx, types.NamespacedName{Name: job.Spec.JobClassName}, class); err != nil {
if apierrors.IsNotFound(err) {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "JobClassNotFound", fmt.Sprintf("JobClass %q does not exist", job.Spec.JobClassName))
}
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
}
if class.Spec.ControllerName != kubernetesadapter.ControllerName {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "UnsupportedController", fmt.Sprintf("controller %q is not supported", class.Spec.ControllerName))
}
ref := class.Spec.ParametersRef
if ref.Group != executionv1alpha1.GroupVersion.Group || ref.Kind != "KubernetesExecutionParameters" {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "InvalidParametersReference", "JobClass must reference KubernetesExecutionParameters")
}
if allowed, err := r.namespaceAllowed(ctx, job.Namespace, class.Spec.AllowedNamespaces); err != nil {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
} else if !allowed {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "NamespaceNotAllowed", fmt.Sprintf("namespace %q is not allowed by JobClass %q", job.Namespace, class.Name))
}
parameters := &executionv1alpha1.KubernetesExecutionParameters{}
if err := r.Get(ctx, types.NamespacedName{Name: ref.Name}, parameters); err != nil {
if apierrors.IsNotFound(err) {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "ParametersNotFound", fmt.Sprintf("KubernetesExecutionParameters %q does not exist", ref.Name))
}
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
}
serviceAccount := &corev1.ServiceAccount{}
if err := r.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: parameters.Spec.ServiceAccountName}, serviceAccount); err != nil {
if apierrors.IsNotFound(err) {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "ServiceAccountNotFound", fmt.Sprintf("ServiceAccount %q does not exist", parameters.Spec.ServiceAccountName))
}
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
}
resources := applyResourceDefaults(job.Spec.Resources, class.Spec.Resources.Defaults)
if err := validateResources(resources); err != nil {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "InvalidResources", err.Error())
}
if err := r.accept(ctx, job, class, parameters, resources); err != nil {
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
}
return class, parameters, resources, false, nil
}
func (r *JobReconciler) namespaceAllowed(ctx context.Context, namespace string, selector *metav1.LabelSelector) (bool, error) {
if selector == nil {
return true, nil
}
ns := &corev1.Namespace{}
if err := r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return false, err
}
compiled, err := metav1.LabelSelectorAsSelector(selector)
if err != nil {
return false, err
}
return compiled.Matches(labels.Set(ns.Labels)), nil
}
func (r *JobReconciler) accept(ctx context.Context, job *executionv1alpha1.Job, class *executionv1alpha1.JobClass, parameters *executionv1alpha1.KubernetesExecutionParameters, resources executionv1alpha1.ExecutionResourceRequirements) error {
job.Status.ResolvedJobClass = &executionv1alpha1.ResolvedJobClassReference{
Name: class.Name, UID: class.UID, ControllerName: class.Spec.ControllerName,
ParametersRef: executionv1alpha1.ParametersReference{
Group: class.Spec.ParametersRef.Group, Kind: class.Spec.ParametersRef.Kind,
Name: parameters.Name, UID: parameters.UID,
},
}
job.Status.EffectiveResources = resources
return r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionAccepted, Status: metav1.ConditionTrue,
Reason: "Accepted", Message: fmt.Sprintf("JobClass %q accepted", class.Name),
})
}
func (r *JobReconciler) reject(ctx context.Context, job *executionv1alpha1.Job, reason, message string) error {
return r.setCondition(ctx, job, metav1.Condition{
Type: executionv1alpha1.JobConditionAccepted, Status: metav1.ConditionFalse,
Reason: reason, Message: message,
})
}
func (r *JobReconciler) markScheduled(ctx context.Context, job *executionv1alpha1.Job, class *executionv1alpha1.JobClass, parameters *executionv1alpha1.KubernetesExecutionParameters, resources executionv1alpha1.ExecutionResourceRequirements, backend *batchv1.Job) error {
job.Status.ResolvedJobClass = &executionv1alpha1.ResolvedJobClassReference{
Name: class.Name, UID: class.UID, ControllerName: class.Spec.ControllerName,
ParametersRef: executionv1alpha1.ParametersReference{Group: class.Spec.ParametersRef.Group, Kind: class.Spec.ParametersRef.Kind, Name: parameters.Name, UID: parameters.UID},
}
job.Status.EffectiveResources = resources
job.Status.Execution = &executionv1alpha1.ExecutionStatus{
Adapter: "kubernetes",
References: []executionv1alpha1.ExecutionReference{{Type: kubernetesadapter.ReferenceType, ID: string(backend.UID)}},
}
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionAccepted, metav1.ConditionTrue, "Accepted", "Job accepted"))
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionScheduled, metav1.ConditionTrue, "BackendCreated", "Kubernetes Job created"))
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown, "Pending", "Waiting for task to start"))
job.Status.ObservedGeneration = job.Generation
return r.Status().Update(ctx, job)
}
func (r *JobReconciler) observe(ctx context.Context, job *executionv1alpha1.Job, backend *batchv1.Job) error {
if job.Status.StartTime == nil && backend.Status.StartTime != nil {
job.Status.StartTime = backend.Status.StartTime.DeepCopy()
}
for _, backendCondition := range backend.Status.Conditions {
switch {
case backendCondition.Type == batchv1.JobComplete && backendCondition.Status == corev1.ConditionTrue:
completion := backend.Status.CompletionTime
if completion == nil {
now := metav1.NewTime(r.now())
completion = &now
}
job.Status.CompletionTime = completion.DeepCopy()
job.Status.Result = &executionv1alpha1.JobResult{Reason: "Completed"}
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionTrue, Reason: "Completed", Message: backendCondition.Message})
case backendCondition.Type == batchv1.JobFailed && backendCondition.Status == corev1.ConditionTrue:
completion := metav1.NewTime(r.now())
job.Status.CompletionTime = &completion
job.Status.Result = &executionv1alpha1.JobResult{Reason: "ProcessFailed"}
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionFalse, Reason: "ProcessFailed", Message: backendCondition.Message})
}
}
reason := "Pending"
message := "Waiting for task to start"
if backend.Status.StartTime != nil || backend.Status.Active > 0 {
reason = "Running"
message = "Task is running"
}
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown, Reason: reason, Message: message})
}
func (r *JobReconciler) cancel(ctx context.Context, job *executionv1alpha1.Job) error {
backend := &batchv1.Job{}
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
err := r.Get(ctx, key, backend)
if err == nil {
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
return err
}
for _, backendCondition := range backend.Status.Conditions {
if (backendCondition.Type == batchv1.JobComplete || backendCondition.Type == batchv1.JobFailed) &&
backendCondition.Status == corev1.ConditionTrue {
return r.observe(ctx, job, backend)
}
}
if err := r.Delete(ctx, backend, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
}
if !apierrors.IsNotFound(err) {
return err
}
now := metav1.NewTime(r.now())
job.Status.CompletionTime = &now
job.Status.Result = &executionv1alpha1.JobResult{Reason: "Cancelled"}
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionFalse, Reason: "Cancelled", Message: "Execution cancelled"})
}
func (r *JobReconciler) finalize(ctx context.Context, job *executionv1alpha1.Job) error {
if !containsString(job.Finalizers, jobFinalizer) {
return nil
}
backend := &batchv1.Job{}
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
if err := r.Get(ctx, key, backend); err == nil {
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
return err
}
if err := r.Delete(ctx, backend, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
} else if !apierrors.IsNotFound(err) {
return err
}
job.Finalizers = removeString(job.Finalizers, jobFinalizer)
return r.Update(ctx, job)
}
func (r *JobReconciler) setCondition(ctx context.Context, job *executionv1alpha1.Job, next metav1.Condition) error {
meta.SetStatusCondition(&job.Status.Conditions, condition(job, next.Type, next.Status, next.Reason, next.Message))
job.Status.ObservedGeneration = job.Generation
return r.Status().Update(ctx, job)
}
func condition(job *executionv1alpha1.Job, conditionType string, status metav1.ConditionStatus, reason, message string) metav1.Condition {
return metav1.Condition{Type: conditionType, Status: status, Reason: reason, Message: message, ObservedGeneration: job.Generation}
}
func conditionTrue(conditions []metav1.Condition, conditionType string) bool {
current := meta.FindStatusCondition(conditions, conditionType)
return current != nil && current.Status == metav1.ConditionTrue
}
func isTerminal(job *executionv1alpha1.Job) bool {
current := meta.FindStatusCondition(job.Status.Conditions, executionv1alpha1.JobConditionSucceeded)
return current != nil && (current.Status == metav1.ConditionTrue || current.Status == metav1.ConditionFalse)
}
func applyResourceDefaults(requested, defaults executionv1alpha1.ExecutionResourceRequirements) executionv1alpha1.ExecutionResourceRequirements {
result := requested.DeepCopy()
if result.Requests.CPU == nil && defaults.Requests.CPU != nil {
result.Requests.CPU = copyQuantity(defaults.Requests.CPU)
}
if result.Requests.Memory == nil && defaults.Requests.Memory != nil {
result.Requests.Memory = copyQuantity(defaults.Requests.Memory)
}
if result.Limits.CPU == nil && defaults.Limits.CPU != nil {
result.Limits.CPU = copyQuantity(defaults.Limits.CPU)
}
if result.Limits.Memory == nil && defaults.Limits.Memory != nil {
result.Limits.Memory = copyQuantity(defaults.Limits.Memory)
}
return *result
}
func copyQuantity(value *resource.Quantity) *resource.Quantity {
copy := value.DeepCopy()
return &copy
}
func validateResources(resources executionv1alpha1.ExecutionResourceRequirements) error {
if resources.Requests.CPU != nil && resources.Limits.CPU != nil && resources.Requests.CPU.Cmp(*resources.Limits.CPU) > 0 {
return fmt.Errorf("CPU request must not exceed limit")
}
if resources.Requests.Memory != nil && resources.Limits.Memory != nil && resources.Requests.Memory.Cmp(*resources.Limits.Memory) > 0 {
return fmt.Errorf("memory request must not exceed limit")
}
return nil
}
func containsString(values []string, target string) bool {
return slices.Contains(values, target)
}
func removeString(values []string, target string) []string {
result := values[:0]
for _, value := range values {
if value != target {
result = append(result, value)
}
}
return result
}
func (r *JobReconciler) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func (r *JobReconciler) SetupWithManager(manager ctrl.Manager) error {
return ctrl.NewControllerManagedBy(manager).
For(&executionv1alpha1.Job{}).
Owns(&batchv1.Job{}).
Named("execution-job").
Complete(r)
}
@@ -0,0 +1,202 @@
package controller
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
)
const (
integrationNamespace = "controller-integration"
integrationClass = "integration"
)
//nolint:modernize // Kubernetes API structs expose ObjectMeta through embedded TypeMeta fields.
func TestJobControllerIntegration(t *testing.T) {
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("KUBEBUILDER_ASSETS is unset; run make test to execute controller integration tests")
}
scheme := runtime.NewScheme()
for _, addToScheme := range []func(*runtime.Scheme) error{
corev1.AddToScheme,
batchv1.AddToScheme,
executionv1alpha1.AddToScheme,
} {
if err := addToScheme(scheme); err != nil {
t.Fatal(err)
}
}
crdPath, err := filepath.Abs("../../config/crd/bases")
if err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}}
config, err := environment.Start()
if err != nil {
t.Fatalf("start envtest: %v", err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Errorf("stop envtest: %v", err)
}
})
manager, err := ctrl.NewManager(config, ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: "0"},
})
if err != nil {
t.Fatal(err)
}
if err := (&JobReconciler{Client: manager.GetClient()}).SetupWithManager(manager); err != nil {
t.Fatal(err)
}
managerContext, cancelManager := context.WithCancel(context.Background())
t.Cleanup(cancelManager)
managerErrors := make(chan error, 1)
go func() {
managerErrors <- manager.Start(managerContext)
}()
if !manager.GetCache().WaitForCacheSync(managerContext) {
t.Fatal("manager cache did not synchronize")
}
directClient, err := client.New(config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
objects := []client.Object{
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: integrationNamespace, Labels: map[string]string{testLabelKey: testLabelEnabled}}},
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: testSAName, Namespace: integrationNamespace}},
&executionv1alpha1.KubernetesExecutionParameters{
ObjectMeta: metav1.ObjectMeta{Name: integrationClass},
Spec: executionv1alpha1.KubernetesExecutionParametersSpec{
ServiceAccountName: testSAName,
ImagePullPolicy: corev1.PullIfNotPresent,
},
},
&executionv1alpha1.JobClass{
ObjectMeta: metav1.ObjectMeta{Name: integrationClass},
Spec: executionv1alpha1.JobClassSpec{
ControllerName: kubernetesadapter.ControllerName,
ParametersRef: executionv1alpha1.ParametersReference{
Group: executionv1alpha1.GroupVersion.Group,
Kind: "KubernetesExecutionParameters",
Name: integrationClass,
},
AllowedNamespaces: &metav1.LabelSelector{MatchLabels: map[string]string{testLabelKey: testLabelEnabled}},
},
},
}
for _, object := range objects {
if err := directClient.Create(ctx, object); err != nil {
t.Fatalf("create %T: %v", object, err)
}
}
job := &executionv1alpha1.Job{
ObjectMeta: metav1.ObjectMeta{Name: testJobName, Namespace: integrationNamespace},
Spec: executionv1alpha1.JobSpec{
JobClassName: integrationClass,
DesiredState: executionv1alpha1.JobDesiredStateRunning,
Task: executionv1alpha1.TaskSpec{Image: "alpine:3.22", Command: []string{"true"}},
},
}
if err := directClient.Create(ctx, job); err != nil {
t.Fatal(err)
}
backend := &batchv1.Job{}
eventually(t, 10*time.Second, func() (bool, error) {
err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, backend)
return err == nil, client.IgnoreNotFound(err)
})
if backend.Labels[kubernetesadapter.JobUIDLabel] != string(job.UID) {
t.Fatalf("backend identity label = %q, want %q", backend.Labels[kubernetesadapter.JobUIDLabel], job.UID)
}
eventually(t, 10*time.Second, func() (bool, error) {
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, job); err != nil {
return false, err
}
return conditionStatus(job, executionv1alpha1.JobConditionScheduled) == metav1.ConditionTrue, nil
})
completed := metav1.Now()
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, backend); err != nil {
t.Fatal(err)
}
backend.Status.StartTime = &completed
backend.Status.CompletionTime = &completed
backend.Status.Conditions = []batchv1.JobCondition{
{Type: batchv1.JobSuccessCriteriaMet, Status: corev1.ConditionTrue, Reason: "CompletionsReached"},
{Type: batchv1.JobComplete, Status: corev1.ConditionTrue, Reason: "Completed"},
}
if err := directClient.Status().Update(ctx, backend); err != nil {
t.Fatal(err)
}
eventually(t, 10*time.Second, func() (bool, error) {
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, job); err != nil {
return false, err
}
return conditionStatus(job, executionv1alpha1.JobConditionSucceeded) == metav1.ConditionTrue, nil
})
if job.Status.StartTime == nil || job.Status.CompletionTime == nil || job.Status.Execution == nil {
t.Fatalf("controller did not persist execution status: %#v", job.Status)
}
cancelManager()
select {
case err := <-managerErrors:
if err != nil {
t.Fatalf("manager stopped with error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("manager did not stop")
}
}
func eventually(t *testing.T, timeout time.Duration, check func() (bool, error)) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
ready, err := check()
if err != nil {
t.Fatal(err)
}
if ready {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal(fmt.Errorf("condition was not met within %s", timeout))
}
func conditionStatus(job *executionv1alpha1.Job, conditionType string) metav1.ConditionStatus {
condition := meta.FindStatusCondition(job.Status.Conditions, conditionType)
if condition == nil {
return metav1.ConditionUnknown
}
return condition.Status
}
+245
View File
@@ -0,0 +1,245 @@
package controller
import (
"context"
"testing"
"time"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
const (
defaultClassName = "default"
testJobName = "hello"
testSAName = "runner"
testLabelKey = "execution"
testLabelEnabled = "enabled"
)
//nolint:modernize // controller-runtime and Kubernetes API structs expose promoted embedded fields.
func TestJobReconcilerKubernetesLifecycle(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
reconciler, kubeClient := testReconciler(t, now, validObjects()...)
request := ctrl.Request{}
request.NamespacedName = types.NamespacedName{Namespace: "ci", Name: testJobName}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatalf("add finalizer: %v", err)
}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatalf("create backend: %v", err)
}
backend := &batchv1.Job{}
if err := kubeClient.Get(ctx, request.NamespacedName, backend); err != nil {
t.Fatalf("backend Job was not created: %v", err)
}
if backend.Labels[kubernetesadapter.JobUIDLabel] != "ayatori-job-uid" {
t.Fatalf("backend UID label = %q", backend.Labels[kubernetesadapter.JobUIDLabel])
}
job := getJob(t, ctx, kubeClient, request.NamespacedName)
if !conditionIs(job, executionv1alpha1.JobConditionAccepted, metav1.ConditionTrue) ||
!conditionIs(job, executionv1alpha1.JobConditionScheduled, metav1.ConditionTrue) {
t.Fatalf("Job was not accepted and scheduled: %#v", job.Status.Conditions)
}
started := metav1.NewTime(now.Add(time.Minute))
backend.Status.StartTime = &started
backend.Status.Active = 1
if err := kubeClient.Status().Update(ctx, backend); err != nil {
t.Fatalf("set backend running: %v", err)
}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatalf("observe running backend: %v", err)
}
job = getJob(t, ctx, kubeClient, request.NamespacedName)
if job.Status.StartTime == nil || !conditionIs(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown) {
t.Fatalf("running state was not observed: %#v", job.Status)
}
completed := metav1.NewTime(now.Add(2 * time.Minute))
backend = &batchv1.Job{}
if err := kubeClient.Get(ctx, request.NamespacedName, backend); err != nil {
t.Fatal(err)
}
backend.Status.Active = 0
backend.Status.CompletionTime = &completed
backend.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue, Reason: "Completed"}}
if err := kubeClient.Status().Update(ctx, backend); err != nil {
t.Fatalf("set backend complete: %v", err)
}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatalf("observe completed backend: %v", err)
}
job = getJob(t, ctx, kubeClient, request.NamespacedName)
if !conditionIs(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionTrue) || job.Status.CompletionTime == nil {
t.Fatalf("terminal state was not observed: %#v", job.Status)
}
}
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
func TestJobReconcilerRejectsMissingClass(t *testing.T) {
ctx := context.Background()
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
job.Spec.JobClassName = "missing"
reconciler, kubeClient := testReconciler(t, time.Now(), validObjects()[0], validObjects()[1], job)
request := ctrl.Request{}
request.NamespacedName = types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatal(err)
}
result, err := reconciler.Reconcile(ctx, request)
if err != nil {
t.Fatal(err)
}
if result.RequeueAfter == 0 {
t.Fatal("missing JobClass did not schedule a retry")
}
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
accepted := meta.FindStatusCondition(stored.Status.Conditions, executionv1alpha1.JobConditionAccepted)
if accepted == nil || accepted.Status != metav1.ConditionFalse || accepted.Reason != "JobClassNotFound" {
t.Fatalf("unexpected Accepted condition: %#v", accepted)
}
}
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
func TestJobReconcilerObservesExistingExecutionWithoutJobClass(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
job.Finalizers = []string{jobFinalizer}
job.Status.Execution = &executionv1alpha1.ExecutionStatus{Adapter: "kubernetes"}
backend := kubernetesadapter.BuildJob(job, validObjects()[4].(*executionv1alpha1.KubernetesExecutionParameters), executionv1alpha1.ExecutionResourceRequirements{})
backend.Status.StartTime = &metav1.Time{Time: now}
backend.Status.Active = 1
reconciler, kubeClient := testReconciler(t, now, job, backend)
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatal(err)
}
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
if stored.Status.StartTime == nil || !conditionIs(stored, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown) {
t.Fatalf("existing execution was not observed without its JobClass: %#v", stored.Status)
}
}
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
func TestJobReconcilerCancelsBeforeScheduling(t *testing.T) {
ctx := context.Background()
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateCancelled
reconciler, kubeClient := testReconciler(t, time.Unix(1_700_000_000, 0), job)
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatal(err)
}
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
condition := meta.FindStatusCondition(stored.Status.Conditions, executionv1alpha1.JobConditionSucceeded)
if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "Cancelled" {
t.Fatalf("unexpected cancellation condition: %#v", condition)
}
if stored.Status.CompletionTime == nil {
t.Fatal("cancelled Job has no completionTime")
}
}
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
func TestJobReconcilerKeepsConfirmedSuccessDuringCancellation(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateCancelled
job.Finalizers = []string{jobFinalizer}
backend := kubernetesadapter.BuildJob(job, validObjects()[4].(*executionv1alpha1.KubernetesExecutionParameters), executionv1alpha1.ExecutionResourceRequirements{})
backend.Status.CompletionTime = &metav1.Time{Time: now}
backend.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
reconciler, kubeClient := testReconciler(t, now, job, backend)
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
if _, err := reconciler.Reconcile(ctx, request); err != nil {
t.Fatal(err)
}
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
if !conditionIs(stored, executionv1alpha1.JobConditionSucceeded, metav1.ConditionTrue) {
t.Fatalf("confirmed success was overwritten by cancellation: %#v", stored.Status.Conditions)
}
if err := kubeClient.Get(ctx, request.NamespacedName, &batchv1.Job{}); err != nil {
t.Fatalf("successful backend was deleted: %v", err)
}
}
//nolint:modernize // Kubernetes API structs expose ObjectMeta through embedded TypeMeta fields.
func validObjects() []client.Object {
return []client.Object{
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ci", Labels: map[string]string{testLabelKey: testLabelEnabled}}},
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: testSAName, Namespace: "ci"}},
&executionv1alpha1.JobClass{
ObjectMeta: metav1.ObjectMeta{Name: defaultClassName, UID: types.UID("class-uid")},
Spec: executionv1alpha1.JobClassSpec{
ControllerName: kubernetesadapter.ControllerName,
ParametersRef: executionv1alpha1.ParametersReference{Group: executionv1alpha1.GroupVersion.Group, Kind: "KubernetesExecutionParameters", Name: defaultClassName},
AllowedNamespaces: &metav1.LabelSelector{MatchLabels: map[string]string{testLabelKey: testLabelEnabled}},
},
},
&executionv1alpha1.Job{
ObjectMeta: metav1.ObjectMeta{Name: testJobName, Namespace: "ci", UID: types.UID("ayatori-job-uid")},
Spec: executionv1alpha1.JobSpec{
JobClassName: defaultClassName, DesiredState: executionv1alpha1.JobDesiredStateRunning,
Task: executionv1alpha1.TaskSpec{Image: "alpine:3.22", Command: []string{"true"}},
},
},
&executionv1alpha1.KubernetesExecutionParameters{
ObjectMeta: metav1.ObjectMeta{Name: defaultClassName, UID: types.UID("parameters-uid")},
Spec: executionv1alpha1.KubernetesExecutionParametersSpec{ServiceAccountName: testSAName, ImagePullPolicy: corev1.PullIfNotPresent},
},
}
}
func testReconciler(t *testing.T, now time.Time, objects ...client.Object) (*JobReconciler, client.Client) {
t.Helper()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := batchv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := executionv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
kubeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&executionv1alpha1.Job{}, &batchv1.Job{}).
WithObjects(objects...).
Build()
return &JobReconciler{Client: kubeClient, Now: func() time.Time { return now }}, kubeClient
}
func getJob(t *testing.T, ctx context.Context, kubeClient client.Client, key types.NamespacedName) *executionv1alpha1.Job {
t.Helper()
job := &executionv1alpha1.Job{}
if err := kubeClient.Get(ctx, key, job); err != nil {
t.Fatal(err)
}
return job
}
func conditionIs(job *executionv1alpha1.Job, conditionType string, status metav1.ConditionStatus) bool {
condition := meta.FindStatusCondition(job.Status.Conditions, conditionType)
return condition != nil && condition.Status == status
}
@@ -1,68 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package kubernetes 提供 Database 所需的 Kubernetes API 薄适配。
package kubernetes
import (
"context"
"errors"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation"
typedcore "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/rest"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
// SecretCredentials 直接读取 API server,不将 Secret 数据纳入共享 informer cache。
// namespace 在装配时固定,Instance 不能选择跨 namespace 读取。
type SecretCredentials struct {
secrets typedcore.SecretInterface
}
func NewSecretCredentials(config *rest.Config, namespace string) (*SecretCredentials, error) {
if config == nil || len(validation.IsDNS1123Label(namespace)) != 0 {
return nil, errors.New("valid controller namespace and API configuration required")
}
client, err := typedcore.NewForConfig(config)
if err != nil {
return nil, application.ErrCredentialsUnavailable
}
return &SecretCredentials{secrets: client.Secrets(namespace)}, nil
}
func (r *SecretCredentials) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
if err := ref.Validate(); err != nil {
return application.Credentials{}, application.ErrCredentialsInvalid
}
keys := ref.Values()
secret, err := r.secrets.Get(ctx, keys.Name, metav1.GetOptions{})
if err != nil {
return application.Credentials{}, application.ErrCredentialsUnavailable
}
return decode(secret, keys)
}
func decode(secret *corev1.Secret, keys instance.CredentialReferenceValues) (application.Credentials, error) {
if secret.DeletionTimestamp != nil {
return application.Credentials{}, application.ErrCredentialsUnavailable
}
return application.NewCredentials(string(secret.Data[keys.UsernameKey]), string(secret.Data[keys.PasswordKey]))
}
@@ -1,108 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package postgresql 使用 pgxpool 提供 PostgreSQL 能力的薄适配。
package postgresql
import (
"context"
"crypto/tls"
"errors"
"net"
"net/url"
"strconv"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
// Connector 不读取 Secret、不决定连接何时替换;池本身由 pgxpool 实现。
type Connector struct {
RootCert string
}
type database struct {
pool *pgxpool.Pool
}
func (*database) String() string { return "[redacted PostgreSQL database]" }
func (d *database) GoString() string { return d.String() }
func (d *database) Close() {
d.pool.Close()
}
func (c Connector) Connect(ctx context.Context, endpoint instance.Endpoint, credentials application.Credentials) (application.Database, error) {
if err := endpoint.Validate(); err != nil {
return nil, err
}
if credentials.Username() == "" || credentials.Password() == "" {
return nil, application.ErrCredentialsInvalid
}
endpointValues := endpoint.Values()
query := url.Values{
"sslmode": {string(endpointValues.TLSMode)},
"connect_timeout": {"5"},
"application_name": {"ayatori-database-management"},
}
if c.RootCert != "" {
query.Set("sslrootcert", c.RootCert)
}
connectionURL := url.URL{
Scheme: "postgresql",
Host: net.JoinHostPort(endpointValues.Host, strconv.Itoa(endpointValues.Port)),
Path: "/" + endpointValues.ManagementDatabase,
User: url.UserPassword(credentials.Username(), credentials.Password()),
RawQuery: query.Encode(),
}
config, err := pgxpool.ParseConfig(connectionURL.String())
if err != nil {
return nil, application.ErrConnection
}
// pgx 不实现 libpq hostaddr;复用其 LookupFunc 扩展点,TLS 验证身份仍采用 host。
config.ConnConfig.LookupFunc = func(context.Context, string) ([]string, error) {
return []string{endpointValues.HostAddr}, nil
}
config.ConnConfig.Fallbacks = nil
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, safeError(err, application.ErrConnection)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, safeError(err, application.ErrConnection)
}
return &database{pool: pool}, nil
}
func safeError(err, fallback error) error {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
if errors.Is(err, context.DeadlineExceeded) {
return context.DeadlineExceeded
}
var pgerr *pgconn.PgError
if errors.As(err, &pgerr) && (pgerr.Code == "28P01" || pgerr.Code == "28000") {
return application.ErrAuthentication
}
if _, ok := errors.AsType[*tls.CertificateVerificationError](err); ok {
return application.ErrAuthentication
}
return fallback
}
@@ -1,231 +0,0 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"context"
"errors"
"os/exec"
"sync"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestManagementSecretScopeAndMissingDependencyRecovery(t *testing.T) {
fixture := newCredentialFixture(t)
reference := fixture.target.Definition().AdminCredential()
fixture.createSecret(t, "unrelated")
if _, err := fixture.reader.Read(fixture.ctx, reference); !errors.Is(err, application.ErrCredentialsUnavailable) {
t.Fatal("a Secret in another namespace satisfied the reference")
}
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsUnavailable) {
t.Fatal("missing Secret did not fail closed")
}
fixture.createSecret(t, controllerNamespace)
if _, err := fixture.deniedReader.Read(fixture.ctx, reference); !errors.Is(err, application.ErrCredentialsUnavailable) {
t.Fatal("API server did not enforce Secret RBAC")
}
fixture.observeVersion(t)
fixture.updateSecret(t, func(secret *corev1.Secret) {
delete(secret.Data, "credential")
})
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsInvalid) {
t.Fatal("missing credential field reused a cached connection")
}
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["credential"] = []byte(fixturePassword)
})
fixture.observeVersion(t)
err := fixture.client.CoreV1().Secrets(controllerNamespace).Delete(fixture.ctx, secretName, metav1.DeleteOptions{})
if err != nil {
t.Fatal("cannot delete fixture Secret")
}
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsUnavailable) {
t.Fatal("deleted Secret retained access")
}
}
func TestEffectiveCredentialChangesReplaceConnection(t *testing.T) {
fixture := newCredentialFixture(t)
fixture.createSecret(t, controllerNamespace)
fixture.observeVersion(t)
originalBackend := fixture.backendIDs(t)
if originalBackend == "" {
t.Fatal("management connection not visible in PostgreSQL")
}
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Labels = map[string]string{"changed": "true"}
secret.Data["unrelated"] = []byte("ignored")
})
fixture.observeVersion(t)
if fixture.backendIDs(t) != originalBackend {
t.Fatal("metadata or unrelated fields rebuilt the connection")
}
// 先改变 Secret、暂不改变服务器密码:旧连接必须失效,新认证必须失败。
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["credential"] = []byte(rotatedPassword)
})
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
if !errors.Is(err, application.ErrAuthentication) || version != "" {
t.Fatal("old connection bypassed changed credentials")
}
fixture.queryPostgres(t, "ALTER ROLE postgres PASSWORD '"+rotatedPassword+"'")
fixture.observeVersion(t)
if fixture.backendIDs(t) == originalBackend {
t.Fatal("password rotation reused the old backend")
}
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["login"] = []byte("nonexistent")
})
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrAuthentication) {
t.Fatal("username change did not require a new authentication")
}
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["login"] = []byte(fixtureUser)
})
fixture.observeVersion(t)
}
func TestObservationDiscardsResultWhenCredentialsChange(t *testing.T) {
fixture := newCredentialFixture(t)
fixture.createSecret(t, controllerNamespace)
reads := 0
fixture.gate.beforeRead = func() {
reads++
if reads == 2 {
fixture.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["credential"] = []byte(rotatedPassword)
})
}
}
observation, err := fixture.service.ObserveMetadata(fixture.ctx, fixture.target)
if !errors.Is(err, application.ErrCredentialsChanged) {
t.Fatal("in-flight rotation was not detected")
}
if observation.Version() != "" {
t.Fatal("observation returned data obtained with stale credentials")
}
requested := instance.NewExtensionSet([]string{fixtureExtension})
if observation.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("observation returned extension support obtained with stale credentials")
}
if fixture.backendIDs(t) != "" {
t.Fatal("stale connection was retained after rotation")
}
}
func TestConnectionReleaseAndServiceRestart(t *testing.T) {
fixture := newCredentialFixture(t)
fixture.createSecret(t, controllerNamespace)
fixture.observeVersion(t)
fixture.service.Forget(fixture.target.Identity().Name())
if fixture.backendIDs(t) != "" {
t.Fatal("Forget retained a connection")
}
fixture.observeVersion(t)
fixture.service.Close()
fixture.service.Close()
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrClosed) {
t.Fatal("closed service accepted work")
}
restarted, err := application.NewInstanceService(fixture.reader, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(restarted.Close)
if _, err := restarted.ObserveVersion(fixture.ctx, fixture.target); err != nil {
t.Fatal("new service could not recover from stored Secret", err)
}
// 此 fixture 未启用 TLS;各加密模式均不得偷偷回退到明文连接。
for _, mode := range []instance.TLSMode{instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull} {
securedTarget := target(t, fixture.port, mode)
if _, err := restarted.ObserveVersion(fixture.ctx, securedTarget); err == nil {
t.Fatal("TLS policy silently downgraded to plaintext")
}
}
}
func TestConcurrentVersionObservations(t *testing.T) {
fixture := newCredentialFixture(t)
fixture.createSecret(t, controllerNamespace)
var workers sync.WaitGroup
for range 4 {
workers.Go(func() {
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
if err != nil || version == "" {
t.Error("concurrent observation failed", err)
}
})
}
workers.Go(func() {
fixture.service.Forget(fixture.target.Identity().Name())
})
workers.Wait()
fixture.observeVersion(t)
}
func TestManagementConnectionRecoversAfterTimeout(t *testing.T) {
fixture := newCredentialFixture(t)
fixture.createSecret(t, controllerNamespace)
fixture.observeVersion(t)
if err := exec.CommandContext(fixture.ctx, "docker", "pause", fixture.containerID).Run(); err != nil {
t.Fatal("cannot pause isolated PostgreSQL fixture")
}
// 即使断言失败,也先恢复容器,再由 fixture 按原 ID 清理。
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = exec.CommandContext(cleanupContext, "docker", "unpause", fixture.containerID).Run()
})
queryContext, cancel := context.WithTimeout(fixture.ctx, 500*time.Millisecond)
version, err := fixture.service.ObserveVersion(queryContext, fixture.target)
cancel()
if err == nil || version != "" {
t.Fatal("timed out PostgreSQL observation returned a successful result")
}
if err := exec.CommandContext(fixture.ctx, "docker", "unpause", fixture.containerID).Run(); err != nil {
t.Fatal("cannot resume isolated PostgreSQL fixture")
}
fixture.observeVersion(t)
}
@@ -1,333 +0,0 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"context"
"errors"
"os/exec"
"regexp"
"strconv"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/envtest"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const (
fixtureHost = "fixture.invalid"
fixtureUser = "postgres"
fixtureExtension = "plpgsql"
dockerExec = "exec"
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
controllerNamespace = "database-controller"
secretName = "management"
)
// fixture 不接受外部 DSN,只创建自己的临时容器并按确切 ID 清理。
func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
t.Helper()
output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::5432",
"-e", "POSTGRES_PASSWORD="+fixturePassword, fixtureImage).Output()
if err != nil {
t.Fatalf("cannot start isolated PostgreSQL fixture: %s", fixtureCommandError(err))
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("unexpected container identifier")
}
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("fixture cleanup failed")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", `{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatalf("cannot inspect fixture port: %s", fixtureCommandError(err))
}
port, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
t.Fatal("invalid fixture port")
}
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil {
select {
case <-ctx.Done():
t.Fatal("fixture startup timed out")
case <-time.After(200 * time.Millisecond):
}
}
return id, port
}
// Output 将 stderr 保存在 ExitError 中;保留诊断,但不打印命令参数和测试密码。
func fixtureCommandError(err error) string {
detail := err.Error()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
}
redactor := strings.NewReplacer(
fixturePassword, "[REDACTED]",
rotatedPassword, "[REDACTED]",
)
return redactor.Replace(detail)
}
func TestFixtureCommandErrorPreservesDiagnosticsAndRedactsPasswords(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{
name: "missing docker executable",
err: &exec.Error{Name: "docker", Err: exec.ErrNotFound},
want: "executable file not found",
},
{
name: "daemon failure from stderr",
err: &exec.ExitError{Stderr: []byte("Cannot connect to the Docker daemon")},
want: "Cannot connect to the Docker daemon",
},
{
name: "passwords in stderr",
err: &exec.ExitError{Stderr: []byte("failure: " + fixturePassword + " " + rotatedPassword)},
want: "failure: [REDACTED] [REDACTED]",
},
{
name: "password in error text",
err: errors.New("failure: " + fixturePassword),
want: "failure: [REDACTED]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detail := fixtureCommandError(tt.err)
if !strings.Contains(detail, tt.want) {
t.Fatalf("diagnostic lost expected information: %q", tt.want)
}
if strings.Contains(detail, fixturePassword) || strings.Contains(detail, rotatedPassword) {
t.Fatal("diagnostic exposed a fixture password")
}
})
}
}
func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationTarget {
t.Helper()
id, err := instance.NewIdentity("fixture-uid", "fixture")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(1)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: fixtureHost,
HostAddr: "127.0.0.1",
Port: port,
ManagementDatabase: fixtureUser,
TLSMode: mode,
})
if err != nil {
t.Fatal(err)
}
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secretName,
UsernameKey: "login",
PasswordKey: "credential",
})
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, ref)
if err != nil {
t.Fatal(err)
}
value, err := instance.NewObservationTarget(id, revision, definition)
if err != nil {
t.Fatal(err)
}
return value
}
// 在真实读取前设置屏障,确定性验证观测期间 Secret 变化;实际数据仍来自 API server。
type gatedReader struct {
application.CredentialReader
beforeRead func()
}
func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
if r.beforeRead != nil {
r.beforeRead()
}
return r.CredentialReader.Read(ctx, ref)
}
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
type credentialFixture struct {
ctx context.Context
client *kubernetes.Clientset
reader *secretadapter.SecretCredentials
deniedReader *secretadapter.SecretCredentials
gate *gatedReader
service *application.InstanceService
target instance.ObservationTarget
containerID string
port int
}
func newCredentialFixture(t *testing.T) *credentialFixture {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
environment := &envtest.Environment{}
config, err := environment.Start()
if err != nil {
t.Fatal("envtest startup failed", err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Error("envtest cleanup failed", err)
}
})
client, err := kubernetes.NewForConfig(config)
if err != nil {
t.Fatal("cannot create test client")
}
for _, namespace := range []string{controllerNamespace, "unrelated"} {
_, err := client.CoreV1().Namespaces().Create(
ctx,
&corev1.Namespace{Name: namespace},
metav1.CreateOptions{},
)
if err != nil {
t.Fatal("cannot create fixture namespace")
}
}
reader, err := secretadapter.NewSecretCredentials(config, controllerNamespace)
if err != nil {
t.Fatal(err)
}
user, err := environment.AddUser(envtest.User{Name: "without-secret-access"}, config)
if err != nil {
t.Fatal(err)
}
deniedReader, err := secretadapter.NewSecretCredentials(user.Config(), controllerNamespace)
if err != nil {
t.Fatal(err)
}
containerID, port := postgresFixture(t, ctx)
gate := &gatedReader{CredentialReader: reader}
service, err := application.NewInstanceService(gate, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
return &credentialFixture{
ctx: ctx,
client: client,
reader: reader,
deniedReader: deniedReader,
gate: gate,
service: service,
target: target(t, port, instance.TLSDisable),
containerID: containerID,
port: port,
}
}
func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
t.Helper()
secret := &corev1.Secret{
Name: secretName,
Data: map[string][]byte{
"login": []byte(fixtureUser),
"credential": []byte(fixturePassword),
},
}
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
t.Fatal("cannot create fixture Secret")
}
}
func (f *credentialFixture) updateSecret(t *testing.T, change func(*corev1.Secret)) {
t.Helper()
secrets := f.client.CoreV1().Secrets(controllerNamespace)
secret, err := secrets.Get(f.ctx, secretName, metav1.GetOptions{})
if err != nil {
t.Fatal("cannot read fixture Secret")
}
change(secret)
if _, err := secrets.Update(f.ctx, secret, metav1.UpdateOptions{}); err != nil {
t.Fatal("cannot update fixture Secret")
}
}
func (f *credentialFixture) observeVersion(t *testing.T) {
t.Helper()
version, err := f.service.ObserveVersion(f.ctx, f.target)
if err != nil {
t.Fatal("version observation failed", err)
}
if version == "" {
t.Fatal("successful observation returned an empty version")
}
}
func (f *credentialFixture) queryPostgres(t *testing.T, sql string) string {
t.Helper()
output, err := exec.CommandContext(
f.ctx, "docker", dockerExec, f.containerID,
"psql", "-U", fixtureUser, "-tAc", sql,
).Output()
if err != nil {
t.Fatal("fixture SQL failed")
}
return strings.TrimSpace(string(output))
}
func (f *credentialFixture) backendIDs(t *testing.T) string {
t.Helper()
return f.queryPostgres(t, `
SELECT pid
FROM pg_stat_activity
WHERE application_name = 'ayatori-database-management'
ORDER BY pid
`)
}
@@ -1,48 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
// 使用 pg_catalog 限定名称,避免管理账号的 search_path 改变查询来源。
// 一个语句读取版本和可用列表;ARRAY 子查询在无行时返回空数组,而非 NULL。
// 不查询 pg_extension:已安装集合不能代表服务器提供的全部扩展。
const inspectMetadataStatement = `
SELECT
pg_catalog.current_setting('server_version'),
ARRAY(
SELECT name::text
FROM pg_catalog.pg_available_extensions
ORDER BY name
)`
func (d *database) InspectMetadata(ctx context.Context) (application.DatabaseMetadata, error) {
var metadata application.DatabaseMetadata
err := d.pool.QueryRow(ctx, inspectMetadataStatement).Scan(
&metadata.Version,
&metadata.AvailableExtensions,
)
if err != nil {
// 不返回部分结果,也不把查询失败转换为“成功观察到空列表”。
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
}
return metadata, nil
}
@@ -1,116 +0,0 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"errors"
"testing"
corev1 "k8s.io/api/core/v1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestMetadataObservesAvailableExtensionsWithoutInstalling(t *testing.T) {
f := newCredentialFixture(t)
f.createSecret(t, controllerNamespace)
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
t.Fatal("fixture unexpectedly has hstore installed")
}
// 提供同名遮蔽对象,验证 adapter 不依赖管理账号可修改的 search_path。
f.queryPostgres(t, "CREATE VIEW public.pg_available_extensions AS SELECT 'fake_extension'::name AS name")
f.queryPostgres(t, "ALTER ROLE postgres SET search_path = public, pg_catalog")
schemasBefore := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace")
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil {
t.Fatal(err)
}
if !observed.Target().Matches(f.target) || observed.Version() == "" {
t.Fatal("metadata was not bound to the current target")
}
requested := instance.NewExtensionSet([]string{"hstore", fixtureExtension})
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("available but uninstalled extension was omitted")
}
unsupported := instance.NewExtensionSet([]string{"fake_extension", "HSTORE"})
check := observed.Extensions().Check(unsupported)
if check.Decision != instance.ExtensionsUnsupported || len(check.Unsupported) != 2 {
t.Fatal("metadata accepted shadowed or case-normalized extension names")
}
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
t.Fatal("metadata observation installed an extension")
}
if schemas := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace"); schemas != schemasBefore {
t.Fatal("metadata observation changed database schemas")
}
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.ObserveExtensions(observed.Target(), observed.Extensions()); err != nil {
t.Fatal(err)
}
if aggregate.CheckExtensions(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("domain rejected observed extension availability")
}
if err := aggregate.RequireProvisioningReady(); err == nil {
t.Fatal("extension availability incorrectly authorized provisioning")
}
}
func TestMetadataPermissionFailureAndRecovery(t *testing.T) {
f := newCredentialFixture(t)
f.createSecret(t, controllerNamespace)
// 低权限账号也能读取可用列表;这不能证明具备 role/database/extension 管理权限。
f.queryPostgres(t, "CREATE ROLE metadata_reader LOGIN PASSWORD '"+fixturePassword+"'")
f.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["login"] = []byte("metadata_reader")
})
if flags := f.queryPostgres(t, "SELECT rolsuper, rolcreaterole, rolcreatedb FROM pg_catalog.pg_roles WHERE rolname = 'metadata_reader'"); flags != "f|f|f" {
t.Fatal("metadata reader unexpectedly has management privileges")
}
requested := instance.NewExtensionSet([]string{fixtureExtension})
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil || observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatalf("read-only account could not observe metadata: %v", err)
}
// 仅操作本测试独占容器的 catalog ACL;失败不能转换成“不支持任何扩展”。
f.queryPostgres(t, "REVOKE SELECT ON pg_catalog.pg_available_extensions FROM PUBLIC")
failed, err := f.service.ObserveMetadata(f.ctx, f.target)
if !errors.Is(err, application.ErrObservation) {
t.Fatalf("metadata permission failure was not reported safely: %v", err)
}
if failed.Version() != "" || failed.Target().Validate() == nil {
t.Fatal("permission failure returned partial metadata")
}
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("permission failure returned an observed empty set")
}
if f.backendIDs(t) != "" {
t.Fatal("failed metadata connection was retained")
}
f.queryPostgres(t, "GRANT SELECT ON pg_catalog.pg_available_extensions TO PUBLIC")
recovered, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil || recovered.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatalf("metadata observation did not recover: %v", err)
}
}
@@ -1,140 +0,0 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func fixtureCertificate(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
cert := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: fixtureHost},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{fixtureHost},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
certPath := filepath.Join(dir, "server.crt")
keyPath := filepath.Join(dir, "server.key")
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: encodedKey}), 0600); err != nil {
t.Fatal(err)
}
return certPath, keyPath
}
func TestPostgreSQLTLSHostIdentity(t *testing.T) {
const psql = "psql"
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
id, port := postgresFixture(t, ctx)
certPath, keyPath := fixtureCertificate(t)
commands := [][]string{
{"cp", certPath, id + ":/tmp/server.crt"},
{"cp", keyPath, id + ":/tmp/server.key"},
{dockerExec, "-u", "0", id, "chown", "postgres:postgres", "/tmp/server.crt", "/tmp/server.key"},
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl_cert_file='/tmp/server.crt'"},
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl_key_file='/tmp/server.key'"},
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl=on"},
{dockerExec, id, psql, "-U", fixtureUser, "-c", "SELECT pg_reload_conf()"},
}
for _, args := range commands {
if exec.CommandContext(ctx, "docker", args...).Run() != nil {
t.Fatal("TLS fixture setup failed")
}
}
credentials, err := application.NewCredentials(fixtureUser, fixturePassword)
if err != nil {
t.Fatal(err)
}
connector := postgresql.Connector{RootCert: certPath}
endpoint := target(t, port, instance.TLSVerifyFull).Definition().Endpoint()
db, err := connector.Connect(ctx, endpoint, credentials)
if err != nil {
t.Fatal("trusted DNS SAN connection failed", err)
}
if metadata, err := db.InspectMetadata(ctx); err != nil || metadata.Version == "" {
db.Close()
t.Fatal("TLS metadata read failed", err)
}
db.Close()
values := endpoint.Values()
values.Host = "127.0.0.1"
ipEndpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
db, err = connector.Connect(ctx, ipEndpoint, credentials)
if err != nil {
t.Fatal("trusted IP SAN connection failed", err)
}
db.Close()
values.Host = "wrong.invalid"
wrongEndpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if db, err := connector.Connect(ctx, wrongEndpoint, credentials); err == nil {
db.Close()
t.Fatal("wrong TLS hostname accepted")
}
otherCA, _ := fixtureCertificate(t)
if db, err := (postgresql.Connector{RootCert: otherCA}).Connect(ctx, endpoint, credentials); err == nil {
db.Close()
t.Fatal("wrong CA accepted")
}
}
@@ -1,58 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package application 定义 Database 用例与适配器之间的边界。
package application
import (
"context"
"errors"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var (
ErrCredentialsUnavailable = errors.New("management credentials unavailable")
ErrCredentialsInvalid = errors.New("management credentials invalid")
)
// Credentials 只存在于应用与连接适配器内存,不进入领域对象或持久化状态。
type Credentials struct {
username string
password string
}
func NewCredentials(username, password string) (Credentials, error) {
if username == "" || password == "" {
return Credentials{}, ErrCredentialsInvalid
}
return Credentials{username: username, password: password}, nil
}
func (c Credentials) Username() string { return c.username }
func (c Credentials) Password() string { return c.password }
func (c Credentials) String() string { return "[redacted management credentials]" }
func (c Credentials) GoString() string { return c.String() }
// MarshalJSON 显式隐藏内容,避免未来字段调整意外改变日志或序列化行为。
func (c Credentials) MarshalJSON() ([]byte, error) {
return []byte(`"[redacted management credentials]"`), nil
}
// CredentialReader 返回本次读取的有效值;metadata 不参与凭据相等比较。
type CredentialReader interface {
Read(context.Context, instance.CredentialReference) (Credentials, error)
}
@@ -1,70 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
const testUsername = "test-user"
func TestCredentialsRejectEmptyValues(t *testing.T) {
for _, values := range [][2]string{
{"", "test-password"},
{testUsername, ""},
{"", ""},
} {
if _, err := NewCredentials(values[0], values[1]); err != ErrCredentialsInvalid {
t.Fatal("empty credential was accepted")
}
}
}
func TestCredentialAndServiceFormattingIsRedacted(t *testing.T) {
const canary = "SECRET-CANARY-never-log-this"
credentials, err := NewCredentials(canary, canary)
if err != nil {
t.Fatal(err)
}
if credentials.Username() != canary || credentials.Password() != canary {
t.Fatal("explicit credential access changed values")
}
service, err := NewInstanceService(&sourceStub{credentials: credentials}, &connectorStub{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
encoded, err := json.Marshal(credentials)
if err != nil {
t.Fatal(err)
}
outputs := []string{
string(encoded),
fmt.Sprintf("%v %+v %#v", credentials, credentials, credentials),
fmt.Sprintf("%v %+v %#v", service, service, service),
}
for _, output := range outputs {
if strings.Contains(output, canary) {
t.Fatal("formatting leaked credential data")
}
}
}
@@ -1,185 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"context"
"errors"
"sync"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var (
ErrConnection = errors.New("management connection unavailable")
ErrAuthentication = errors.New("management authentication failed")
ErrObservation = errors.New("management observation failed")
ErrCredentialsChanged = errors.New("management credentials changed during observation")
ErrClosed = errors.New("instance service closed")
)
// Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。
// Metadata 只查询版本与可用扩展,不能产生领域 Ready。
type Database interface {
InspectMetadata(context.Context) (DatabaseMetadata, error)
Close()
}
type Connector interface {
Connect(context.Context, instance.Endpoint, Credentials) (Database, error)
}
type entry struct {
target instance.ObservationTarget
credentials Credentials
database Database
}
// InstanceService 由原 Service 迁移:连接复用与释放属于应用装配,不属于 SQL adapter。
// 保留原实现串行操作的约束,防止 Close 与查询并发;controller 停止 worker 后调用 Close。
// 不缓存能力观察,不把连接存活等同于 Ready。凭据每轮重新读取,而非只在引用变化时读取。
type InstanceService struct {
mu sync.Mutex
source CredentialReader
connector Connector
entries map[string]*entry
closed bool
}
func NewInstanceService(source CredentialReader, connector Connector) (*InstanceService, error) {
if source == nil || connector == nil {
return nil, errors.New("credential source and connector required")
}
return &InstanceService{
source: source,
connector: connector,
entries: make(map[string]*entry),
}, nil
}
func (s *InstanceService) String() string { return "[redacted instance service]" }
func (s *InstanceService) GoString() string { return s.String() }
// ObserveVersion 是完整 metadata 读取的便捷入口,不再维护另一条连接或查询路径。
func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.ObservationTarget) (string, error) {
observation, err := s.ObserveMetadata(ctx, target)
return observation.Version(), err
}
// ObserveMetadata 返回当前目标和凭据下的版本与扩展;任何失败均丢弃全部结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.ObservationTarget) (MetadataObservation, error) {
if err := target.Validate(); err != nil {
return MetadataObservation{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return MetadataObservation{}, ErrClosed
}
if err := ctx.Err(); err != nil {
return MetadataObservation{}, err
}
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
name := target.Identity().Name()
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return MetadataObservation{}, credentialError(err)
}
if credentials.username == "" || credentials.password == "" {
s.release(name)
return MetadataObservation{}, ErrCredentialsInvalid
}
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
current := s.entries[name]
if current != nil && (current.target.Identity() != target.Identity() ||
current.target.Definition() != target.Definition() || current.credentials != credentials) {
s.release(name)
current = nil
}
if current == nil {
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
if err != nil {
return MetadataObservation{}, err
}
current = &entry{
target: target,
credentials: credentials,
database: database,
}
s.entries[name] = current
}
metadata, err := current.database.InspectMetadata(ctx)
if err != nil {
s.release(name)
return MetadataObservation{}, err
}
if metadata.Version == "" {
s.release(name)
return MetadataObservation{}, ErrObservation
}
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return MetadataObservation{}, credentialError(err)
}
if latest != credentials {
s.release(name)
return MetadataObservation{}, ErrCredentialsChanged
}
return MetadataObservation{
target: target,
version: metadata.Version,
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions),
}, nil
}
func credentialError(err error) error {
if errors.Is(err, ErrCredentialsInvalid) {
return ErrCredentialsInvalid
}
return ErrCredentialsUnavailable
}
// Forget 只释放本地连接;不删除数据库,不替代 Instance finalizer。
func (s *InstanceService) Forget(name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.release(name)
}
func (s *InstanceService) release(name string) {
if current := s.entries[name]; current != nil {
current.database.Close()
}
delete(s.entries, name)
}
func (s *InstanceService) Close() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
for name := range s.entries {
s.release(name)
}
}
@@ -1,249 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"context"
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const serviceTestPassword = "test-only"
// 延续源项目 Service 测试,用于穷举身份与装配失败;真实行为由 adapter 集成测试验证。
type sourceStub struct {
credentials Credentials
err error
}
func (s *sourceStub) Read(context.Context, instance.CredentialReference) (Credentials, error) {
return s.credentials, s.err
}
type databaseStub struct {
closes int
err error
metadata DatabaseMetadata
}
func (d *databaseStub) InspectMetadata(context.Context) (DatabaseMetadata, error) {
return d.metadata, d.err
}
func (d *databaseStub) Close() {
d.closes++
}
type connectorStub struct {
databases []*databaseStub
err error
}
func (c *connectorStub) Connect(context.Context, instance.Endpoint, Credentials) (Database, error) {
if c.err != nil {
return nil, c.err
}
db := &databaseStub{
metadata: DatabaseMetadata{
Version: "17",
AvailableExtensions: []string{"plpgsql"},
},
}
c.databases = append(c.databases, db)
return db, nil
}
func serviceTarget(t *testing.T, uid, host, secret string, generation int64) instance.ObservationTarget {
t.Helper()
id, err := instance.NewIdentity(uid, "shared")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(generation)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: host,
HostAddr: "127.0.0.1",
Port: 5432,
ManagementDatabase: "postgres",
TLSMode: instance.TLSDisable,
})
if err != nil {
t.Fatal(err)
}
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secret,
UsernameKey: "user",
PasswordKey: "pass",
})
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, ref)
if err != nil {
t.Fatal(err)
}
target, err := instance.NewObservationTarget(id, revision, definition)
if err != nil {
t.Fatal(err)
}
return target
}
func TestInstanceConnectionIdentity(t *testing.T) {
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
ctx := context.Background()
cases := []struct {
name string
target instance.ObservationTarget
wantConnections int
}{
{"initial connection", serviceTarget(t, "uid-1", "first", "admin", 1), 1},
{"generation alone", serviceTarget(t, "uid-1", "first", "admin", 2), 1},
{"endpoint changed", serviceTarget(t, "uid-1", "second", "admin", 3), 2},
{"reference changed", serviceTarget(t, "uid-1", "second", "replacement", 4), 3},
{"same name with new UID", serviceTarget(t, "uid-2", "second", "replacement", 1), 4},
}
for _, testCase := range cases {
observation, err := service.ObserveMetadata(ctx, testCase.target)
if err != nil {
t.Fatal(err)
}
if !observation.Target().Matches(testCase.target) {
t.Fatalf("%s: observation was bound to a previous target", testCase.name)
}
if len(connector.databases) != testCase.wantConnections {
t.Fatalf("%s: got %d connections, want %d", testCase.name, len(connector.databases), testCase.wantConnections)
}
}
for _, db := range connector.databases[:3] {
if db.closes != 1 {
t.Fatal("replaced connection not closed exactly once")
}
}
service.Forget("shared")
service.Forget("shared")
if connector.databases[3].closes != 1 {
t.Fatal("forget did not close exactly once")
}
}
func TestMetadataObservationFreshnessAndFailure(t *testing.T) {
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
ctx := context.Background()
target := serviceTarget(t, "metadata-uid", "first", "admin", 1)
observed, err := service.ObserveMetadata(ctx, target)
if err != nil || observed.Version() != "17" || !observed.Target().Matches(target) {
t.Fatalf("metadata observation: %v", err)
}
requested := instance.NewExtensionSet([]string{"plpgsql"})
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("extension list was not observed")
}
// 连接可以复用,但每轮必须重新查询;旧观察还必须与 adapter 的可变 slice 脱离。
database := connector.databases[0]
database.metadata.AvailableExtensions[0] = "replacement"
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("adapter mutation changed a completed observation")
}
refreshed, err := service.ObserveMetadata(ctx, target)
if err != nil || refreshed.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
t.Fatalf("extension list was cached across observations: %v", err)
}
database.metadata.AvailableExtensions = nil
empty, err := service.ObserveMetadata(ctx, target)
if err != nil || empty.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
t.Fatalf("successful empty list was treated as unobserved: %v", err)
}
// 即使 adapter 附带部分数据,错误仍使整个观察失效。
database.err = ErrObservation
failed, err := service.ObserveMetadata(ctx, target)
if !errors.Is(err, ErrObservation) || failed.Version() != "" || failed.Target().Validate() == nil {
t.Fatal("failed query returned a bound observation")
}
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("failed query was interpreted as an empty extension list")
}
if _, err := service.ObserveMetadata(ctx, target); err != nil {
t.Fatalf("retry after query failure: %v", err)
}
connector.databases[1].metadata.Version = ""
if _, err := service.ObserveMetadata(ctx, target); !errors.Is(err, ErrObservation) {
t.Fatal("missing server version was accepted as complete metadata")
}
}
func TestInstanceAssemblyFailureRecovery(t *testing.T) {
ctx := context.Background()
target := serviceTarget(t, "uid-1", "first", "admin", 1)
source := &sourceStub{
credentials: Credentials{username: testUsername, password: serviceTestPassword},
err: errors.New("unsafe source error"),
}
connector := &connectorStub{err: ErrConnection}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrCredentialsUnavailable) {
t.Fatal("unsafe source error escaped")
}
source.err = nil
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrConnection) {
t.Fatal("connection failure returned evidence")
}
connector.err = nil
if _, err := service.ObserveVersion(ctx, target); err != nil {
t.Fatal(err)
}
connector.databases[0].err = ErrObservation
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrObservation) {
t.Fatal("failed query returned evidence")
}
if connector.databases[0].closes != 1 {
t.Fatal("failed connection retained")
}
if _, err := service.ObserveVersion(ctx, target); err != nil {
t.Fatal("retry failed", err)
}
service.Close()
service.Close()
if connector.databases[1].closes != 1 {
t.Fatal("shutdown did not close once")
}
if _, err := service.ObserveVersion(ctx, target); !errors.Is(err, ErrClosed) {
t.Fatal("closed service accepted work")
}
}
-40
View File
@@ -1,40 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
// DatabaseMetadata 是一次只读查询的事实,不包含管理权限或完整就绪结论。
// AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。
type DatabaseMetadata struct {
Version string
AvailableExtensions []string
}
// MetadataObservation 只在查询成功且有效凭据再次核对一致后产生。
// target 绑定本次调用,而非连接最初创建时的 generation;零值表示没有观察。
type MetadataObservation struct {
target instance.ObservationTarget
version string
extensions instance.ExtensionSupport
}
func (o MetadataObservation) Target() instance.ObservationTarget { return o.target }
func (o MetadataObservation) Version() string { return o.version }
func (o MetadataObservation) Extensions() instance.ExtensionSupport {
return o.extensions
}
@@ -1,67 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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
}
@@ -1,119 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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")
}
}
@@ -1,89 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package 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")
}
}
@@ -1,118 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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")
}
}
@@ -1,84 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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}
}
@@ -1,107 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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")
}
}
@@ -1,73 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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
}
@@ -1,102 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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")
}
}
@@ -1,128 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance
import "errors"
// Phase is a workflow checkpoint, never evidence of external resource state.
type Phase string
const (
PhasePending Phase = "Pending"
PhaseValidating Phase = "Validating"
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.
type Snapshot struct {
Phase Phase
ObservedRevision int64
Readiness Readiness
ReportedVersion string
Failure Failure
}
// Instance protects registration state and pure lifecycle transitions.
// Reconstitution does not establish live capability evidence, even for a Ready snapshot.
type Instance struct {
target ObservationTarget
snapshot Snapshot
deleting bool
extensions ExtensionSupport
evidence *CapabilityObservation
}
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, 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
i.extensions = ExtensionSupport{}
i.evidence = nil
i.snapshot.Failure = NoFailure
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
i.extensions = ExtensionSupport{}
i.evidence = nil
i.snapshot.Failure = NoFailure
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)
}
@@ -1,105 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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{testTrigram})); err != nil {
t.Fatal(err)
}
if got := value.CheckExtensions(instance.NewExtensionSet([]string{testTrigram})); 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{testTrigram})); err == nil {
t.Fatal("observation for a different target was accepted")
}
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{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{testTrigram})); 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{testTrigram})); 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{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 {
t.Fatal(err)
}
if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved {
t.Fatalf("explicit invalidation retained stale support: %v", got)
}
}
@@ -1,134 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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.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.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")
}
}
@@ -1,81 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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
}
@@ -1,165 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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")
}
}
@@ -1,162 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance
import "errors"
// Failure 只表示安全类别;驱动错误、凭据和 Condition 文案留在应用边界。
type Failure uint8
const (
NoFailure Failure = iota
ObservationIncomplete
DependencyUnavailable
AuthenticationFailed
InsufficientPrivileges
)
// CheckResult 的零值表示未观察,不能视为成功。
type CheckResult uint8
const (
CheckUnobserved CheckResult = iota
CheckPassed
CheckUnavailable
CheckAuthenticationFailed
CheckInsufficientPrivileges
)
// ManagementChecks 分别记录所需能力;SQL 探测和同轮次关联由 adapter/application 保证。
// Extensions 不代表任意扩展均可安装;具体请求仍需支持检查、执行及回读。
type ManagementChecks struct {
Connection CheckResult
Metadata CheckResult
Roles CheckResult
Databases CheckResult
Grants CheckResult
Extensions CheckResult
}
func (c ManagementChecks) failure() Failure {
for _, check := range []CheckResult{c.Connection, c.Metadata, c.Roles, c.Databases, c.Grants, c.Extensions} {
switch check {
case CheckPassed:
case CheckUnavailable:
return DependencyUnavailable
case CheckAuthenticationFailed:
return AuthenticationFailed
case CheckInsufficientPrivileges:
return InsufficientPrivileges
default:
return ObservationIncomplete
}
}
return NoFailure
}
// CapabilityObservation 是值对象,不包含连接、凭据或可变集合。
type CapabilityObservation struct {
target ObservationTarget
version string
checks ManagementChecks
}
func NewCapabilityObservation(
target ObservationTarget,
version string,
checks ManagementChecks,
) (CapabilityObservation, error) {
if err := target.Validate(); err != nil {
return CapabilityObservation{}, err
}
return CapabilityObservation{target: target, version: version, checks: checks}, nil
}
func (o CapabilityObservation) managementFailure() Failure {
if failure := o.checks.failure(); failure != NoFailure {
return failure
}
if o.version == "" {
return ObservationIncomplete
}
return NoFailure
}
func (i *Instance) acceptObservation(o CapabilityObservation, phase Phase) error {
if !i.target.Matches(o.target) {
return errors.New("capability observation target does not match instance")
}
if i.deleting || i.snapshot.Phase != phase {
return errors.New("capability observation is not allowed in current lifecycle")
}
return nil
}
func (i *Instance) fail(failure Failure) {
i.evidence = nil
i.extensions = ExtensionSupport{}
i.snapshot.Readiness = NotReady
i.snapshot.Failure = failure
i.snapshot.ObservedRevision = i.target.Revision().Value()
}
// AssessManagement 根据本轮完整能力观察完成验证,不执行外部写入。
func (i *Instance) AssessManagement(o CapabilityObservation) error {
if err := i.acceptObservation(o, PhaseValidating); err != nil {
return err
}
i.assessComplete(o)
return nil
}
func (i *Instance) assessComplete(o CapabilityObservation) {
if failure := o.managementFailure(); failure != NoFailure {
i.fail(failure)
return
}
i.snapshot = Snapshot{
Phase: PhaseReady,
ObservedRevision: i.target.Revision().Value(),
Readiness: Ready,
ReportedVersion: o.version,
}
i.evidence = &o
}
// AssessReadiness 每轮接收完整事实,失败立即撤销本轮供应能力。
func (i *Instance) AssessReadiness(o CapabilityObservation) error {
if err := i.acceptObservation(o, PhaseReady); err != nil {
return err
}
if i.snapshot.ObservedRevision != i.target.Revision().Value() {
return i.BeginValidation()
}
if o.managementFailure() != NoFailure {
i.snapshot.Phase = PhaseValidating
}
i.assessComplete(o)
return nil
}
// RequireProvisioningReady 仅检查 Instance 前置条件,不授予 Tenant 所有权或外部写入许可。
func (i *Instance) RequireProvisioningReady() error {
if i.deleting || i.evidence == nil || i.snapshot.Phase != PhaseReady || i.snapshot.Readiness != Ready ||
i.snapshot.ObservedRevision != i.target.Revision().Value() {
return errors.New("instance is not ready for provisioning")
}
return nil
}
@@ -1,266 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance_test
import (
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const testServerVersion = "17.6"
func completeChecks() instance.ManagementChecks {
return instance.ManagementChecks{
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
}
}
func capability(t *testing.T, value *instance.Instance, checks instance.ManagementChecks) instance.CapabilityObservation {
t.Helper()
o, err := instance.NewCapabilityObservation(value.Target(), testServerVersion, checks)
if err != nil {
t.Fatal(err)
}
return o
}
func readyInstance(t *testing.T) *instance.Instance {
t.Helper()
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil {
t.Fatal(err)
}
if err := i.RequireProvisioningReady(); err != nil {
t.Fatal(err)
}
return i
}
func TestReadinessRequiresCompleteReadBack(t *testing.T) {
i := lifecycleInstance(t, instance.Snapshot{}, false)
if err := i.BeginValidation(); err != nil {
t.Fatal(err)
}
if i.RequireProvisioningReady() == nil {
t.Fatal("validation intent authorized provisioning")
}
if err := i.AssessManagement(capability(t, i, instance.ManagementChecks{})); err != nil {
t.Fatal(err)
}
if snapshot := i.Snapshot(); snapshot.Phase != instance.PhaseValidating ||
snapshot.Readiness != instance.NotReady || snapshot.Failure != instance.ObservationIncomplete {
t.Fatalf("incomplete observation accepted: %+v", snapshot)
}
if i.RequireProvisioningReady() == nil {
t.Fatal("incomplete observation authorized provisioning")
}
if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil {
t.Fatal(err)
}
snapshot := i.Snapshot()
if snapshot.Phase != instance.PhaseReady || snapshot.Readiness != instance.Ready ||
snapshot.ReportedVersion != testServerVersion ||
snapshot.ObservedRevision != i.Target().Revision().Value() {
t.Fatalf("complete management observation did not establish readiness: %+v", snapshot)
}
if err := i.RequireProvisioningReady(); err != nil {
t.Fatal(err)
}
}
// 重启只恢复 checkpoint;依赖稍后恢复时必须重新取得完整事实。
func TestReadinessRecoveryAndInvalidation(t *testing.T) {
i := readyInstance(t)
restored, err := instance.Reconstitute(i.Target(), i.Snapshot(), false)
if err != nil {
t.Fatal(err)
}
if restored.RequireProvisioningReady() == nil {
t.Fatal("persisted Ready fabricated fresh evidence")
}
for range 2 {
if err := restored.AssessReadiness(capability(t, restored, completeChecks())); err != nil {
t.Fatal(err)
}
if err := restored.RequireProvisioningReady(); err != nil {
t.Fatal(err)
}
}
if err := restored.BeginValidation(); err != nil {
t.Fatal(err)
}
if restored.RequireProvisioningReady() == nil {
t.Fatal("validation retained evidence")
}
deleted, err := instance.Reconstitute(i.Target(), i.Snapshot(), true)
if err != nil {
t.Fatal(err)
}
if deleted.RequireProvisioningReady() == nil {
t.Fatal("deletion allowed provisioning")
}
if err := deleted.BeginDeletion(); err != nil {
t.Fatal(err)
}
if deleted.RequireProvisioningReady() == nil {
t.Fatal("deleting checkpoint allowed provisioning")
}
}
func TestEachManagementCheckIsRequired(t *testing.T) {
checkNames := []string{"connection", "metadata", "roles", "databases", "grants", "extensions"}
failures := []struct {
name string
result instance.CheckResult
want instance.Failure
}{
{"unobserved", instance.CheckUnobserved, instance.ObservationIncomplete},
{"unavailable", instance.CheckUnavailable, instance.DependencyUnavailable},
{"authentication", instance.CheckAuthenticationFailed, instance.AuthenticationFailed},
{"privileges", instance.CheckInsufficientPrivileges, instance.InsufficientPrivileges},
{"unknown", 255, instance.ObservationIncomplete},
}
for field, name := range checkNames {
for _, failure := range failures {
for _, phase := range []instance.Phase{instance.PhaseValidating, instance.PhaseReady} {
t.Run(name+"/"+failure.name+"/"+string(phase), func(t *testing.T) {
checks := completeChecks()
fields := []*instance.CheckResult{
&checks.Connection, &checks.Metadata, &checks.Roles,
&checks.Databases, &checks.Grants, &checks.Extensions,
}
*fields[field] = failure.result
value := lifecycleInstance(t, instance.Snapshot{Phase: phase}, false)
assess := value.AssessManagement
if phase == instance.PhaseReady {
value = readyInstance(t)
assess = value.AssessReadiness
}
if err := assess(capability(t, value, checks)); err != nil {
t.Fatal(err)
}
snapshot := value.Snapshot()
if snapshot.Phase != instance.PhaseValidating ||
snapshot.Readiness != instance.NotReady ||
snapshot.Failure != failure.want ||
snapshot.ObservedRevision != value.Target().Revision().Value() {
t.Fatalf("incorrect failed observation: %+v", snapshot)
}
if value.RequireProvisioningReady() == nil {
t.Fatal("failed check authorized provisioning")
}
// 依赖恢复后重新验证,不保留失败或旧就绪证据。
if err := value.AssessManagement(capability(t, value, completeChecks())); err != nil {
t.Fatal(err)
}
if err := value.RequireProvisioningReady(); err != nil {
t.Fatal("dependency recovery did not restore readiness", err)
}
})
}
}
}
}
func TestReadinessMethodsRejectWrongPhaseAndDeletion(t *testing.T) {
for _, deleting := range []bool{false, true} {
for _, phase := range []instance.Phase{instance.PhasePending, instance.PhaseValidating,
instance.PhaseReady, instance.PhaseDeleting} {
for _, operation := range []struct {
phase instance.Phase
apply func(*instance.Instance, instance.CapabilityObservation) error
}{
{instance.PhaseValidating, (*instance.Instance).AssessManagement},
{instance.PhaseReady, (*instance.Instance).AssessReadiness},
} {
if !deleting && operation.phase == phase {
continue
}
i := lifecycleInstance(t, instance.Snapshot{Phase: phase}, deleting)
before := i.Snapshot()
if err := operation.apply(i, capability(t, i, completeChecks())); err == nil {
t.Fatalf("phase %s deleting=%t accepted operation for %s", phase, deleting, operation.phase)
}
if i.Snapshot() != before {
t.Fatal("rejected operation mutated snapshot")
}
}
}
}
}
func TestOldGenerationObservationDoesNotReplaceEvidence(t *testing.T) {
i := readyInstance(t)
target := i.Target()
revision, err := instance.NewRevision(target.Revision().Value() + 1)
if err != nil {
t.Fatal(err)
}
other, err := instance.NewObservationTarget(target.Identity(), revision, target.Definition())
if err != nil {
t.Fatal(err)
}
o, err := instance.NewCapabilityObservation(other, testServerVersion, completeChecks())
if err != nil {
t.Fatal(err)
}
before := i.Snapshot()
if err := i.AssessReadiness(o); err == nil || i.Snapshot() != before {
t.Fatal("mismatched generation observation was accepted")
}
if err := i.RequireProvisioningReady(); err != nil {
t.Fatal("rejected unrelated input changed previously accepted evidence", err)
}
}
func TestCapabilityInputsAndLifecycleGuards(t *testing.T) {
i := readyInstance(t)
if _, err := instance.NewCapabilityObservation(instance.ObservationTarget{}, testServerVersion,
completeChecks()); err == nil {
t.Fatal("invalid target accepted")
}
for _, method := range []func(instance.CapabilityObservation) error{
i.AssessManagement, i.AssessReadiness,
} {
before := i.Snapshot()
if err := method(instance.CapabilityObservation{}); err == nil || i.Snapshot() != before {
t.Fatal("mismatched observation accepted or mutated state")
}
}
old := i.Snapshot()
old.ObservedRevision = 0
changed := lifecycleInstance(t, old, false)
if err := changed.AssessReadiness(capability(t, changed, completeChecks())); err != nil {
t.Fatal(err)
}
if s := changed.Snapshot(); s.Phase != instance.PhaseValidating || s.ObservedRevision != 0 || s.Readiness != instance.Unknown {
t.Fatalf("changed generation accepted old checkpoint: %+v", s)
}
o, err := instance.NewCapabilityObservation(i.Target(), "", completeChecks())
if err != nil {
t.Fatal(err)
}
if err := i.AssessReadiness(o); err != nil {
t.Fatal(err)
}
if i.Snapshot().Failure != instance.ObservationIncomplete {
t.Fatal("missing version accepted")
}
}