Compare commits
30
Commits
c5746130bf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae546e58e | ||
|
|
2270e013f0
|
||
|
|
a84c37d842 | ||
|
|
0c66760628
|
||
|
|
da22b6b266 | ||
|
|
e1c6eec91a
|
||
|
|
fdd43f32d1 | ||
|
|
87ec7896b5
|
||
|
|
b24b85c31f | ||
|
|
55869acfd5
|
||
|
|
ed6fd7bb85 | ||
|
|
5521d5f98d
|
||
|
|
a0f62b1e01 | ||
|
|
536d6e665f
|
||
|
|
6db3f756b2 | ||
|
|
bf7b8e4066
|
||
|
|
012efc1581 | ||
|
|
bb87316773
|
||
|
|
a0afbf992c
|
||
|
|
953878ac47
|
||
|
|
59303f5276
|
||
|
|
89d9e2316b
|
||
|
|
212ca4be99
|
||
|
|
97f66669a6
|
||
|
|
0101de3ffa
|
||
|
|
9be3f3bc14 | ||
|
|
5ff3cf7282
|
||
|
|
98cdc9c2b2
|
||
|
|
6220a917e4 | ||
|
|
9e5fbe6dfb
|
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: Run on Ubuntu
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, pod]
|
||||
steps:
|
||||
- name: Clone the code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: Run on Ubuntu
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, vm]
|
||||
steps:
|
||||
- name: Clone the code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: Run on Ubuntu
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, pod]
|
||||
steps:
|
||||
- name: Clone the code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -31,11 +31,16 @@ jobs:
|
||||
make test
|
||||
|
||||
- name: Running PostgreSQL Integration Tests
|
||||
run: make test-integration
|
||||
run: |
|
||||
make dev-up
|
||||
docker network connect postgresql-tenant-operator-dev_default "${HOSTNAME}"
|
||||
POSTGRES_TEST_DSN="postgres://postgres:postgres-dev-only@postgres:5432/postgres?sslmode=disable" make test-integration
|
||||
|
||||
- name: Clean up Integration Dependencies
|
||||
if: always()
|
||||
run: make dev-down
|
||||
run: |
|
||||
docker network disconnect postgresql-tenant-operator-dev_default "${HOSTNAME}" || true
|
||||
make dev-down
|
||||
|
||||
- name: Verify generated files and modules are current
|
||||
run: git diff --exit-code
|
||||
|
||||
+33
-2
@@ -20,6 +20,7 @@ import (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
||||
// to ensure that exec-entrypoint and run can make use of them.
|
||||
@@ -37,6 +38,7 @@ import (
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/controller"
|
||||
instanceinitializer "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
@@ -61,6 +63,10 @@ func main() {
|
||||
var probeAddr string
|
||||
var secureMetrics bool
|
||||
var enableHTTP2 bool
|
||||
var openBaoAddress, openBaoConsumerAddress, openBaoAuthMount, openBaoAuthRole string
|
||||
var openBaoKVMount, openBaoTenantBasePath, openBaoServiceAccountTokenPath string
|
||||
var externalSecretStoreName, postgreSQLCABundlePath string
|
||||
var reconcileTimeout time.Duration
|
||||
var tlsOpts []func(*tls.Config)
|
||||
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
|
||||
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
|
||||
@@ -79,6 +85,20 @@ func main() {
|
||||
flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
|
||||
flag.BoolVar(&enableHTTP2, "enable-http2", false,
|
||||
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
|
||||
flag.StringVar(&openBaoAddress, "openbao-address", "", "OpenBao API address used by the controller.")
|
||||
flag.StringVar(&openBaoConsumerAddress, "openbao-consumer-address", "", "OpenBao API address exposed to consumers.")
|
||||
flag.StringVar(&openBaoAuthMount, "openbao-auth-mount", "kubernetes", "OpenBao Kubernetes auth mount.")
|
||||
flag.StringVar(&openBaoAuthRole, "openbao-auth-role", "", "OpenBao Kubernetes auth role.")
|
||||
flag.StringVar(&openBaoKVMount, "openbao-kv-mount", "kv", "OpenBao KV v2 mount.")
|
||||
flag.StringVar(&openBaoServiceAccountTokenPath, "openbao-service-account-token-path",
|
||||
"/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
"Projected service account token used for OpenBao authentication.")
|
||||
flag.StringVar(&openBaoTenantBasePath, "openbao-tenant-base-path", "postgresql-tenants",
|
||||
"Tenant credential base path.")
|
||||
flag.StringVar(&externalSecretStoreName, "external-secret-store-name", "", "ESO ClusterSecretStore name.")
|
||||
flag.StringVar(&postgreSQLCABundlePath, "postgresql-ca-bundle-path", "", "PostgreSQL CA bundle path.")
|
||||
flag.DurationVar(&reconcileTimeout, "reconcile-timeout", 30*time.Second,
|
||||
"Deadline for external operations in one reconcile.")
|
||||
opts := zap.Options{
|
||||
Development: true,
|
||||
}
|
||||
@@ -87,6 +107,18 @@ func main() {
|
||||
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
instanceInitializer, err := instanceinitializer.New(instanceinitializer.Config{
|
||||
OpenBaoAddress: openBaoAddress, OpenBaoConsumerAddress: openBaoConsumerAddress,
|
||||
OpenBaoAuthMount: openBaoAuthMount, OpenBaoAuthRole: openBaoAuthRole,
|
||||
ServiceAccountTokenPath: openBaoServiceAccountTokenPath, OpenBaoKVMount: openBaoKVMount,
|
||||
OpenBaoTenantBasePath: openBaoTenantBasePath, ExternalSecretStoreName: externalSecretStoreName,
|
||||
PostgreSQLCABundlePath: postgreSQLCABundlePath, Timeout: reconcileTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "Invalid controller dependency configuration")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// if the enable-http2 flag is false (the default), http/2 should be disabled
|
||||
// due to its vulnerabilities. More specifically, disabling http/2 will
|
||||
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
|
||||
@@ -179,8 +211,7 @@ func main() {
|
||||
}
|
||||
|
||||
if err := (&controller.PostgreSQLInstanceReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Initializer: instanceInitializer, Timeout: reconcileTimeout,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "Failed to create controller", "controller", "postgresqlinstance")
|
||||
os.Exit(1)
|
||||
|
||||
@@ -63,6 +63,9 @@ spec:
|
||||
args:
|
||||
- --leader-elect
|
||||
- --health-probe-bind-address=:8081
|
||||
- --openbao-address=https://openbao.openbao.svc:8200
|
||||
- --openbao-auth-role=postgresql-tenant-operator
|
||||
- --external-secret-store-name=openbao
|
||||
image: controller:latest
|
||||
name: manager
|
||||
ports:
|
||||
|
||||
+24
-13
@@ -37,16 +37,22 @@ cluster-scoped,short name 为 `pginstance`。
|
||||
| `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.path` | string | 必填 | 部署级 KV mount 内的 mount-relative path |
|
||||
| `spec.adminCredentialRef.usernameKey` | string | `username` | OpenBao record 中的键名 |
|
||||
| `spec.adminCredentialRef.passwordKey` | string | `password` | OpenBao record 中的键名 |
|
||||
| `spec.allowedExtensions` | set[string] | 空集合 | 合法 extension 名称的 allowlist |
|
||||
| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 |
|
||||
| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
|
||||
| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
|
||||
|
||||
`adminCredentialRef.path` 不以 `/` 开头,不含空段、`.`、`..`,也不包含 KV v2 API 的
|
||||
`data`/`metadata` 层。它只定位既有管理凭据;controller 不创建或修改该记录。
|
||||
`adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller
|
||||
namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO
|
||||
同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订,
|
||||
现有 API types、生成 CRD 和 samples 尚未更新。
|
||||
|
||||
Instance endpoint、管理凭据引用和 allowlist 可以修改。修改后 controller 重新验证;
|
||||
删除 allowlist 项目不会自动从已有 Tenant database 删除 extension。
|
||||
Instance endpoint、管理凭据引用可以修改。修改后 controller 重新验证。
|
||||
2026-09-14 修订:v1alpha1 不实现 allowedExtensions;现有 API types、生成 CRD 和
|
||||
samples 中的字段待后续移除,不作为一个可配置但被忽略的策略保留。
|
||||
扩展请求按目标 PostgreSQL 实际可安装列表判断,可用列表由应用层查询。
|
||||
|
||||
endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置
|
||||
的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。
|
||||
|
||||
### Status
|
||||
|
||||
@@ -62,6 +68,9 @@ print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。
|
||||
Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry
|
||||
可访问且权限预检成功。它不代表数据库已经备份或高可用。
|
||||
|
||||
管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO
|
||||
暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。
|
||||
|
||||
## PostgreSQLTenant
|
||||
|
||||
namespaced,short name 为 `pgtenant`。
|
||||
@@ -73,7 +82,7 @@ namespaced,short name 为 `pgtenant`。
|
||||
| `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 |
|
||||
| `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier |
|
||||
| `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login |
|
||||
| `spec.extensions` | set[string] | 空集合 | 必须属于 Instance allowlist;成功创建后只允许追加 |
|
||||
| `spec.extensions` | set[string] | 空集合 | 必须属于目标实例实际可安装的扩展列表;成功创建后只允许追加 |
|
||||
| `spec.credential.secretName` | string | `<instance>-<name>-postgresql` | 合法的同 namespace ESO target Secret 名称 |
|
||||
| `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` |
|
||||
|
||||
@@ -142,8 +151,11 @@ phase 用于进度展示、恢复和排障。
|
||||
|
||||
- `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。
|
||||
- `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。
|
||||
- controller 不为 `PostgreSQLInstance` 级联删除 Tenant 或外部资源;存在引用时 Instance
|
||||
删除应被 finalizer 阻止,直到 Tenant 被删除或管理员使用运维逃生流程。
|
||||
- Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用
|
||||
(包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。
|
||||
不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。
|
||||
- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得
|
||||
开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -160,8 +172,7 @@ spec:
|
||||
database: postgres
|
||||
sslMode: verify-full
|
||||
adminCredentialRef:
|
||||
path: infrastructure/postgresql/shared/admin
|
||||
allowedExtensions: [pg_trgm]
|
||||
name: shared-postgresql-admin
|
||||
---
|
||||
apiVersion: database.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLTenant
|
||||
|
||||
@@ -35,8 +35,12 @@ controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 Open
|
||||
## 资源模型
|
||||
|
||||
`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的
|
||||
DNS host、IP host address、端口、管理 database、TLS 模式、OpenBao 管理凭据引用和
|
||||
extension allowlist。
|
||||
DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。
|
||||
实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。
|
||||
|
||||
管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的
|
||||
Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取
|
||||
管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。
|
||||
|
||||
`PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner
|
||||
的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV
|
||||
|
||||
+30
-20
@@ -17,7 +17,9 @@
|
||||
3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。
|
||||
4. 在 OpenBao KV v2 写入管理 role 凭据。
|
||||
5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。
|
||||
6. 在 Kubernetes 安装 ESO,创建可读取租户路径的 `ClusterSecretStore`。
|
||||
6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller
|
||||
namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的
|
||||
`ClusterSecretStore`。
|
||||
7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。
|
||||
8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。
|
||||
|
||||
@@ -25,19 +27,26 @@
|
||||
|
||||
## Controller 配置合同
|
||||
|
||||
具体 CLI flag/env 名称将在实现时按下表确定;语义和作用域已经固定:
|
||||
controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须
|
||||
在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。
|
||||
|
||||
| 配置 | 必填/默认 | 说明 |
|
||||
| CLI flag | 必填/默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| OpenBao internal API address | 必填 | controller 可访问的 HTTPS 地址 |
|
||||
| OpenBao consumer API address | 默认同 internal | 写入 Tenant status,必须能被预期外部消费者解析 |
|
||||
| OpenBao auth mount | 默认 `kubernetes` | Kubernetes auth mount 名称 |
|
||||
| OpenBao auth role | 必填 | controller ServiceAccount 对应 role |
|
||||
| OpenBao KV mount | 默认 `kv` | KV v2 mount;开发可显式用 `secret` |
|
||||
| `--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 前缀 |
|
||||
| ESO ClusterSecretStore name | 必填 | controller 创建的 ExternalSecret 固定引用 |
|
||||
| CA bundle path | 必填(TLS) | 只读 PEM trust bundle,不含私钥 |
|
||||
| reconcile timeout | 有安全默认 | 单轮外部操作的总期限 |
|
||||
| `--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 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过
|
||||
Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer
|
||||
@@ -60,7 +69,7 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空
|
||||
- 创建/修改受管 login role;
|
||||
- 创建 database 并指定 owner;
|
||||
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
|
||||
- 连接租户 database 并创建 allowlist extension;
|
||||
- 连接租户 database 并创建实例实际支持、租户申请的 extension;
|
||||
- 创建和维护 controller 专属 registry schema/table;
|
||||
- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。
|
||||
|
||||
@@ -71,13 +80,12 @@ controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成
|
||||
|
||||
## OpenBao 与 ESO
|
||||
|
||||
controller policy 分成两个范围:
|
||||
controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2
|
||||
data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。
|
||||
|
||||
- 只读 Instance 管理凭据路径;
|
||||
- 在固定 tenant base path 下 create/read/update/delete KV v2 data 和 metadata,Delete
|
||||
必须能永久删除全部版本及 metadata。
|
||||
|
||||
ESO 使用独立身份,只需读取 tenant base path;它不应读取 PostgreSQL 管理凭据。
|
||||
管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
|
||||
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
|
||||
读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。
|
||||
`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。
|
||||
controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标
|
||||
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
|
||||
@@ -90,8 +98,10 @@ Secret 包含固定七键:`username`、`password`、`database`、`host`、`hos
|
||||
对应 Secret 是否完成投射。
|
||||
- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller
|
||||
配置或其他 namespace 的 ExternalSecret。
|
||||
- controller 无需读取目标 Secret 的 data;验证登录使用从 OpenBao 读取的应用凭据,
|
||||
对 Secret 只检查存在性和 ESO 状态。
|
||||
- controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace
|
||||
的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。
|
||||
- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用
|
||||
凭据,只检查 Secret 存在性和 ESO 状态。
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
|
||||
+9
-3
@@ -118,6 +118,11 @@ make test-integration
|
||||
PostgreSQL。registry 测试会删除并重建固定的测试 schema,因此禁止将该变量指向真实
|
||||
homelab database。测试后运行 `make dev-down` 清理依赖。
|
||||
|
||||
Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问
|
||||
Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络,
|
||||
并通过 `postgres:5432` 运行集成测试;清理前先断开该网络,才能删除 Compose
|
||||
network。本机执行仍使用默认的 `127.0.0.1:15432`。
|
||||
|
||||
本机运行 controller 时,先确认当前 kubeconfig 指向专用 Kind,而不是真实 homelab:
|
||||
|
||||
```sh
|
||||
@@ -166,9 +171,10 @@ E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等
|
||||
-> 删除 Kind
|
||||
```
|
||||
|
||||
当前 controller 只实现 PostgreSQL registry adapter,尚未实现完整 PostgreSQL/OpenBao
|
||||
provisioning;现有 E2E 只能验证 CRD、manager Deployment 和 metrics endpoint。上述真实依赖 fixture 应与第一个完整
|
||||
reconcile 纵向切片一起实现,不能在文档中声称已经通过。
|
||||
当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动
|
||||
PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry
|
||||
migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需
|
||||
后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。
|
||||
|
||||
## 测试数据与泄漏检查
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Instance 领域对象规格
|
||||
|
||||
状态:Draft,含已确认决策。日期:2026-09-13。
|
||||
|
||||
上层边界见 [领域模型](domain-model.md)。本文只展开 Instance,不包含 Tenant 的供应
|
||||
实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。
|
||||
|
||||
## 1. 对象职责与生命周期
|
||||
|
||||
Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、
|
||||
根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao,
|
||||
不查询权限或初始化 registry。
|
||||
|
||||
本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。**
|
||||
应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收
|
||||
context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。
|
||||
|
||||
每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。
|
||||
管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。
|
||||
|
||||
身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或
|
||||
registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。
|
||||
新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。
|
||||
下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。
|
||||
|
||||
## 2. 字段与值对象
|
||||
|
||||
所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过
|
||||
下一轮装载新的 definition 处理,不提供任意 SetPhase/SetReady/SetEndpoint。
|
||||
|
||||
| 字段 | 类型与内容 | 来源/持久化 | 修改规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| identity | InstanceIdentity:UID、name | CR metadata | 本次对象身份内不可变;同名新 UID 是新对象 |
|
||||
| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 |
|
||||
| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 |
|
||||
| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 |
|
||||
| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 |
|
||||
| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 |
|
||||
| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 |
|
||||
| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 |
|
||||
| reportedVersion | 可选服务器版本字符串 | status.postgresqlVersion;验证后从服务器更新 | 仅供展示,不能证明连接成功 |
|
||||
| deleting | 是否已请求删除 | metadata.deletionTimestamp 映射 | 本轮不可变;优先于其他动作 |
|
||||
| evidence | 可选 CapabilityEvidence | 本轮外部回读;不新增 status 字段 | 重建时始终为空,不能从 Ready Condition 伪造 |
|
||||
|
||||
Endpoint 的构造约束沿用 API:非空 host、合法 IP、1–65535 端口、合法 PostgreSQL
|
||||
identifier、显式 TLS mode,禁止隐式降级。CredentialReference 包含合法 Secret 名称
|
||||
及非空字段名,不包含 namespace 或 Bao path;namespace 由应用层固定为 controller
|
||||
自身 namespace。这里校验领域值,不在对象里校验整个 controller 部署配置。
|
||||
|
||||
CapabilityEvidence 包含本轮目标绑定(Instance UID、revision、endpoint、凭据引用)、
|
||||
server version、管理能力检查结果、registry 观察结果。registry 结果区分
|
||||
Absent/NeedsMigration/Usable;连接失败不能当作 Absent。它不包含密码、token 或 DSN。
|
||||
|
||||
管理能力要求来自规格中的 role/database/grant/extension 操作,不等价于“能执行
|
||||
SHOW server_version”。具体权限探测矩阵需在 PostgreSQL 适配器规格中定义,不能
|
||||
让一个没有定义检查内容的布尔值承担验收。
|
||||
|
||||
不属于 Instance 的字段:Tenant 清单、客户端、连接池、token TTL、CA 文件句柄、
|
||||
Kubernetes resourceVersion。resourceVersion 留在应用层作为乐观并发保存的前提。
|
||||
|
||||
### 扩展支持判定(2026-09-14 已确认方向)
|
||||
|
||||
v1alpha1 按目标 PostgreSQL 实际可安装的扩展列表判断请求,不实现管理员 allowlist。
|
||||
allowlist 仅保留为后续可选策略,不接受一个看似生效、实际被忽略的策略字段;现有
|
||||
CRD 的 allowedExtensions 应在对应 API 改动中移除,本次只修订文档。
|
||||
|
||||
应用层查询实际可用扩展并提供与本轮目标绑定的观察;Instance 只做集合判断,不
|
||||
访问数据库。不沿用之前提议的字符正则,不自动改大小写或名称;SQL 适配器仍须
|
||||
安全引用 identifier。可用列表不是已安装列表,也不保证权限或其他安装前提满足。
|
||||
|
||||
未观察/查询失败不得当作空集合或不支持;不得用旧目标的列表授权新目标的操作。
|
||||
非空请求须属于已观察的可用集合,返回不支持的名称;空请求无需扩展支持判定,
|
||||
但不绕过 Instance 的其他就绪要求。安装后仍需回读,不能以集合匹配代替安装验证。
|
||||
列表变化不触发自动卸载;已有扩展的漂移处理留到 Tenant 用例细化。
|
||||
|
||||
## 3. 设计签名
|
||||
|
||||
```text
|
||||
Reconstitute(identity, revision, definition, checkpointSnapshot, deleting)
|
||||
-> Instance | InvalidDefinition
|
||||
|
||||
Instance.BeginValidation() -> Outcome
|
||||
Instance.AssessManagement(observation: CapabilityObservation) -> Outcome
|
||||
Instance.PlanRegistryPreparation(observation: CapabilityObservation)
|
||||
-> AlreadyUsable | PreparationAllowed | PreparationDenied
|
||||
Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome
|
||||
Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome
|
||||
Instance.CheckExtensions(requested: ExtensionSet)
|
||||
-> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved
|
||||
Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady
|
||||
Instance.BeginDeletion() -> Outcome
|
||||
Instance.Snapshot() -> InstanceSnapshot
|
||||
```
|
||||
|
||||
Outcome 是正常推进、已知失败或方法前提不成立,不包含重试秒数、Kubernetes patch
|
||||
或原始驱动错误。InstanceSnapshot 只包含 checkpoint、observedRevision、readiness、
|
||||
reportedVersion,不能序列化 evidence。快照与集合访问返回值副本。
|
||||
|
||||
CapabilityObservation 是不可变的事实输入:目标绑定、服务器版本、管理能力检查项和
|
||||
registry 观察结果;各检查项区分成功、失败、未观察,未观察不视为成功。失败只含安全
|
||||
类别,不含驱动异常或凭据。对象校验目标绑定与当前身份/配置一致,拒绝不匹配输入,
|
||||
不改变状态;完整性不足不能产生 Ready。观察结果由应用层收集,对象不能自行证明
|
||||
这些事实的真实性或实时性;采集来源、同轮次关联和并发检查由应用层保证。
|
||||
|
||||
RegistryPreparationResult 为操作失败(目标绑定、安全失败类别)或操作后的完整回读
|
||||
观察。单独的“迁移调用成功”不是就绪证据。CapabilityEvidence 是对象接受并判定满足
|
||||
要求的观察值,不是调用方传入的 Ready 布尔值。
|
||||
|
||||
### 构造与恢复
|
||||
|
||||
Reconstitute 校验期望 definition;无效输入不构造一个可参与用例决策的 Instance。
|
||||
入口把 InvalidDefinition 映射成 InvalidSpec,不必为了报告坏 CR 而制造非法领域对象。
|
||||
checkpoint 缺失或未知时保守使用 Pending;reportedVersion 和 Ready 都只是旧观察,
|
||||
evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得通过供应检查。
|
||||
|
||||
### 方法合同
|
||||
|
||||
| 方法 | 前置条件/输入 | 行为与状态变化 | 失败语义 |
|
||||
| --- | --- | --- | --- |
|
||||
| BeginValidation | 未删除;初次登记、配置变更或需重建 checkpoint | 转 Validating,readiness=Unknown,清空 evidence;不做外部 IO,不推进 observedRevision | deleting 时不启动验证 |
|
||||
| AssessManagement | 未删除;Validating;目标匹配的观察 | 判定管理访问、metadata、权限是否满足;registry 可用或可安全准备时转 InitializingRegistry,仍为 Unknown;不执行探测 | 失败保持 Validating,NotReady,observedRevision=当前版本 |
|
||||
| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision |
|
||||
| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready |
|
||||
| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 |
|
||||
| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 |
|
||||
| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 |
|
||||
| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 |
|
||||
| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 |
|
||||
|
||||
领域方法只检查对象状态,不知道 checkpoint 是否已落盘。“已持久化 checkpoint”是
|
||||
应用用例执行外部写入的前提。内存字段变成 InitializingRegistry 不代表已保存成功;不能
|
||||
在同一轮无条件接着执行迁移。通过用例测试验证此约束,而不是伪造一个内存事务。
|
||||
|
||||
AssessManagement 成功只是中间步骤,observedRevision 不前移;完成就绪判定或
|
||||
明确失败才产生相应有结论结果。旧版本字符串可供诊断,但失败会清空 evidence。
|
||||
|
||||
Instance 不在本轮暴露 CreateDatabase/DeleteDatabase:Tenant 的供应/销毁授权来自
|
||||
Tenant 和 OwnershipClaim,不是从 Instance.Ready 推导。数据库执行能力如何承接
|
||||
已授权动作,留到 Tenant 对象规格,不在这里设计第二个万能 service。
|
||||
|
||||
## 4. 应用层与外部访问边界
|
||||
|
||||
```text
|
||||
应用层依赖的适配器能力(不传入 Instance):
|
||||
InspectManagement(context, target) -> ManagementObservation | AccessFailure
|
||||
InspectRegistry(context, target) -> RegistryObservation | AccessFailure
|
||||
EnsureRegistry(context, target) -> Completed | AccessFailure
|
||||
```
|
||||
|
||||
应用层在 IO 前绑定目标并关联结果,领域对象在接受观察时检查身份和配置匹配;旧
|
||||
endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移,
|
||||
不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。
|
||||
|
||||
适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance
|
||||
管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、
|
||||
认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。
|
||||
registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。
|
||||
|
||||
管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret,
|
||||
ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。
|
||||
首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。
|
||||
检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池
|
||||
并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation
|
||||
变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和
|
||||
刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。
|
||||
controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。
|
||||
|
||||
## 5. 状态转换与初始化走查
|
||||
|
||||
```text
|
||||
Pending --BeginValidation/保存--> Validating
|
||||
Validating --AssessManagement(观察)/保存--> InitializingRegistry
|
||||
InitializingRegistry --AssessRegistryResult(回读结果)/保存--> Ready
|
||||
Ready --配置变化或访问失败/保存--> Validating
|
||||
Ready --registry 需修复/保存--> InitializingRegistry
|
||||
任意阶段 --删除请求/保存--> Deleting
|
||||
```
|
||||
|
||||
1. 入口读取 CR,装配 definition、checkpointSnapshot;客户端不注入领域对象。
|
||||
2. 应用层按 checkpoint 协调用例;首次调用 BeginValidation,没有 IO。
|
||||
3. 保存 Validating。若保存失败,结束本轮,不执行 registry 写入。
|
||||
4. 下一轮应用层调用适配器探测实例,将观察交给 AssessManagement;领域判定通过后
|
||||
保存 InitializingRegistry,保存失败则停止,不进行迁移。
|
||||
5. 再下一轮应用层采集前置观察,调用 PlanRegistryPreparation。仅在意图已持久化且
|
||||
领域允许时调用 EnsureRegistry;AlreadyUsable 则跳过写入,PreparationDenied 则
|
||||
保存失败结果并停止。允许的操作完成后回读,交给 AssessRegistryResult 决定能否
|
||||
Ready;操作失败也用安全结果交回,不在应用层直接修改 phase。
|
||||
6. 入口用原 resourceVersion 前提保存快照;并发变更导致冲突时重新装载,不覆盖新状态。
|
||||
7. 后续 Ready 检查先由应用层探测,再调用 AssessReadiness;Tenant 用例同样获取当前事实,不能
|
||||
仅凭另一个 CR 的 Ready Condition 永久缓存授权。实际资源写入仍须处理并发变化。
|
||||
|
||||
阶段调度和外部操作顺序在应用层;“观察是否满足业务要求、是否允许准备 registry、
|
||||
哪些结果算完成、失败退到哪里”在 Instance 方法内。controller 不重复这些规则,
|
||||
也不直接把 phase 设置成 Ready。领域允许操作并不锁住外部世界,适配器仍须保障幂等
|
||||
和并发安全;禁止把旧观察当成永久授权。
|
||||
|
||||
## 6. 不变量与恢复验收
|
||||
|
||||
- UID 不随名称复用;不同 UID 的 evidence/结果不可互用。
|
||||
- 未完成当前配置的能力回读,不能新产生 Ready,也不能通过供应检查。
|
||||
- checkpoint 可以落后或被伪造;每次初始化/供应前都核对事实。status 清空只需重新
|
||||
验证和幂等准备,不删除 registry,更不能重新生成 Tenant 密码。
|
||||
- 迁移成功而 status 保存失败:重试回读已存在 registry,安全完成,不重复破坏性写入。
|
||||
- registry 在 Ready 后消失:下一次回读撤销 Ready,保存修复意图后才能重新准备。
|
||||
- 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用
|
||||
已超时的 IO 上下文而丢失失败状态。
|
||||
- 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或
|
||||
Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。
|
||||
删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留
|
||||
finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于
|
||||
领域 IO,Instance 只根据删除请求禁用供应能力。
|
||||
- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被
|
||||
finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消
|
||||
已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。
|
||||
- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。
|
||||
- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。
|
||||
- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入
|
||||
得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。
|
||||
|
||||
上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试
|
||||
验证。本文为设计文档,未执行或宣称通过这些测试。
|
||||
|
||||
## 7. 本轮待评审与后续阻塞项
|
||||
|
||||
本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。
|
||||
管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时
|
||||
重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则
|
||||
均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。
|
||||
|
||||
批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。
|
||||
@@ -0,0 +1,149 @@
|
||||
# 领域模型设计草案
|
||||
|
||||
状态:Draft,含已确认决策。日期:2026-09-13。
|
||||
|
||||
本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用
|
||||
设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以
|
||||
[系统规格](specification.md) 为准;下列未决问题不能由实现自行决定。
|
||||
PR #6 的代码和已有 registry 表结构是可评估的实现素材,不反向决定领域模型。
|
||||
|
||||
## 1. 领域与统一语言
|
||||
|
||||
本系统的领域是“在共享 PostgreSQL 上供应并管理应用租户”,不是数据库服务器运维。
|
||||
v1alpha1 先采用一个限界上下文,不把 PostgreSQL、Bao、Kubernetes 各自当成业务上下文。
|
||||
|
||||
| 术语 | 含义 | 不是什么 |
|
||||
| --- | --- | --- |
|
||||
| Instance | 平台登记的外部 PostgreSQL 管理对象及其供应策略 | 连接池、VM 或 controller 单例 |
|
||||
| Tenant | 一个应用的数据库使用合同及受管资源生命周期 | PostgreSQL database 的别名 |
|
||||
| Database | 租户数据库的名称、owner、扩展等期望描述与实际观察 | 包含 Bao 登录与连接关闭的操作接口 |
|
||||
| LoginRole | 同时作为 database owner 和应用登录身份的角色 | 额外的 NOLOGIN owner |
|
||||
| OwnershipClaim | 某个 Tenant 身份对一组资源名称与凭据位置的所有权声明 | 工作流阶段或仅凭名称推断的归属 |
|
||||
| CredentialLocation | 固定推导的凭据位置及所有权关联 | 密码本身或用户可任意选择的 KV path |
|
||||
| CredentialProjection | 把既定凭据交付到目标 Secret 的要求与观察结果 | controller 直接写入明文 Secret |
|
||||
|
||||
UID 表示一次 Kubernetes 对象身份;namespace/name 用于定位,不足以证明归属。
|
||||
database OID 是诊断观察值,不充当本系统的租户身份。
|
||||
|
||||
## 2. 候选聚合边界
|
||||
|
||||
### Instance:实例能力与供应策略
|
||||
|
||||
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察,
|
||||
以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。
|
||||
|
||||
其行为包括:
|
||||
|
||||
- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。
|
||||
- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。
|
||||
- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。
|
||||
- 在 registry 初始化完成并回读验证后,接受新的就绪结果。
|
||||
|
||||
“探测实例”“准备管理 registry”是应用用例协调的外部操作,不是 Instance 的 IO 方法。
|
||||
领域对象只接收观察值,负责前提、规则和状态决策;应用层调用适配器获取事实与执行
|
||||
获准操作。领域对象不持有或调用外部访问端口、客户端或回调。具体选择见
|
||||
[Instance 字段与行为](domain-instance.md),仍处于待评审状态。
|
||||
|
||||
### Tenant:供应合同与资源生命周期
|
||||
|
||||
Tenant 是另一个候选聚合根,通过身份引用 Instance,而不是 Instance 的聚合成员。
|
||||
操作一个 Tenant 不应要求装载、锁定或保存整个实例的租户集合。
|
||||
|
||||
Tenant 持有有效的 database/role 名称、请求的扩展、凭据交付目标、删除策略,以及
|
||||
已建立的资源绑定。它负责:
|
||||
|
||||
- 检查绑定后的不可变字段、extension 只追加规则。
|
||||
- 判断外部部分状态属于本 Tenant、尚不存在,还是与未知资源冲突。
|
||||
- 决定是否允许继续供应、何时达到 Ready、是否允许释放受管资源。
|
||||
- 按 Retain/Delete 合同限制行为,禁止把保留资源自动认领给同名新 UID。
|
||||
|
||||
Database、LoginRole 和 CredentialProjection 暂不设独立聚合根或独立 CRUD 用例。
|
||||
它们可作为 Tenant 内的资源描述与观察值;有规则才增加行为,不为了“充血”添加方法。
|
||||
真实 PostgreSQL database/role 的存在不意味着内存中必须各有一个有身份的实体。
|
||||
|
||||
聚合边界是业务规则的保护边界,不表示 Tenant 对应的 PostgreSQL、Bao、ESO 资源
|
||||
能够一次事务提交。跨系统供应必须允许部分完成。
|
||||
|
||||
### OwnershipClaim:跨租户唯一性与持久证据
|
||||
|
||||
名称唯一性不可能只靠某个 Tenant 的内存检查保证。需要一项领域能力,在持久化边界
|
||||
原子认领资源;已有 registry 是其适配器候选,仍需结合 catalog 和 Bao metadata 检查。
|
||||
|
||||
Claim 与 Tenant 关联,但不随 Tenant CR 消失:Retain 后证据必须继续存在。因此不能
|
||||
把它仅视为 CR 的附属 status。是否作为独立的小聚合,先以“可独立持久化、保留并保护
|
||||
归属不变量的声明”建模;不因此引入新的 CRD。
|
||||
|
||||
- 同一身份、同一绑定的重复认领可以成功;不同 UID 或不同绑定不能覆盖。
|
||||
- Claim 预留名称不等于证明同名外部资源由本 controller 创建。
|
||||
- 实际写入仍须核对所有权,不能把先查后建当成并发安全保证。
|
||||
- 当前 registry 的数据库事务不能覆盖 Bao;跨实例的凭据路径竞争也不能靠单个
|
||||
registry 的唯一约束解决。写入前提与条件创建协议需单独设计和验收。
|
||||
|
||||
## 3. 领域、用例与适配器的分工
|
||||
|
||||
| 层 | 承担的职责 | 禁止承揽的职责 |
|
||||
| --- | --- | --- |
|
||||
| 领域对象/策略 | 身份、有效合同、归属判断、允许的动作、完成条件 | 外部 IO(包括通过接口间接调用)、解析 CLI、生成 Kubernetes Condition |
|
||||
| 应用用例 | 装载模型与事实、持久化意图、调用能力、回读、提交结果 | 另写一套绕过领域规则的判断流程 |
|
||||
| controller 入口 | CR 映射、调度、watch、重试、status/finalizer 写入 | 在 reconcile 中重新定义业务规则 |
|
||||
| 基础设施适配器 | PostgreSQL、registry、Bao、ESO 的实际读写与并发保障 | 自行决定接管、改密码或扩大删除范围 |
|
||||
| 启动装配 | 校验部署配置,创建共享客户端、连接管理器及用例依赖 | 把连接生命周期当成 Instance 的业务状态 |
|
||||
|
||||
领域可使用独立的身份、endpoint、identifier、extension 集合等值对象,不依赖 CRD
|
||||
类型、pgx pool 或 Bao SDK。Kubernetes 对象的存取与 registry 的存取不是一个通用
|
||||
`Save(Tenant)` 可以原子完成的事情;不虚构跨系统 Unit of Work。
|
||||
|
||||
暂不引入事件总线、事件溯源、通用聚合框架或全套 Repository CRUD。领域建模的依据
|
||||
是业务规则,而不是接口和目录数量。
|
||||
|
||||
## 4. 状态与恢复
|
||||
|
||||
CR `status.phase` 仍是已批准的工作流 checkpoint,不在内存对象或 registry 再建一套
|
||||
权威 phase。领域对象可以由 CR 的期望状态、checkpoint 和外部观察重新构造。
|
||||
|
||||
phase 只决定候选步骤,外部证据决定该步骤是否允许执行、是否已经完成。应用层在
|
||||
写操作前保存意图,调用幂等操作后回读,再保存下一 checkpoint。status 写入失败时,
|
||||
下次从外部事实识别完成结果;不能重发密码,也不能相信伪造的 Ready。
|
||||
|
||||
业务失败区分 InvalidSpec、ImmutableField、Conflict 等;依赖故障由适配器转换成
|
||||
安全的能力失败,应用层决定重试并映射 Condition。凭据不进入模型序列化、status、
|
||||
事件或错误明细;只能在实际需要它的执行边界短暂传递。
|
||||
|
||||
## 5. 用例走查与验收方向
|
||||
|
||||
| 场景 | 领域判定 | 应用与适配器执行/恢复 |
|
||||
| --- | --- | --- |
|
||||
| 登记 Instance | 当前配置的能力要求是否满足 | 读取管理凭据,验证连接与权限,准备并回读 registry;完成后才 Ready |
|
||||
| 供应 Tenant | Instance 策略、绑定与归属允许供应 | 保存意图,认领资源,先写并回读 Bao 凭据,再创建 role/database,登录验证和 ESO 投射 |
|
||||
| Bao 写入后进程中断 | 同一身份的部分状态可继续 | 回读原凭据继续,不生成第二份密码 |
|
||||
| 两个 Tenant 竞争名称 | 只有匹配所有权的一方可继续 | 持久化认领和条件写入裁决竞争,失败方 Conflict,不覆盖资源 |
|
||||
| Delete 中断 | 已消失资源可视为完成;剩余资源仍须归属正确 | 按规格顺序继续删除,全部回读不存在后才清 registry 和 finalizer |
|
||||
| Retain 后同名 CR 重建 | 新 UID 不等于原所有者 | Conflict,不恢复管理、不改密码 |
|
||||
|
||||
领域测试验证规则与决策;adapter 测试验证锁、条件写入、SQL 与协议行为;controller
|
||||
测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用
|
||||
就声称实现了最终一致性。
|
||||
|
||||
## 6. 决策记录与待细化边界
|
||||
|
||||
1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证
|
||||
物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置
|
||||
的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。
|
||||
2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记
|
||||
unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子
|
||||
完成两者,也不能在没有回读时声称已写入保留标记。
|
||||
3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的
|
||||
管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接
|
||||
从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready;
|
||||
首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并
|
||||
重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。
|
||||
4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后
|
||||
不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失
|
||||
后恢复;否则供应中途改名称可能产生无人管理的资源。
|
||||
|
||||
5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应,
|
||||
有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer
|
||||
与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。
|
||||
|
||||
Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。
|
||||
相关用例在决策批准前不进入实现,不同时实现整套模型。
|
||||
+2
-2
@@ -33,7 +33,7 @@ pg_dump --schema-only --no-owner --no-privileges \
|
||||
```
|
||||
|
||||
检查不受 v1alpha1 管理的对象:额外 roles、跨库依赖、FDW、large objects、订阅、显式
|
||||
tablespace、owner/grant 和不在 allowlist 的 extension。无法映射为单 database + 单 login
|
||||
tablespace、owner/grant 和目标实例不支持的 extension。无法映射为单 database + 单 login
|
||||
owner 的环境必须先人工简化,不能让 controller 猜测。
|
||||
|
||||
### 2. 创建一致性 dump
|
||||
@@ -84,7 +84,7 @@ pg_restore --exit-on-error --no-owner --no-privileges \
|
||||
```
|
||||
|
||||
extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore
|
||||
行为幂等;不在 allowlist 的 extension 必须在迁移前解决。
|
||||
行为幂等;目标实例不支持的 extension 必须在迁移前解决。
|
||||
|
||||
### 6. 验证并切换
|
||||
|
||||
|
||||
+6
-3
@@ -24,7 +24,8 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O
|
||||
## 凭据处理
|
||||
|
||||
- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。
|
||||
- 管理凭据只从 Instance 引用读取,不复制到 CR/status/Event/metric/trace。
|
||||
- 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到
|
||||
CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。
|
||||
- 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。
|
||||
- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。
|
||||
- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、
|
||||
@@ -41,8 +42,10 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O
|
||||
|
||||
## 最小权限
|
||||
|
||||
OpenBao controller identity 只能读取管理凭据范围并管理固定 tenant base path;ESO
|
||||
identity 只能读取 tenant base path。两者不得共用可访问管理凭据的 policy。
|
||||
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,并仅向
|
||||
|
||||
+49
-11
@@ -4,7 +4,7 @@
|
||||
| --- | --- |
|
||||
| 状态 | Approved |
|
||||
| 目标 API | `database.ddupan.top/v1alpha1` |
|
||||
| 最后更新 | 2026-09-10 |
|
||||
| 最后更新 | 2026-09-13 |
|
||||
| 批准日期 | 2026-09-10 |
|
||||
| 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 |
|
||||
|
||||
@@ -38,7 +38,7 @@ v1alpha1 必须实现以下目标:
|
||||
|
||||
1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。
|
||||
2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。
|
||||
3. 根据实例 allowlist 安装租户申请的 PostgreSQL extension。
|
||||
3. 根据实例实际可安装扩展列表检查并安装租户申请的 PostgreSQL extension。
|
||||
4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。
|
||||
5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。
|
||||
6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。
|
||||
@@ -75,7 +75,7 @@ v1alpha1 不负责:
|
||||
| controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 |
|
||||
| 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 |
|
||||
| Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 |
|
||||
| PostgreSQL 管理凭据 | OpenBao KV v2 | 由 `PostgreSQLInstance` 引用 |
|
||||
| PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret |
|
||||
|
||||
平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和
|
||||
PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理
|
||||
@@ -93,12 +93,27 @@ PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管
|
||||
- PostgreSQL host、port 和管理连接使用的 database;
|
||||
- PostgreSQL host address,供无法解析 DNS 的消费者使用;
|
||||
- TLS mode;
|
||||
- PostgreSQL 管理凭据在 OpenBao 中的位置和字段名;
|
||||
- 租户允许申请的 extension 集合。
|
||||
- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。
|
||||
|
||||
可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance
|
||||
中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装
|
||||
权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。
|
||||
|
||||
实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全
|
||||
建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。
|
||||
|
||||
Instance 身份和 endpoint 以管理员声明为准。修改 endpoint 不验证是否仍是原物理
|
||||
服务器或原 registry,不增加服务器/安装身份绑定检查;但旧配置观察失效,必须按
|
||||
新配置重新检查连接和管理能力。新 CR 按新 Instance 处理,不自动接管旧 UID 的租户
|
||||
资源。controller 不迁移旧服务器上的数据,也不清理旧目标,影响由管理员负责评估。
|
||||
|
||||
Instance 开始受管时即添加 finalizer,成功保存后才参与供应,不等发现 Tenant 后
|
||||
再补加。删除期间停止新供应;仍有引用它的 Tenant(包括正在删除的 Tenant)时保留
|
||||
finalizer,无引用后才移除。不级联删除 Tenant 或任何外部数据库、角色、凭据。
|
||||
引用检查失败不得当作无引用。首版不引入跨对象锁或准入控制:finalizer 不禁止同时
|
||||
创建 Tenant CR,新 Tenant 遇到正在删除或已不存在的 Instance 时不得开始供应。
|
||||
这不保证列表检查、CR 创建和在途外部操作之间的原子性;不是严格的跨对象事务。
|
||||
|
||||
### 5.2 PostgreSQLTenant
|
||||
|
||||
`PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应:
|
||||
@@ -174,8 +189,20 @@ Token 禁止写入 Deployment、CR 或镜像。
|
||||
|
||||
### 8.2 管理凭据
|
||||
|
||||
`PostgreSQLInstance` 只引用 PostgreSQL 管理用户名和密码所在的 OpenBao KV v2
|
||||
mount-relative path。controller 对该路径只需要读取权限。
|
||||
`PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及
|
||||
用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。
|
||||
平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读
|
||||
Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。
|
||||
|
||||
Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问
|
||||
PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。
|
||||
首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码
|
||||
变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关
|
||||
字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际
|
||||
故障报告,不能用旧连接的成功结果证明新凭据可用。
|
||||
controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧
|
||||
凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。
|
||||
Tenant 凭据管理仍直接依赖 Bao。
|
||||
|
||||
### 8.3 租户凭据
|
||||
|
||||
@@ -410,8 +437,9 @@ v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回
|
||||
明文连接。
|
||||
3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL
|
||||
superuser;若 extension 安装需要额外权限,必须单独记录例外。
|
||||
4. OpenBao policy 必须限制为:读取已登记的管理凭据范围,以及创建/读取本 controller
|
||||
管理的租户 KV 范围。
|
||||
4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。
|
||||
管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身
|
||||
namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。
|
||||
5. namespace 用户不得修改 cluster-scoped Instance。
|
||||
6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。
|
||||
7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。
|
||||
@@ -435,7 +463,9 @@ v1alpha1 至少必须提供:
|
||||
实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖:
|
||||
|
||||
1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。
|
||||
2. PostgreSQL 或 OpenBao 暂时不可用时 Ready=False,恢复后自动变为 Ready。
|
||||
2. PostgreSQL 管理能力不可用时 Instance Ready=False,恢复后自动变为 Ready;已有
|
||||
管理凭据可正常使用时,Bao/ESO 故障不单独影响 Instance Ready。首次装配缺少有效
|
||||
管理 Secret 时不能 Ready;Tenant 的 Bao 操作失败按其自身依赖故障报告。
|
||||
3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao
|
||||
记录。
|
||||
4. 应用凭据可以实际连接且不能创建其他 database/role。
|
||||
@@ -443,7 +473,8 @@ v1alpha1 至少必须提供:
|
||||
6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。
|
||||
7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致
|
||||
Conflict,且不修改已有资源。
|
||||
8. 未在 allowlist 的 extension 在任何外部写入前被拒绝。
|
||||
8. 目标实例实际不支持的 extension 在供应外部写入前被拒绝;扩展列表查询失败时
|
||||
按依赖故障处理,不报告为不支持。安装结果仍须回读验证。
|
||||
9. status 被清空后可以从两个外部事实来源重建。
|
||||
10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告
|
||||
Conflict。
|
||||
@@ -496,6 +527,13 @@ v1alpha1 至少必须提供:
|
||||
|
||||
## 17. 批准状态
|
||||
|
||||
2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员
|
||||
allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。
|
||||
|
||||
2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret,
|
||||
管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。
|
||||
此项是已批准行为,现有 API types 与实现尚待后续修改。
|
||||
|
||||
具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和
|
||||
实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威
|
||||
checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。
|
||||
|
||||
@@ -4,8 +4,11 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/jackc/tern/v2 v2.4.3
|
||||
github.com/onsi/ginkgo/v2 v2.27.4
|
||||
github.com/onsi/gomega v1.39.0
|
||||
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0
|
||||
github.com/openbao/openbao/api/v2 v2.7.0
|
||||
k8s.io/apimachinery v0.36.0
|
||||
k8s.io/client-go v0.36.0
|
||||
sigs.k8s.io/controller-runtime v0.24.1
|
||||
@@ -13,7 +16,10 @@ require (
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
@@ -25,6 +31,7 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-logr/zapr v1.3.0 // indirect
|
||||
@@ -32,12 +39,22 @@ require (
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/cel-go v0.26.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -45,6 +62,9 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -53,8 +73,11 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -69,17 +92,18 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -24,8 +30,12 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
@@ -36,6 +46,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -53,6 +65,10 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -72,6 +88,27 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -82,6 +119,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/tern/v2 v2.4.3 h1:g293d3OZgW7OFEhsYXgEv0C21jea2boNr0VR4K5I7OY=
|
||||
github.com/jackc/tern/v2 v2.4.3/go.mod h1:rMpMuRYcff5wWLptoTSO1qcDxJ4OodysvK17i2SVBys=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
@@ -103,8 +142,18 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -117,6 +166,10 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y
|
||||
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0 h1:Fw/pJRMpMTH83pMByCyikRHhxuBDYcnyiNSiK8OqJW0=
|
||||
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0/go.mod h1:LkXPq4+8aLyQ+qoNBHcJF7nZFx0PYt2FOu+m7sdpAXU=
|
||||
github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc=
|
||||
github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -133,10 +186,17 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp
|
||||
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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
|
||||
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -149,8 +209,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -189,28 +249,31 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
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/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
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.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
@@ -19,6 +19,7 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@@ -33,6 +34,14 @@ import (
|
||||
type PostgreSQLInstanceReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Initializer PostgreSQLInstanceInitializer
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// PostgreSQLInstanceInitializer is the external dependency boundary used by the Instance state machine.
|
||||
type PostgreSQLInstanceInitializer interface {
|
||||
Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
|
||||
InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=database.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;create;update;patch;delete
|
||||
@@ -56,11 +65,12 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
}
|
||||
|
||||
before := instance.DeepCopy()
|
||||
phaseResult := newInstanceStateMachine().reconcile(instance)
|
||||
instance.Status.Phase = phaseResult.phase
|
||||
if phaseResult.reconcilingMessage != "" {
|
||||
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, phaseResult.reconcilingMessage)
|
||||
if r.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
result, reconcileErr := newInstanceStateMachine(r.Initializer).reconcile(ctx, instance)
|
||||
|
||||
if !reflect.DeepEqual(before.Status, instance.Status) {
|
||||
if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil {
|
||||
@@ -71,7 +81,7 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
}
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
return result, reconcileErr
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
|
||||
@@ -16,58 +16,115 @@ limitations under the License.
|
||||
|
||||
package controller
|
||||
|
||||
import databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
type instancePhaseResult struct {
|
||||
phase databasev1alpha1.PostgreSQLInstancePhase
|
||||
reconcilingMessage string
|
||||
}
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
)
|
||||
|
||||
type instancePhaseHandler func(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult
|
||||
type instancePhaseHandler func(context.Context, *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error)
|
||||
|
||||
type instanceStateMachine struct {
|
||||
initializer PostgreSQLInstanceInitializer
|
||||
handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler
|
||||
}
|
||||
|
||||
func newInstanceStateMachine() instanceStateMachine {
|
||||
return instanceStateMachine{handlers: map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{
|
||||
databasev1alpha1.PostgreSQLInstancePhasePending: reconcileInstancePending,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseValidating: keepInstancePhase,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: keepInstancePhase,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseReady: keepInstancePhase,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseDeleting: reconcileInstanceDeleting,
|
||||
}}
|
||||
func newInstanceStateMachine(initializer PostgreSQLInstanceInitializer) *instanceStateMachine {
|
||||
m := &instanceStateMachine{initializer: initializer}
|
||||
m.handlers = map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{
|
||||
databasev1alpha1.PostgreSQLInstancePhasePending: m.pending,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseValidating: m.validate,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: m.initializeRegistry,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseReady: m.ready,
|
||||
databasev1alpha1.PostgreSQLInstancePhaseDeleting: m.deleting,
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m instanceStateMachine) reconcile(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult {
|
||||
func (m *instanceStateMachine) reconcile(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
phase := instance.Status.Phase
|
||||
if !instance.DeletionTimestamp.IsZero() {
|
||||
phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting
|
||||
} else if phase == "" {
|
||||
phase = databasev1alpha1.PostgreSQLInstancePhasePending
|
||||
}
|
||||
|
||||
handler, found := m.handlers[phase]
|
||||
if !found {
|
||||
return instancePhaseResult{phase: phase}
|
||||
handler = m.pending
|
||||
}
|
||||
return handler(instance)
|
||||
result, err := handler(ctx, instance)
|
||||
if err != nil {
|
||||
instance.Status.ObservedGeneration = instance.Generation
|
||||
reason := databasev1alpha1.ReasonDependencyUnavailable
|
||||
var categorized interface{ ConditionReason() string }
|
||||
if errors.As(err, &categorized) {
|
||||
reason = categorized.ConditionReason()
|
||||
}
|
||||
setFailedCondition(&instance.Status.Conditions, instance.Generation, reason, "instance dependency validation failed")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func reconcileInstancePending(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult {
|
||||
return instancePhaseResult{
|
||||
phase: databasev1alpha1.PostgreSQLInstancePhaseValidating,
|
||||
reconcilingMessage: "instance dependencies are being validated",
|
||||
}
|
||||
func (m *instanceStateMachine) pending(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseValidating,
|
||||
"instance dependencies are being validated"), nil
|
||||
}
|
||||
|
||||
func reconcileInstanceDeleting(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult {
|
||||
return instancePhaseResult{
|
||||
phase: databasev1alpha1.PostgreSQLInstancePhaseDeleting,
|
||||
reconcilingMessage: "instance deletion is reconciling",
|
||||
func (m *instanceStateMachine) validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
if m.initializer == nil {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
version, err := m.initializer.Validate(ctx, instance)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
instance.Status.PostgreSQLVersion = version
|
||||
return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry,
|
||||
"PostgreSQL registry is being initialized"), nil
|
||||
}
|
||||
|
||||
func keepInstancePhase(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult {
|
||||
return instancePhaseResult{phase: instance.Status.Phase}
|
||||
func (m *instanceStateMachine) initializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
if m.initializer == nil {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
version, err := m.initializer.InitializeRegistry(ctx, instance)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
instance.Status.PostgreSQLVersion = version
|
||||
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseReady
|
||||
instance.Status.ObservedGeneration = instance.Generation
|
||||
setReadyCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are ready")
|
||||
return ctrl.Result{RequeueAfter: time.Minute}, nil
|
||||
}
|
||||
|
||||
func (m *instanceStateMachine) ready(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
if instance.Status.ObservedGeneration != instance.Generation {
|
||||
return m.pending(ctx, instance)
|
||||
}
|
||||
if m.initializer == nil {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
version, err := m.initializer.Validate(ctx, instance)
|
||||
if err != nil {
|
||||
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
instance.Status.PostgreSQLVersion = version
|
||||
return ctrl.Result{RequeueAfter: time.Minute}, nil
|
||||
}
|
||||
|
||||
func (m *instanceStateMachine) deleting(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
|
||||
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting
|
||||
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance deletion is reconciling")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func advanceInstance(instance *databasev1alpha1.PostgreSQLInstance, phase databasev1alpha1.PostgreSQLInstancePhase, message string) ctrl.Result {
|
||||
instance.Status.Phase = phase
|
||||
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, message)
|
||||
return ctrl.Result{RequeueAfter: time.Millisecond}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ limitations under the License.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -26,15 +28,120 @@ import (
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
type fakeInstanceInitializer struct {
|
||||
validateVersion string
|
||||
registryVersion string
|
||||
err error
|
||||
}
|
||||
|
||||
const testPostgreSQLVersion = "17.6"
|
||||
|
||||
func (f fakeInstanceInitializer) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
||||
return f.validateVersion, f.err
|
||||
}
|
||||
|
||||
func (f fakeInstanceInitializer) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
||||
return f.registryVersion, f.err
|
||||
}
|
||||
|
||||
var _ = Describe("phase handler state machines", func() {
|
||||
It("persists validation intent before touching unavailable dependencies", func() {
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{}
|
||||
machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")})
|
||||
result, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result.RequeueAfter).To(BeNumerically(">", 0))
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
|
||||
Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionUnknown))
|
||||
})
|
||||
|
||||
It("persists a new validation checkpoint when a Ready Instance generation changes", func() {
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{
|
||||
ObjectMeta: metav1.ObjectMeta{Generation: 4},
|
||||
Status: databasev1alpha1.PostgreSQLInstanceStatus{
|
||||
Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3,
|
||||
},
|
||||
}
|
||||
machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")})
|
||||
_, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
|
||||
Expect(instance.Status.ObservedGeneration).To(Equal(int64(3)))
|
||||
})
|
||||
|
||||
It("detects a dependency outage after Ready and recovers through registry initialization", func() {
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{
|
||||
ObjectMeta: metav1.ObjectMeta{Generation: 3},
|
||||
Status: databasev1alpha1.PostgreSQLInstanceStatus{
|
||||
Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3,
|
||||
},
|
||||
}
|
||||
machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")})
|
||||
_, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).To(MatchError("unavailable"))
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
|
||||
Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
|
||||
machine.initializer = fakeInstanceInitializer{
|
||||
validateVersion: testPostgreSQLVersion,
|
||||
registryVersion: testPostgreSQLVersion,
|
||||
}
|
||||
_, err = machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry))
|
||||
_, err = machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue))
|
||||
result, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady))
|
||||
Expect(result.RequeueAfter).To(Equal(time.Minute))
|
||||
})
|
||||
|
||||
It("advances an Instance through external validation and registry initialization", func() {
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{
|
||||
ObjectMeta: metav1.ObjectMeta{Generation: 3},
|
||||
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
|
||||
}
|
||||
machine := newInstanceStateMachine(fakeInstanceInitializer{
|
||||
validateVersion: testPostgreSQLVersion,
|
||||
registryVersion: testPostgreSQLVersion,
|
||||
})
|
||||
_, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry))
|
||||
_, err = machine.reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady))
|
||||
Expect(instance.Status.PostgreSQLVersion).To(Equal(testPostgreSQLVersion))
|
||||
Expect(instance.Status.ObservedGeneration).To(Equal(int64(3)))
|
||||
Expect(instance.Status.Conditions).To(ConsistOf(And(
|
||||
HaveField("Status", metav1.ConditionTrue), HaveField("Reason", databasev1alpha1.ReasonReady),
|
||||
)))
|
||||
})
|
||||
|
||||
It("keeps the safe phase when a dependency is unavailable", func() {
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{
|
||||
ObjectMeta: metav1.ObjectMeta{Generation: 2},
|
||||
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
|
||||
}
|
||||
machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")})
|
||||
_, err := machine.reconcile(context.Background(), instance)
|
||||
Expect(err).To(MatchError("unavailable"))
|
||||
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
|
||||
Expect(instance.Status.Conditions).To(ConsistOf(And(
|
||||
HaveField("Status", metav1.ConditionFalse), HaveField("Reason", databasev1alpha1.ReasonDependencyUnavailable),
|
||||
)))
|
||||
})
|
||||
|
||||
DescribeTable("dispatches Instance phases",
|
||||
func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) {
|
||||
result := newInstanceStateMachine().reconcile(instance)
|
||||
Expect(result.phase).To(Equal(expected))
|
||||
_, err := newInstanceStateMachine(nil).reconcile(context.Background(), instance)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(instance.Status.Phase).To(Equal(expected))
|
||||
if hasMessage {
|
||||
Expect(result.reconcilingMessage).NotTo(BeEmpty())
|
||||
Expect(instance.Status.Conditions).NotTo(BeEmpty())
|
||||
} else {
|
||||
Expect(result.reconcilingMessage).To(BeEmpty())
|
||||
Expect(instance.Status.Conditions).To(BeEmpty())
|
||||
}
|
||||
},
|
||||
Entry("starts validation from an empty checkpoint",
|
||||
|
||||
@@ -32,3 +32,17 @@ func setReconcilingCondition(conditions *[]metav1.Condition, generation int64, m
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func setReadyCondition(conditions *[]metav1.Condition, generation int64, message string) {
|
||||
apiMeta.SetStatusCondition(conditions, metav1.Condition{
|
||||
Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue,
|
||||
ObservedGeneration: generation, Reason: databasev1alpha1.ReasonReady, Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func setFailedCondition(conditions *[]metav1.Condition, generation int64, reason, message string) {
|
||||
apiMeta.SetStatusCondition(conditions, metav1.Condition{
|
||||
Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse,
|
||||
ObservedGeneration: generation, Reason: reason, Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// CredentialReferenceValues contains effective field mappings, not secret data.
|
||||
// The application supplies defaults and fixes the namespace to the controller's.
|
||||
// Namespace and provider-specific paths are deliberately not selectable here.
|
||||
type CredentialReferenceValues struct {
|
||||
Name string
|
||||
UsernameKey string
|
||||
PasswordKey string
|
||||
}
|
||||
|
||||
// CredentialReference is an immutable reference to a management Secret.
|
||||
// Its zero value is invalid; aggregate construction must Validate incoming values.
|
||||
type CredentialReference struct {
|
||||
values CredentialReferenceValues
|
||||
}
|
||||
|
||||
// Instance and Secret names share the DNS subdomain syntax and 253-character limit.
|
||||
var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||
|
||||
func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) {
|
||||
reference := CredentialReference{values: values}
|
||||
if err := reference.Validate(); err != nil {
|
||||
return CredentialReference{}, err
|
||||
}
|
||||
return reference, nil
|
||||
}
|
||||
|
||||
// Values returns a copy of the reference, never secret contents.
|
||||
func (r CredentialReference) Values() CredentialReferenceValues { return r.values }
|
||||
|
||||
// Validate enforces reference invariants without accessing Kubernetes or OpenBao.
|
||||
// Checking that the referenced Secret contains nonempty credentials is an application
|
||||
// responsibility. Errors omit input values and no implicit defaults are applied.
|
||||
func (r CredentialReference) Validate() error {
|
||||
if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) {
|
||||
return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters")
|
||||
}
|
||||
if r.values.UsernameKey == "" {
|
||||
return errors.New("management Secret username field is required")
|
||||
}
|
||||
if r.values.PasswordKey == "" {
|
||||
return errors.New("management Secret password field is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
|
||||
)
|
||||
|
||||
func validCredentialReference() instance.CredentialReferenceValues {
|
||||
return instance.CredentialReferenceValues{
|
||||
Name: "shared-postgresql-admin", UsernameKey: "username", PasswordKey: "password",
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance: docs/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package instance contains the pure domain model of a registered PostgreSQL instance.
|
||||
// It does not depend on Kubernetes types, database drivers or credential providers.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// TLSMode is an explicit transport policy, not a driver-specific default.
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
TLSDisable TLSMode = "disable"
|
||||
TLSRequire TLSMode = "require"
|
||||
TLSVerifyCA TLSMode = "verify-ca"
|
||||
TLSVerifyFull TLSMode = "verify-full"
|
||||
)
|
||||
|
||||
// EndpointValues carries explicit, effective values across the application boundary.
|
||||
// Defaults are supplied by the API/application mapping, never silently by the domain.
|
||||
type EndpointValues struct {
|
||||
Host string
|
||||
HostAddr string
|
||||
Port int
|
||||
ManagementDatabase string
|
||||
TLSMode TLSMode
|
||||
}
|
||||
|
||||
// Endpoint is an immutable connection target. Equality compares its declared values,
|
||||
// not physical server identity. Its zero value is invalid; aggregate construction
|
||||
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
|
||||
type Endpoint struct {
|
||||
values EndpointValues
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
func NewEndpoint(values EndpointValues) (Endpoint, error) {
|
||||
endpoint := Endpoint{values: values}
|
||||
if err := endpoint.Validate(); err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// Values returns a copy, without exposing mutable state.
|
||||
func (e Endpoint) Values() EndpointValues { return e.values }
|
||||
|
||||
// Validate checks local invariants only; it does not resolve DNS or perform IO.
|
||||
// Errors intentionally omit input values.
|
||||
func (e Endpoint) Validate() error {
|
||||
if e.values.Host == "" {
|
||||
return errors.New("endpoint host is required")
|
||||
}
|
||||
address, err := netip.ParseAddr(e.values.HostAddr)
|
||||
if err != nil || address.Zone() != "" {
|
||||
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
|
||||
}
|
||||
if e.values.Port < 1 || e.values.Port > 65535 {
|
||||
return errors.New("endpoint port must be between 1 and 65535")
|
||||
}
|
||||
if !identifier.MatchString(e.values.ManagementDatabase) {
|
||||
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
|
||||
}
|
||||
switch e.values.TLSMode {
|
||||
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
|
||||
return nil
|
||||
default:
|
||||
return errors.New("endpoint TLS mode must be explicitly supported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/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/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "slices"
|
||||
|
||||
// ExtensionSet is an immutable set of exact names. Zero represents the empty set.
|
||||
// It does not impose identifier syntax or claim that a server supports any name.
|
||||
type ExtensionSet struct {
|
||||
names []string
|
||||
}
|
||||
|
||||
func NewExtensionSet(names []string) ExtensionSet {
|
||||
copied := slices.Clone(names)
|
||||
slices.Sort(copied)
|
||||
return ExtensionSet{names: slices.Compact(copied)}
|
||||
}
|
||||
|
||||
// Names returns a sorted, deduplicated copy.
|
||||
func (s ExtensionSet) Names() []string { return slices.Clone(s.names) }
|
||||
|
||||
type ExtensionDecision string
|
||||
|
||||
const (
|
||||
ExtensionsAccepted ExtensionDecision = "Accepted"
|
||||
ExtensionsUnsupported ExtensionDecision = "ExtensionsUnsupported"
|
||||
ExtensionSupportUnobserved ExtensionDecision = "ExtensionSupportUnobserved"
|
||||
)
|
||||
|
||||
// ExtensionCheck reports support only, not readiness or permission to install.
|
||||
// Unsupported is a detached, sorted list and is populated only for known support.
|
||||
type ExtensionCheck struct {
|
||||
Decision ExtensionDecision
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
// ExtensionSupport is the extension-list component of an Instance observation.
|
||||
// Zero means unobserved, not an observed empty list. Target/revision binding and
|
||||
// invalidation belong to the containing Instance observation, not this set value.
|
||||
type ExtensionSupport struct {
|
||||
observed bool
|
||||
available ExtensionSet
|
||||
}
|
||||
|
||||
// ObserveExtensionSupport records a successfully read list, including an empty one.
|
||||
// A failed query must not call this constructor with an empty list: the application
|
||||
// must propagate the dependency failure and leave support unobserved.
|
||||
func ObserveExtensionSupport(available []string) ExtensionSupport {
|
||||
return ExtensionSupport{observed: true, available: NewExtensionSet(available)}
|
||||
}
|
||||
|
||||
// Check performs no IO and cannot install or remove extensions.
|
||||
func (s ExtensionSupport) Check(requested ExtensionSet) ExtensionCheck {
|
||||
if len(requested.names) == 0 {
|
||||
return ExtensionCheck{Decision: ExtensionsAccepted}
|
||||
}
|
||||
if !s.observed {
|
||||
return ExtensionCheck{Decision: ExtensionSupportUnobserved}
|
||||
}
|
||||
var unsupported []string
|
||||
for _, name := range requested.names {
|
||||
if _, found := slices.BinarySearch(s.available.names, name); !found {
|
||||
unsupported = append(unsupported, name)
|
||||
}
|
||||
}
|
||||
if len(unsupported) != 0 {
|
||||
return ExtensionCheck{Decision: ExtensionsUnsupported, Unsupported: unsupported}
|
||||
}
|
||||
return ExtensionCheck{Decision: ExtensionsAccepted}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
|
||||
)
|
||||
|
||||
const (
|
||||
testUUID = "uuid-ossp"
|
||||
testTrigram = "pg_trgm"
|
||||
testVector = "vector"
|
||||
testChanged = "changed"
|
||||
)
|
||||
|
||||
// Acceptance: docs/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "errors"
|
||||
|
||||
// Identity identifies one registration, not a physical PostgreSQL server.
|
||||
// UID is opaque; a recreated resource with the same name has a different identity.
|
||||
// Its zero value is invalid and must be rejected when constructing an aggregate.
|
||||
type Identity struct {
|
||||
uid string
|
||||
name string
|
||||
}
|
||||
|
||||
func NewIdentity(uid, name string) (Identity, error) {
|
||||
identity := Identity{uid: uid, name: name}
|
||||
if err := identity.Validate(); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (i Identity) UID() string { return i.uid }
|
||||
func (i Identity) Name() string { return i.name }
|
||||
|
||||
// Validate checks registration values without looking up any external identity.
|
||||
func (i Identity) Validate() error {
|
||||
if i.uid == "" {
|
||||
return errors.New("instance UID is required")
|
||||
}
|
||||
if len(i.name) > 253 || !dnsSubdomainName.MatchString(i.name) {
|
||||
return errors.New("instance name must be a valid DNS subdomain of at most 253 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revision is a positive configuration generation, separate from Identity.
|
||||
// Zero is invalid for desired configuration; an unobserved status generation of
|
||||
// zero must be represented separately when the aggregate is implemented.
|
||||
type Revision struct {
|
||||
value int64
|
||||
}
|
||||
|
||||
func NewRevision(value int64) (Revision, error) {
|
||||
revision := Revision{value: value}
|
||||
if err := revision.Validate(); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (r Revision) Value() int64 { return r.value }
|
||||
|
||||
func (r Revision) Validate() error {
|
||||
if r.value <= 0 {
|
||||
return errors.New("instance revision must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
|
||||
)
|
||||
|
||||
// Acceptance: docs/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "errors"
|
||||
|
||||
// Phase is a workflow checkpoint, never evidence of external resource state.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePending Phase = "Pending"
|
||||
PhaseValidating Phase = "Validating"
|
||||
PhaseInitializingRegistry Phase = "InitializingRegistry"
|
||||
PhaseReady Phase = "Ready"
|
||||
PhaseDeleting Phase = "Deleting"
|
||||
)
|
||||
|
||||
type Readiness string
|
||||
|
||||
const (
|
||||
Unknown Readiness = "Unknown"
|
||||
Ready Readiness = "Ready"
|
||||
NotReady Readiness = "NotReady"
|
||||
)
|
||||
|
||||
// Snapshot contains persisted observations only, without credentials or live evidence.
|
||||
// Failure detail mapping will be added with capability assessment, not intent transitions.
|
||||
type Snapshot struct {
|
||||
Phase Phase
|
||||
ObservedRevision int64
|
||||
Readiness Readiness
|
||||
ReportedVersion string
|
||||
}
|
||||
|
||||
// Instance protects registration state and pure lifecycle transitions.
|
||||
// Reconstitution does not establish live capability evidence, even for a Ready snapshot.
|
||||
// This initial slice deliberately exposes no operation that authorizes provisioning.
|
||||
type Instance struct {
|
||||
target ObservationTarget
|
||||
snapshot Snapshot
|
||||
deleting bool
|
||||
}
|
||||
|
||||
func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) {
|
||||
if err := target.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch snapshot.Phase {
|
||||
case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting:
|
||||
default:
|
||||
snapshot.Phase = PhasePending
|
||||
snapshot.Readiness = Unknown
|
||||
}
|
||||
return &Instance{target: target, snapshot: snapshot, deleting: deleting}, nil
|
||||
}
|
||||
|
||||
func (i *Instance) Target() ObservationTarget { return i.target }
|
||||
|
||||
// Snapshot returns a detached value. Persisting it remains the application's job.
|
||||
func (i *Instance) Snapshot() Snapshot { return i.snapshot }
|
||||
|
||||
// BeginValidation records intent only; it does not claim a concluded observation.
|
||||
func (i *Instance) BeginValidation() error {
|
||||
if err := i.target.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i.deleting {
|
||||
return errors.New("cannot begin validation after deletion was requested")
|
||||
}
|
||||
i.snapshot.Phase = PhaseValidating
|
||||
i.snapshot.Readiness = Unknown
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginDeletion stops the lifecycle from accepting validation. It does not delete
|
||||
// resources, inspect Tenant references, close connections or modify finalizers.
|
||||
func (i *Instance) BeginDeletion() error {
|
||||
if err := i.target.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !i.deleting {
|
||||
return errors.New("cannot begin deletion without a deletion request")
|
||||
}
|
||||
i.snapshot.Phase = PhaseDeleting
|
||||
i.snapshot.Readiness = Unknown
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/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/domain-instance.md §3, checkpoint reconstruction and intent-only transitions.
|
||||
func TestReconstituteCheckpoints(t *testing.T) {
|
||||
for _, phase := range []instance.Phase{
|
||||
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
|
||||
instance.PhaseReady, instance.PhaseDeleting,
|
||||
} {
|
||||
snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"}
|
||||
value := lifecycleInstance(t, snapshot, false)
|
||||
if value.Snapshot() != snapshot {
|
||||
t.Fatal("known checkpoint was not preserved")
|
||||
}
|
||||
// A snapshot is detached; it is not a setter on the aggregate.
|
||||
copy := value.Snapshot()
|
||||
copy.Phase = instance.PhasePending
|
||||
copy.ReportedVersion = "changed"
|
||||
if value.Snapshot() != snapshot {
|
||||
t.Fatal("snapshot mutation changed aggregate")
|
||||
}
|
||||
}
|
||||
for _, phase := range []instance.Phase{"", "unknown"} {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: phase, Readiness: instance.Ready}, false)
|
||||
if value.Snapshot().Phase != instance.PhasePending || value.Snapshot().Readiness != instance.Unknown {
|
||||
t.Fatal("missing or unknown checkpoint did not restart conservatively")
|
||||
}
|
||||
}
|
||||
if value, err := instance.Reconstitute(instance.ObservationTarget{}, instance.Snapshot{}, false); err == nil || value != nil {
|
||||
t.Fatal("invalid target reconstructed an aggregate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginValidationPreservesObservedRevision(t *testing.T) {
|
||||
snapshot := instance.Snapshot{
|
||||
Phase: instance.PhaseReady, ObservedRevision: 0, Readiness: instance.Ready, ReportedVersion: "17",
|
||||
}
|
||||
value := lifecycleInstance(t, snapshot, false)
|
||||
target := value.Target()
|
||||
for range 2 {
|
||||
if err := value.BeginValidation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := value.Snapshot()
|
||||
if got.Phase != instance.PhaseValidating || got.Readiness != instance.Unknown ||
|
||||
got.ObservedRevision != snapshot.ObservedRevision || got.ReportedVersion != snapshot.ReportedVersion {
|
||||
t.Fatal("recording validation intent claimed a completed observation or erased diagnostic version")
|
||||
}
|
||||
}
|
||||
if value.Target() != target {
|
||||
t.Fatal("lifecycle action mutated identity or configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) {
|
||||
snapshot := instance.Snapshot{Phase: instance.PhaseReady, Readiness: instance.Ready, ObservedRevision: 1}
|
||||
active := lifecycleInstance(t, snapshot, false)
|
||||
if err := active.BeginDeletion(); err == nil {
|
||||
t.Fatal("deletion without a request accepted")
|
||||
}
|
||||
if active.Snapshot() != snapshot {
|
||||
t.Fatal("rejected deletion mutated state")
|
||||
}
|
||||
for _, phase := range []instance.Phase{
|
||||
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
|
||||
instance.PhaseReady, instance.PhaseDeleting,
|
||||
} {
|
||||
snapshot.Phase = phase
|
||||
value := lifecycleInstance(t, snapshot, true)
|
||||
if err := value.BeginValidation(); err == nil {
|
||||
t.Fatal("validation accepted after deletion request")
|
||||
}
|
||||
if value.Snapshot() != snapshot {
|
||||
t.Fatal("rejected validation mutated state")
|
||||
}
|
||||
for range 2 {
|
||||
if err := value.BeginDeletion(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := value.Snapshot(); got.Phase != instance.PhaseDeleting || got.Readiness != instance.Unknown ||
|
||||
got.ObservedRevision != snapshot.ObservedRevision {
|
||||
t.Fatal("incorrect deletion checkpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroInstanceCannotTransition(t *testing.T) {
|
||||
var value instance.Instance
|
||||
if err := value.BeginValidation(); err == nil {
|
||||
t.Fatal("zero instance started validation")
|
||||
}
|
||||
if err := value.BeginDeletion(); err == nil {
|
||||
t.Fatal("zero instance started deletion")
|
||||
}
|
||||
if value.Snapshot() != (instance.Snapshot{}) {
|
||||
t.Fatal("invalid transition changed zero instance")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
// Definition is the immutable effective configuration of an Instance.
|
||||
// Available extensions are observations, not part of the declared configuration.
|
||||
type Definition struct {
|
||||
endpoint Endpoint
|
||||
adminCredential CredentialReference
|
||||
}
|
||||
|
||||
func NewDefinition(endpoint Endpoint, adminCredential CredentialReference) (Definition, error) {
|
||||
definition := Definition{endpoint: endpoint, adminCredential: adminCredential}
|
||||
if err := definition.Validate(); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (d Definition) Endpoint() Endpoint { return d.endpoint }
|
||||
func (d Definition) AdminCredential() CredentialReference { return d.adminCredential }
|
||||
|
||||
// Validate rejects invalid zero-value components even when constructors were bypassed.
|
||||
func (d Definition) Validate() error {
|
||||
if err := d.endpoint.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.adminCredential.Validate()
|
||||
}
|
||||
|
||||
// ObservationTarget binds facts to a registration and its declared configuration.
|
||||
// It does not identify a physical PostgreSQL server or prove observation freshness.
|
||||
// Secret content refresh and same-target observation freshness remain application
|
||||
// responsibilities; no credentials or Secret contents are carried by this value.
|
||||
type ObservationTarget struct {
|
||||
identity Identity
|
||||
revision Revision
|
||||
definition Definition
|
||||
}
|
||||
|
||||
func NewObservationTarget(identity Identity, revision Revision, definition Definition) (ObservationTarget, error) {
|
||||
target := ObservationTarget{identity: identity, revision: revision, definition: definition}
|
||||
if err := target.Validate(); err != nil {
|
||||
return ObservationTarget{}, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (t ObservationTarget) Identity() Identity { return t.identity }
|
||||
func (t ObservationTarget) Revision() Revision { return t.revision }
|
||||
func (t ObservationTarget) Definition() Definition { return t.definition }
|
||||
|
||||
func (t ObservationTarget) Validate() error {
|
||||
if err := t.identity.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.revision.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.definition.Validate()
|
||||
}
|
||||
|
||||
// Matches rejects invalid targets before comparing values. Matching is necessary,
|
||||
// but not sufficient, for the aggregate to accept a fresh capability observation.
|
||||
func (t ObservationTarget) Matches(other ObservationTarget) bool {
|
||||
return t.Validate() == nil && other.Validate() == nil && t == other
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/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/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
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
|
||||
|
||||
// Failure contains only a safe operation message and a Condition reason.
|
||||
// Raw driver errors can contain credentials and must not escape through wrapping.
|
||||
type Failure struct {
|
||||
Reason string
|
||||
Operation string
|
||||
}
|
||||
|
||||
func (e Failure) Error() string { return e.Operation }
|
||||
func (e Failure) ConditionReason() string { return e.Reason }
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
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 validates PostgreSQLInstance dependencies and initializes
|
||||
// the controller registry without exposing administrative credentials.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
kubernetesauth "github.com/openbao/openbao/api/auth/kubernetes/v2"
|
||||
openbao "github.com/openbao/openbao/api/v2"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql/registry"
|
||||
)
|
||||
|
||||
// Config contains the deployment-level settings needed by Instance readiness.
|
||||
type Config struct {
|
||||
OpenBaoAddress string
|
||||
OpenBaoConsumerAddress string
|
||||
OpenBaoAuthMount string
|
||||
OpenBaoAuthRole string
|
||||
ServiceAccountTokenPath string
|
||||
OpenBaoKVMount string
|
||||
OpenBaoTenantBasePath string
|
||||
ExternalSecretStoreName string
|
||||
PostgreSQLCABundlePath string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Initializer implements the Instance validation and registry phases.
|
||||
type Initializer struct{ config Config }
|
||||
|
||||
type failure struct {
|
||||
reason string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e failure) Error() string { return e.message }
|
||||
func (e failure) ConditionReason() string { return e.reason }
|
||||
|
||||
func dependencyFailure(message string) error {
|
||||
return failure{reason: databasev1alpha1.ReasonDependencyUnavailable, message: message}
|
||||
}
|
||||
|
||||
func authenticationFailure(message string) error {
|
||||
return failure{reason: databasev1alpha1.ReasonAuthenticationFailed, message: message}
|
||||
}
|
||||
|
||||
func privilegeFailure(message string) error {
|
||||
return failure{reason: databasev1alpha1.ReasonInsufficientPrivileges, message: message}
|
||||
}
|
||||
|
||||
// New validates config and constructs an Initializer.
|
||||
func New(config Config) (*Initializer, error) {
|
||||
if config.OpenBaoAddress == "" || config.OpenBaoAuthRole == "" || config.OpenBaoAuthMount == "" ||
|
||||
config.ServiceAccountTokenPath == "" || config.OpenBaoKVMount == "" || config.OpenBaoTenantBasePath == "" ||
|
||||
config.ExternalSecretStoreName == "" || config.Timeout <= 0 {
|
||||
return nil, errors.New("initialize instance dependencies: required configuration is missing")
|
||||
}
|
||||
if err := validateAddress(config.OpenBaoAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.OpenBaoConsumerAddress == "" {
|
||||
config.OpenBaoConsumerAddress = config.OpenBaoAddress
|
||||
}
|
||||
if err := validateAddress(config.OpenBaoConsumerAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !filepath.IsAbs(config.ServiceAccountTokenPath) || validateRelativePath(config.OpenBaoAuthMount, false) != nil ||
|
||||
validateRelativePath(config.OpenBaoKVMount, false) != nil || validateRelativePath(config.OpenBaoTenantBasePath, true) != nil ||
|
||||
len(validation.IsDNS1123Subdomain(config.ExternalSecretStoreName)) != 0 {
|
||||
return nil, errors.New("initialize instance dependencies: invalid path or resource name configuration")
|
||||
}
|
||||
return &Initializer{config: config}, nil
|
||||
}
|
||||
|
||||
// Validate authenticates to OpenBao, reads the administrative credential, and
|
||||
// verifies that PostgreSQL accepts it. It returns only public server metadata.
|
||||
func (i *Initializer) Validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
||||
pool, err := i.connect(ctx, instance)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var version string
|
||||
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
|
||||
return "", errors.New("validate PostgreSQL server metadata")
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// InitializeRegistry repeats dependency validation and applies registry migrations.
|
||||
func (i *Initializer) InitializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
||||
pool, err := i.connect(ctx, instance)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := registry.NewStore(pool).Bootstrap(ctx); err != nil {
|
||||
return "", privilegeFailure("initialize PostgreSQL registry")
|
||||
}
|
||||
var version string
|
||||
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
|
||||
return "", errors.New("validate PostgreSQL server metadata")
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (i *Initializer) connect(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (*pgxpool.Pool, error) {
|
||||
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable && i.config.PostgreSQLCABundlePath == "" {
|
||||
return nil, errors.New("configure PostgreSQL TLS: CA bundle path is required")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, i.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
clientConfig := openbao.DefaultConfig()
|
||||
clientConfig.Address = i.config.OpenBaoAddress
|
||||
clientConfig.Timeout = i.config.Timeout
|
||||
clientConfig.DisableEnvironment = true
|
||||
client, err := openbao.NewClient(clientConfig)
|
||||
if err != nil {
|
||||
return nil, dependencyFailure("create OpenBao client")
|
||||
}
|
||||
auth, err := kubernetesauth.NewKubernetesAuth(
|
||||
i.config.OpenBaoAuthRole,
|
||||
kubernetesauth.WithMountPath(i.config.OpenBaoAuthMount),
|
||||
kubernetesauth.WithServiceAccountTokenPath(i.config.ServiceAccountTokenPath),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("configure OpenBao Kubernetes authentication")
|
||||
}
|
||||
if secret, err := client.Auth().Login(ctx, auth); err != nil || secret == nil || secret.Auth == nil {
|
||||
return nil, authenticationFailure("authenticate to OpenBao")
|
||||
}
|
||||
|
||||
secret, err := client.KVv2(i.config.OpenBaoKVMount).Get(ctx, instance.Spec.AdminCredentialRef.Path)
|
||||
if err != nil {
|
||||
return nil, privilegeFailure("read PostgreSQL administrative credential")
|
||||
}
|
||||
username, usernameOK := secret.Data[instance.Spec.AdminCredentialRef.UsernameKey].(string)
|
||||
password, passwordOK := secret.Data[instance.Spec.AdminCredentialRef.PasswordKey].(string)
|
||||
if !usernameOK || !passwordOK || username == "" || password == "" {
|
||||
return nil, errors.New("read PostgreSQL administrative credential fields")
|
||||
}
|
||||
|
||||
connectionURL := &url.URL{
|
||||
Scheme: "postgresql",
|
||||
User: url.UserPassword(username, password),
|
||||
Host: net.JoinHostPort(instance.Spec.Endpoint.Host, strconv.Itoa(int(instance.Spec.Endpoint.Port))),
|
||||
Path: instance.Spec.Endpoint.Database,
|
||||
}
|
||||
query := connectionURL.Query()
|
||||
query.Set("sslmode", string(instance.Spec.Endpoint.SSLMode))
|
||||
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable {
|
||||
query.Set("sslrootcert", i.config.PostgreSQLCABundlePath)
|
||||
}
|
||||
connectionURL.RawQuery = query.Encode()
|
||||
poolConfig, err := pgxpool.ParseConfig(connectionURL.String())
|
||||
if err != nil {
|
||||
return nil, errors.New("configure PostgreSQL connection")
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, errors.New("connect to PostgreSQL")
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, authenticationFailure("authenticate to PostgreSQL")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func validateAddress(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || !parsed.IsAbs() || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" ||
|
||||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("initialize instance dependencies: invalid OpenBao address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRelativePath(value string, rejectAPILayer bool) error {
|
||||
for index, segment := range strings.Split(value, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." ||
|
||||
(rejectAPILayer && index == 0 && (segment == "data" || segment == "metadata")) {
|
||||
return errors.New("invalid mount-relative path")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewValidatesDeploymentConfiguration(t *testing.T) {
|
||||
valid := Config{
|
||||
OpenBaoAddress: "http://openbao:8200", OpenBaoAuthMount: "kubernetes", OpenBaoAuthRole: "controller",
|
||||
ServiceAccountTokenPath: "/var/run/secrets/token", OpenBaoKVMount: "secret",
|
||||
OpenBaoTenantBasePath: "postgresql-tenants", ExternalSecretStoreName: "openbao", Timeout: 30 * time.Second,
|
||||
}
|
||||
if _, err := New(valid); err != nil {
|
||||
t.Fatalf("New(valid) error = %v", err)
|
||||
}
|
||||
|
||||
invalid := valid
|
||||
invalid.OpenBaoTenantBasePath = "metadata/tenants"
|
||||
if _, err := New(invalid); err == nil {
|
||||
t.Fatal("New() accepted a KV v2 API-layer base path")
|
||||
}
|
||||
invalid = valid
|
||||
invalid.OpenBaoAddress = "http://user:password@openbao:8200?token=secret"
|
||||
if _, err := New(invalid); err == nil {
|
||||
t.Fatal("New() accepted credentials in the OpenBao address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
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 manages the lifetime and capabilities of external PostgreSQL instances.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
// Credentials are supplied by the credential source, never by the database adapter.
|
||||
type Credentials struct{ Username, Password string }
|
||||
|
||||
func (Credentials) String() string { return "[redacted credentials]" }
|
||||
func (Credentials) GoString() string { return "[redacted credentials]" }
|
||||
|
||||
// CredentialSource resolves administrative credentials when an Instance is assembled.
|
||||
type CredentialSource interface {
|
||||
Read(context.Context, api.OpenBaoSecretReference) (Credentials, error)
|
||||
}
|
||||
|
||||
// Database exposes the capabilities of an assembled Instance.
|
||||
type Database interface {
|
||||
Version(context.Context) (string, error)
|
||||
EnsureRegistry(context.Context) error
|
||||
Close()
|
||||
}
|
||||
|
||||
// Connector creates a database without knowing where credentials came from.
|
||||
type Connector interface {
|
||||
Connect(context.Context, api.PostgreSQLEndpoint, Credentials) (Database, error)
|
||||
}
|
||||
|
||||
type identity struct {
|
||||
uid types.UID
|
||||
endpoint api.PostgreSQLEndpoint
|
||||
reference api.OpenBaoSecretReference
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
identity identity
|
||||
credentials *Credentials
|
||||
database Database
|
||||
}
|
||||
|
||||
// Service owns Instance connections. Reconciles reuse credentials and pools until
|
||||
// the UID, endpoint or credential reference changes; allowlist changes do not rotate them.
|
||||
// Operations are serialized to prevent Close racing with an active database operation.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
source CredentialSource
|
||||
connector Connector
|
||||
entries map[string]*entry
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewService(source CredentialSource, connector Connector) *Service {
|
||||
return &Service{source: source, connector: connector, entries: make(map[string]*entry)}
|
||||
}
|
||||
|
||||
func (s *Service) Validate(ctx context.Context, resource *api.PostgreSQLInstance) (string, error) {
|
||||
return s.observe(ctx, resource, false)
|
||||
}
|
||||
|
||||
func (s *Service) InitializeRegistry(ctx context.Context, resource *api.PostgreSQLInstance) (string, error) {
|
||||
return s.observe(ctx, resource, true)
|
||||
}
|
||||
|
||||
func (s *Service) observe(ctx context.Context, resource *api.PostgreSQLInstance, ensureRegistry bool) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return "", errors.New("instance service is closed")
|
||||
}
|
||||
db, err := s.database(ctx, resource)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ensureRegistry {
|
||||
if err := db.EnsureRegistry(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return db.Version(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) database(ctx context.Context, resource *api.PostgreSQLInstance) (Database, error) {
|
||||
key := identity{uid: resource.UID, endpoint: resource.Spec.Endpoint, reference: resource.Spec.AdminCredentialRef}
|
||||
current := s.entries[resource.Name]
|
||||
if current == nil || current.identity != key {
|
||||
s.release(resource.Name)
|
||||
current = &entry{identity: key}
|
||||
s.entries[resource.Name] = current
|
||||
}
|
||||
if current.credentials == nil {
|
||||
credentials, err := s.source.Read(ctx, key.reference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current.credentials = &credentials
|
||||
}
|
||||
if current.database == nil {
|
||||
database, err := s.connector.Connect(ctx, key.endpoint, *current.credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current.database = database
|
||||
}
|
||||
return current.database, nil
|
||||
}
|
||||
|
||||
// Forget releases local resources only. It never deletes the external registry.
|
||||
func (s *Service) Forget(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.release(name)
|
||||
}
|
||||
|
||||
func (s *Service) release(name string) {
|
||||
if current := s.entries[name]; current != nil && current.database != nil {
|
||||
current.database.Close()
|
||||
}
|
||||
delete(s.entries, name)
|
||||
}
|
||||
|
||||
// Close is called after manager workers stop, and is safe to repeat.
|
||||
func (s *Service) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
for name := range s.entries {
|
||||
s.release(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type sourceStub struct {
|
||||
reads int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *sourceStub) Read(context.Context, api.OpenBaoSecretReference) (Credentials, error) {
|
||||
s.reads++
|
||||
return Credentials{Username: "admin", Password: "canary-secret"}, s.err
|
||||
}
|
||||
|
||||
type databaseStub struct {
|
||||
closes, migrations int
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *databaseStub) Version(context.Context) (string, error) { return "17", d.err }
|
||||
func (d *databaseStub) EnsureRegistry(context.Context) error { d.migrations++; return d.err }
|
||||
func (d *databaseStub) Close() { d.closes++ }
|
||||
|
||||
type connectorStub struct {
|
||||
databases []*databaseStub
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *connectorStub) Connect(context.Context, api.PostgreSQLEndpoint, Credentials) (Database, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
database := &databaseStub{}
|
||||
c.databases = append(c.databases, database)
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func TestInstanceConnectionLifetime(t *testing.T) {
|
||||
source, connector := &sourceStub{}, &connectorStub{}
|
||||
service := NewService(source, connector)
|
||||
defer service.Close()
|
||||
resource := &api.PostgreSQLInstance{ObjectMeta: metav1.ObjectMeta{Name: "shared", UID: "uid-1"}}
|
||||
ctx := context.Background()
|
||||
check := func() {
|
||||
t.Helper()
|
||||
if _, err := service.Validate(ctx, resource); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
}
|
||||
check()
|
||||
if _, err := service.InitializeRegistry(ctx, resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource.Generation++
|
||||
resource.Spec.AllowedExtensions = []string{"pg_trgm"}
|
||||
check()
|
||||
if source.reads != 1 || len(connector.databases) != 1 || connector.databases[0].migrations != 1 {
|
||||
t.Fatal("reconciliation or allowlist update unnecessarily reassembled dependencies")
|
||||
}
|
||||
connector.databases[0].err = errors.New("network outage")
|
||||
if _, err := service.Validate(ctx, resource); err == nil {
|
||||
t.Fatal("outage went unnoticed")
|
||||
}
|
||||
connector.databases[0].err = nil
|
||||
check()
|
||||
if source.reads != 1 {
|
||||
t.Fatal("network recovery reread the password")
|
||||
}
|
||||
|
||||
changes := []func(){
|
||||
func() { resource.Spec.Endpoint.Host = "new.example" },
|
||||
func() { resource.Spec.AdminCredentialRef.Path = "new/admin" },
|
||||
func() { resource.UID = "uid-2" },
|
||||
}
|
||||
for index, change := range changes {
|
||||
change()
|
||||
check()
|
||||
if connector.databases[index].closes != 1 {
|
||||
t.Fatal("replaced pool was not closed")
|
||||
}
|
||||
}
|
||||
if source.reads != 4 {
|
||||
t.Fatal("changed connection identity did not reload credentials")
|
||||
}
|
||||
service.Forget(resource.Name)
|
||||
service.Forget(resource.Name)
|
||||
if connector.databases[3].closes != 1 {
|
||||
t.Fatal("deletion did not close pool exactly once")
|
||||
}
|
||||
check()
|
||||
service.Close()
|
||||
service.Close()
|
||||
if connector.databases[4].closes != 1 {
|
||||
t.Fatal("shutdown did not close pool exactly once")
|
||||
}
|
||||
if _, err := service.Validate(ctx, resource); err == nil {
|
||||
t.Fatal("closed service accepted work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblyRetriesWithoutPasswordRotation(t *testing.T) {
|
||||
source := &sourceStub{err: errors.New("bao unavailable")}
|
||||
connector := &connectorStub{err: errors.New("connection unavailable")}
|
||||
service := NewService(source, connector)
|
||||
defer service.Close()
|
||||
resource := &api.PostgreSQLInstance{ObjectMeta: metav1.ObjectMeta{Name: "shared"}}
|
||||
ctx := context.Background()
|
||||
if _, err := service.Validate(ctx, resource); err == nil {
|
||||
t.Fatal("expected source failure")
|
||||
}
|
||||
source.err = nil
|
||||
if _, err := service.Validate(ctx, resource); err == nil {
|
||||
t.Fatal("expected connection failure")
|
||||
}
|
||||
connector.err = nil
|
||||
if _, err := service.Validate(ctx, resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.reads != 2 {
|
||||
t.Fatal("retry reread successfully cached credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsFormattingIsRedacted(t *testing.T) {
|
||||
value := Credentials{Username: "canary-user", Password: "canary-secret"}
|
||||
for _, format := range []string{"%v", "%+v", "%#v"} {
|
||||
if strings.Contains(fmt.Sprintf(format, value), "canary") {
|
||||
t.Fatal("credential formatting leaked data")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE SCHEMA postgresql_tenant_operator;
|
||||
|
||||
CREATE TABLE postgresql_tenant_operator.tenant_ownership (
|
||||
instance_uid text NOT NULL,
|
||||
tenant_uid text NOT NULL,
|
||||
tenant_namespace text NOT NULL,
|
||||
tenant_name text NOT NULL,
|
||||
database_name text NOT NULL,
|
||||
role_name text NOT NULL,
|
||||
credential_path text NOT NULL,
|
||||
managed boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retained_at timestamptz,
|
||||
PRIMARY KEY (instance_uid, tenant_uid),
|
||||
UNIQUE (instance_uid, tenant_namespace, tenant_name),
|
||||
UNIQUE (instance_uid, database_name),
|
||||
UNIQUE (instance_uid, role_name),
|
||||
UNIQUE (credential_path),
|
||||
CHECK (managed OR retained_at IS NOT NULL)
|
||||
);
|
||||
|
||||
---- create above / drop below ----
|
||||
|
||||
DROP SCHEMA postgresql_tenant_operator CASCADE;
|
||||
@@ -21,14 +21,21 @@ package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/tern/v2/migrate"
|
||||
)
|
||||
|
||||
const schemaVersion = 1
|
||||
const migrationVersionTable = "public.postgresql_tenant_operator_schema_version"
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
var (
|
||||
// ErrNotFound indicates that the registry has no matching ownership record.
|
||||
@@ -80,35 +87,29 @@ const (
|
||||
ClaimOwned ClaimResult = "Owned"
|
||||
)
|
||||
|
||||
// Bootstrap creates the private schema and the current registry table idempotently.
|
||||
// Bootstrap applies all pending versioned registry migrations idempotently.
|
||||
func (s *Store) Bootstrap(ctx context.Context) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("bootstrap registry: nil database")
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
return s.withMigrationConnection(ctx, func(conn *pgx.Conn) error {
|
||||
migrations, err := fs.Sub(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin registry bootstrap: %w", err)
|
||||
return fmt.Errorf("open embedded registry migrations: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
for _, statement := range bootstrapStatements {
|
||||
if _, err := tx.Exec(ctx, statement); err != nil {
|
||||
return fmt.Errorf("bootstrap registry schema version %d: %w", schemaVersion, err)
|
||||
migrator, err := migrate.NewMigrator(ctx, conn, migrationVersionTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize registry migrator: %w", err)
|
||||
}
|
||||
if err := migrator.LoadMigrations(migrations); err != nil {
|
||||
return fmt.Errorf("load registry migrations: %w", err)
|
||||
}
|
||||
var version int
|
||||
if err := tx.QueryRow(ctx, `SELECT version FROM postgresql_tenant_operator.schema_version WHERE singleton`).Scan(&version); err != nil {
|
||||
return fmt.Errorf("read registry schema version: %w", err)
|
||||
}
|
||||
if version != schemaVersion {
|
||||
return fmt.Errorf("registry schema version %d is unsupported; expected %d", version, schemaVersion)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit registry bootstrap: %w", err)
|
||||
if err := migrator.Migrate(ctx); err != nil {
|
||||
return fmt.Errorf("apply registry migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Claim reserves all names for an owner. Repeating an identical claim is idempotent.
|
||||
@@ -126,7 +127,7 @@ func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
commandTag, err := tx.Exec(ctx, claimStatement,
|
||||
record, err := scanRecord(tx.QueryRow(ctx, claimStatement,
|
||||
owner.InstanceUID,
|
||||
owner.TenantUID,
|
||||
owner.TenantNamespace,
|
||||
@@ -134,12 +135,12 @@ func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error)
|
||||
owner.DatabaseName,
|
||||
owner.RoleName,
|
||||
owner.CredentialPath,
|
||||
)
|
||||
if err != nil {
|
||||
))
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return "", fmt.Errorf("insert registry claim: %w", err)
|
||||
}
|
||||
|
||||
record, err := getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
record, err = getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict)
|
||||
@@ -147,16 +148,34 @@ func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error)
|
||||
return "", err
|
||||
}
|
||||
if !record.equal(owner) || !record.Managed {
|
||||
return "", fmt.Errorf("%w: existing record does not match the requested managed owner", ErrConflict)
|
||||
return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("commit registry claim: %w", err)
|
||||
}
|
||||
return ClaimOwned, nil
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("commit registry claim: %w", err)
|
||||
}
|
||||
if commandTag.RowsAffected() == 1 {
|
||||
return ClaimCreated, nil
|
||||
}
|
||||
|
||||
func (s *Store) withMigrationConnection(ctx context.Context, run func(*pgx.Conn) error) error {
|
||||
switch db := s.db.(type) {
|
||||
case *pgx.Conn:
|
||||
return run(db)
|
||||
case *pgxpool.Pool:
|
||||
conn, err := db.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire registry migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
return run(conn.Conn())
|
||||
default:
|
||||
return fmt.Errorf("bootstrap registry: database type %T cannot provide a migration connection", s.db)
|
||||
}
|
||||
return ClaimOwned, nil
|
||||
}
|
||||
|
||||
// Get returns the ownership record for an Instance UID and Tenant UID.
|
||||
|
||||
@@ -16,36 +16,6 @@ limitations under the License.
|
||||
|
||||
package registry
|
||||
|
||||
var bootstrapStatements = []string{
|
||||
`CREATE SCHEMA IF NOT EXISTS postgresql_tenant_operator`,
|
||||
`CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.schema_version (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
version integer NOT NULL
|
||||
)`,
|
||||
`INSERT INTO postgresql_tenant_operator.schema_version (singleton, version)
|
||||
VALUES (true, 1)
|
||||
ON CONFLICT (singleton) DO NOTHING`,
|
||||
`CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.tenant_ownership (
|
||||
instance_uid text NOT NULL,
|
||||
tenant_uid text NOT NULL,
|
||||
tenant_namespace text NOT NULL,
|
||||
tenant_name text NOT NULL,
|
||||
database_name text NOT NULL,
|
||||
role_name text NOT NULL,
|
||||
credential_path text NOT NULL,
|
||||
managed boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retained_at timestamptz,
|
||||
PRIMARY KEY (instance_uid, tenant_uid),
|
||||
UNIQUE (instance_uid, tenant_namespace, tenant_name),
|
||||
UNIQUE (instance_uid, database_name),
|
||||
UNIQUE (instance_uid, role_name),
|
||||
UNIQUE (credential_path),
|
||||
CHECK (managed OR retained_at IS NOT NULL)
|
||||
)`,
|
||||
}
|
||||
|
||||
const claimStatement = `
|
||||
INSERT INTO postgresql_tenant_operator.tenant_ownership (
|
||||
instance_uid,
|
||||
@@ -56,7 +26,19 @@ INSERT INTO postgresql_tenant_operator.tenant_ownership (
|
||||
role_name,
|
||||
credential_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT DO NOTHING`
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING
|
||||
instance_uid,
|
||||
tenant_uid,
|
||||
tenant_namespace,
|
||||
tenant_name,
|
||||
database_name,
|
||||
role_name,
|
||||
credential_path,
|
||||
managed,
|
||||
created_at,
|
||||
updated_at,
|
||||
retained_at`
|
||||
|
||||
const getByTenantUIDStatement = `
|
||||
SELECT
|
||||
|
||||
+85
-2
@@ -36,6 +36,8 @@ import (
|
||||
// namespace where the project is deployed in
|
||||
const namespace = "postgresql-tenant-operator-system"
|
||||
|
||||
const dependencyNamespace = "postgresql-tenant-operator-e2e"
|
||||
|
||||
// serviceAccountName created for the project
|
||||
const serviceAccountName = "postgresql-tenant-operator-controller-manager"
|
||||
|
||||
@@ -52,9 +54,19 @@ var _ = Describe("Manager", Ordered, func() {
|
||||
// enforce the restricted security policy to the namespace, installing CRDs,
|
||||
// and deploying the controller.
|
||||
BeforeAll(func() {
|
||||
By("creating manager namespace")
|
||||
cmd := exec.Command("kubectl", "create", "ns", namespace)
|
||||
By("deploying disposable PostgreSQL and OpenBao dependencies")
|
||||
cmd := exec.Command("kubectl", "apply", "-f", "test/e2e/fixtures/instance-dependencies.yaml")
|
||||
_, err := utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Failed to deploy Instance dependencies")
|
||||
cmd = exec.Command("kubectl", "wait", "deployment/postgres", "deployment/openbao", "--for=condition=Available",
|
||||
"-n", dependencyNamespace, "--timeout=3m")
|
||||
_, err = utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Instance dependencies did not become available")
|
||||
configureOpenBao()
|
||||
|
||||
By("creating manager namespace")
|
||||
cmd = exec.Command("kubectl", "create", "ns", namespace)
|
||||
_, err = utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Failed to create namespace")
|
||||
|
||||
By("labeling the namespace to enforce the restricted security policy")
|
||||
@@ -72,6 +84,13 @@ var _ = Describe("Manager", Ordered, func() {
|
||||
cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage))
|
||||
_, err = utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
|
||||
|
||||
By("configuring the controller for disposable dependencies")
|
||||
argsPatch := `{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","--openbao-address=http://openbao.postgresql-tenant-operator-e2e.svc:8200","--openbao-auth-role=postgresql-tenant-operator","--openbao-kv-mount=secret","--external-secret-store-name=openbao"]}]}}}}`
|
||||
cmd = exec.Command("kubectl", "patch", "deployment", "postgresql-tenant-operator-controller-manager",
|
||||
"-n", namespace, "--type=strategic", "-p", argsPatch)
|
||||
_, err = utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Failed to configure controller dependency flags")
|
||||
})
|
||||
|
||||
// After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
|
||||
@@ -92,6 +111,10 @@ var _ = Describe("Manager", Ordered, func() {
|
||||
By("removing manager namespace")
|
||||
cmd = exec.Command("kubectl", "delete", "ns", namespace)
|
||||
_, _ = utils.Run(cmd)
|
||||
|
||||
By("removing disposable Instance dependencies")
|
||||
cmd = exec.Command("kubectl", "delete", "-f", "test/e2e/fixtures/instance-dependencies.yaml", "--ignore-not-found")
|
||||
_, _ = utils.Run(cmd)
|
||||
})
|
||||
|
||||
// After each test, check for failures and collect logs, events,
|
||||
@@ -268,6 +291,47 @@ var _ = Describe("Manager", Ordered, func() {
|
||||
Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed())
|
||||
})
|
||||
|
||||
It("should make an Instance Ready after validating OpenBao and initializing PostgreSQL", func() {
|
||||
serviceIP, err := utils.Run(exec.Command("kubectl", "get", "service", "postgres", "-n", dependencyNamespace,
|
||||
"-o", "jsonpath={.spec.clusterIP}"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
manifest := fmt.Sprintf(`apiVersion: database.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLInstance
|
||||
metadata:
|
||||
name: e2e
|
||||
spec:
|
||||
endpoint:
|
||||
host: postgres.%s.svc
|
||||
hostaddr: %s
|
||||
port: 5432
|
||||
database: postgres
|
||||
sslMode: disable
|
||||
adminCredentialRef:
|
||||
path: infrastructure/postgresql/admin
|
||||
`, dependencyNamespace, serviceIP)
|
||||
manifestPath := filepath.Join(GinkgoT().TempDir(), "instance.yaml")
|
||||
Expect(os.WriteFile(manifestPath, []byte(manifest), 0o600)).To(Succeed())
|
||||
_, err = utils.Run(exec.Command("kubectl", "apply", "-f", manifestPath))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Eventually(func(g Gomega) {
|
||||
phase, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e",
|
||||
"-o", "jsonpath={.status.phase}"))
|
||||
g.Expect(err).NotTo(HaveOccurred())
|
||||
g.Expect(phase).To(Equal("Ready"))
|
||||
ready, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e",
|
||||
"-o", `jsonpath={.status.conditions[?(@.type=="Ready")].status}`))
|
||||
g.Expect(err).NotTo(HaveOccurred())
|
||||
g.Expect(ready).To(Equal("True"))
|
||||
}).Should(Succeed())
|
||||
|
||||
output, err := utils.Run(exec.Command("kubectl", "exec", "deployment/postgres", "-n", dependencyNamespace,
|
||||
"--", "psql", "-U", "postgres", "-d", "postgres", "-Atc",
|
||||
"SELECT to_regclass('postgresql_tenant_operator.tenant_ownership') IS NOT NULL"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(output).To(Equal("t\n"))
|
||||
})
|
||||
|
||||
// +kubebuilder:scaffold:e2e-webhooks-checks
|
||||
|
||||
// TODO: Customize the e2e test suite with scenarios specific to your project.
|
||||
@@ -282,6 +346,25 @@ var _ = Describe("Manager", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
func configureOpenBao() {
|
||||
By("configuring OpenBao Kubernetes auth and the PostgreSQL administrative credential")
|
||||
script := `set -eu
|
||||
bao auth enable kubernetes
|
||||
bao write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc \
|
||||
token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
|
||||
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
printf 'path "secret/data/infrastructure/postgresql/admin" { capabilities = ["read"] }\n' | bao policy write postgresql-tenant-operator -
|
||||
bao write auth/kubernetes/role/postgresql-tenant-operator \
|
||||
bound_service_account_names=postgresql-tenant-operator-controller-manager \
|
||||
bound_service_account_namespaces=postgresql-tenant-operator-system \
|
||||
policies=postgresql-tenant-operator ttl=5m
|
||||
bao kv put secret/infrastructure/postgresql/admin username=postgres password=postgres-e2e-only
|
||||
`
|
||||
cmd := exec.Command("kubectl", "exec", "deployment/openbao", "-n", dependencyNamespace, "--", "sh", "-ec", script)
|
||||
_, err := utils.Run(cmd)
|
||||
Expect(err).NotTo(HaveOccurred(), "Failed to configure OpenBao")
|
||||
}
|
||||
|
||||
// serviceAccountToken returns a token for the specified service account in the given namespace.
|
||||
// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
|
||||
// and parsing the resulting token from the API response.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: postgresql-tenant-operator-e2e
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: openbao
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: postgresql-tenant-operator-e2e-openbao-token-reviewer
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: system:auth-delegator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: openbao
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: {app: postgres}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: postgres}
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
- {name: POSTGRES_USER, value: postgres}
|
||||
- {name: POSTGRES_PASSWORD, value: postgres-e2e-only}
|
||||
ports:
|
||||
- {name: postgresql, containerPort: 5432}
|
||||
readinessProbe:
|
||||
exec: {command: [pg_isready, -U, postgres]}
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 2
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
spec:
|
||||
selector: {app: postgres}
|
||||
ports:
|
||||
- {name: postgresql, port: 5432, targetPort: postgresql}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openbao
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: {app: openbao}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: openbao}
|
||||
spec:
|
||||
serviceAccountName: openbao
|
||||
containers:
|
||||
- name: openbao
|
||||
image: openbao/openbao:2.6.1
|
||||
args: [server, -dev]
|
||||
env:
|
||||
- {name: BAO_DEV_LISTEN_ADDRESS, value: "0.0.0.0:8200"}
|
||||
- {name: BAO_DEV_ROOT_TOKEN_ID, value: dev-only-root-token}
|
||||
- {name: BAO_ADDR, value: "http://127.0.0.1:8200"}
|
||||
ports:
|
||||
- {name: http, containerPort: 8200}
|
||||
readinessProbe:
|
||||
exec: {command: [bao, status]}
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 2
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openbao
|
||||
namespace: postgresql-tenant-operator-e2e
|
||||
spec:
|
||||
selector: {app: openbao}
|
||||
ports:
|
||||
- {name: http, port: 8200, targetPort: http}
|
||||
Reference in New Issue
Block a user