Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae546e58e | ||
|
|
2270e013f0
|
||
|
|
a84c37d842 | ||
|
|
0c66760628
|
||
|
|
da22b6b266 | ||
|
|
e1c6eec91a
|
||
|
|
fdd43f32d1 | ||
|
|
87ec7896b5
|
||
|
|
b24b85c31f | ||
|
|
55869acfd5
|
||
|
|
ed6fd7bb85 | ||
|
|
5521d5f98d
|
||
|
|
a0f62b1e01 | ||
|
|
536d6e665f
|
||
|
|
6db3f756b2 | ||
|
|
bf7b8e4066
|
||
|
|
012efc1581 |
@@ -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
|
||||
|
||||
@@ -40,15 +40,16 @@ cluster-scoped,short name 为 `pginstance`。
|
||||
| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 |
|
||||
| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
|
||||
| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
|
||||
| `spec.allowedExtensions` | set[string] | 空集合 | 合法 extension 名称的 allowlist |
|
||||
|
||||
`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 资源。
|
||||
@@ -81,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` |
|
||||
|
||||
@@ -172,7 +173,6 @@ spec:
|
||||
sslMode: verify-full
|
||||
adminCredentialRef:
|
||||
name: shared-postgresql-admin
|
||||
allowedExtensions: [pg_trgm]
|
||||
---
|
||||
apiVersion: database.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLTenant
|
||||
|
||||
@@ -35,8 +35,8 @@ controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 Open
|
||||
## 资源模型
|
||||
|
||||
`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的
|
||||
DNS host、IP host address、端口、管理 database、TLS 模式、管理 Secret 引用和
|
||||
extension allowlist。
|
||||
DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。
|
||||
实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。
|
||||
|
||||
管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的
|
||||
Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取
|
||||
|
||||
+1
-1
@@ -69,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。
|
||||
|
||||
|
||||
+20
-4
@@ -34,7 +34,7 @@ registry,不增加安装身份连续性检查;只使旧观察失效,按新
|
||||
| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 |
|
||||
| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 |
|
||||
| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 |
|
||||
| definition.allowedExtensions | 去重的 ExtensionName 集合 | CR spec | 本轮不可变;不因 allowlist 缩小卸载扩展 |
|
||||
| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 |
|
||||
| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 |
|
||||
| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 |
|
||||
| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 |
|
||||
@@ -58,6 +58,21 @@ 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
|
||||
@@ -70,7 +85,8 @@ Instance.PlanRegistryPreparation(observation: CapabilityObservation)
|
||||
-> AlreadyUsable | PreparationAllowed | PreparationDenied
|
||||
Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome
|
||||
Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome
|
||||
Instance.CheckExtensions(requested: ExtensionSet) -> Accepted | ExtensionsDenied
|
||||
Instance.CheckExtensions(requested: ExtensionSet)
|
||||
-> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved
|
||||
Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady
|
||||
Instance.BeginDeletion() -> Outcome
|
||||
Instance.Snapshot() -> InstanceSnapshot
|
||||
@@ -106,7 +122,7 @@ evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得
|
||||
| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision |
|
||||
| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready |
|
||||
| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 |
|
||||
| CheckExtensions | 一组规范化 extension 名称 | 检查请求是否为当前 allowlist 子集,返回不允许的名称;无 IO、无状态修改 | ExtensionsDenied;不卸载已存在 extension |
|
||||
| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 |
|
||||
| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 |
|
||||
| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 |
|
||||
| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 |
|
||||
@@ -196,7 +212,7 @@ Ready --registry 需修复/保存--> InitializingRegistry
|
||||
- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被
|
||||
finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消
|
||||
已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。
|
||||
- CheckExtensions 失败不能产生任何外部写入;修改 allowlist 不会自行卸载扩展。
|
||||
- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。
|
||||
- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。
|
||||
- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入
|
||||
得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。
|
||||
|
||||
@@ -29,12 +29,12 @@ database OID 是诊断观察值,不充当本系统的租户身份。
|
||||
|
||||
### Instance:实例能力与供应策略
|
||||
|
||||
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、extension allowlist,
|
||||
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察,
|
||||
以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。
|
||||
|
||||
其行为包括:
|
||||
|
||||
- 判断租户申请是否符合本实例的 extension 策略。
|
||||
- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。
|
||||
- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。
|
||||
- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。
|
||||
- 在 registry 初始化完成并回读验证后,接受新的就绪结果。
|
||||
|
||||
+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. 验证并切换
|
||||
|
||||
|
||||
+11
-4
@@ -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 外的应用可以直接读取凭据。
|
||||
@@ -93,8 +93,11 @@ PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管
|
||||
- PostgreSQL host、port 和管理连接使用的 database;
|
||||
- PostgreSQL host address,供无法解析 DNS 的消费者使用;
|
||||
- TLS mode;
|
||||
- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名;
|
||||
- 租户允许申请的 extension 集合。
|
||||
- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。
|
||||
|
||||
可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance
|
||||
中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装
|
||||
权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。
|
||||
|
||||
实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全
|
||||
建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。
|
||||
@@ -470,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。
|
||||
@@ -523,6 +527,9 @@ v1alpha1 至少必须提供:
|
||||
|
||||
## 17. 批准状态
|
||||
|
||||
2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员
|
||||
allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。
|
||||
|
||||
2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret,
|
||||
管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。
|
||||
此项是已批准行为,现有 API types 与实现尚待后续修改。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user