Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76978ca17
|
||
|
|
06bc54e3cf
|
||
|
|
86effb72a8
|
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: homelab-knowledge
|
||||
description: Query and maintain the shared homelab-wiki when working on homelab services, infrastructure, architecture, operations, or current service status. Use it to gather existing context before work and to keep durable knowledge synchronized after relevant changes; do not use it for unrelated software work or as a substitute for commit and PR history.
|
||||
---
|
||||
|
||||
# Homelab Knowledge
|
||||
|
||||
Use `homelab-wiki` as the shared long-lived knowledge base for people and agents. Search it directly with `rg`; do not introduce a search index, vector database, or generated copy of the wiki.
|
||||
|
||||
## Locate the wiki
|
||||
|
||||
Resolve the checkout in this order:
|
||||
|
||||
1. `$HOMELAB_WIKI_PATH`, when set.
|
||||
2. A sibling directory named `homelab-wiki` next to the current repository.
|
||||
3. `/home/panxiao81/homelab-wiki` when it exists.
|
||||
|
||||
If no checkout is available, report that constraint. Do not silently skip the knowledge step, clone a repository, or create a replacement wiki without the user's authorization.
|
||||
|
||||
Before using the wiki, read its `AGENTS.md` completely. For edits, also read `README.md` and `CONTRIBUTING.md` completely and follow any more specific instructions associated with the target page.
|
||||
|
||||
## Gather context
|
||||
|
||||
At the beginning of a homelab task:
|
||||
|
||||
1. Derive search terms from the component name, service aliases, hostnames, Kubernetes resources, configuration keys, error text, and task intent.
|
||||
2. Use `rg -n -i` in the wiki to find candidate pages. Prefer several precise searches over reading the whole repository.
|
||||
3. Follow the wiki's task index, service index, architecture constraints, source records, and verification conflicts when they are relevant.
|
||||
4. Read the closest authoritative pages and their material links before making decisions. Also read the corresponding source repository README or runbook when changing an implementation.
|
||||
5. Distinguish documented design, declared configuration, deployment history, live verification, and work currently in progress. Do not present one as another.
|
||||
|
||||
For questions about current project or service status, first obtain the maintainer's current-work and ticket context as required by the wiki, unless the conversation already provides that authorization and scope. Reading documentation does not authorize live-system inspection.
|
||||
|
||||
Answer read-only questions from the evidence found. Include paths or links that let the user verify important claims, and state when evidence may be stale or conflicting.
|
||||
|
||||
## Maintain knowledge after changes
|
||||
|
||||
For any code, configuration, infrastructure, or operational change, perform a documentation-impact check before declaring the task complete.
|
||||
|
||||
Update the wiki in the same task when the change affects durable knowledge such as:
|
||||
|
||||
- service purpose, lifecycle, entry point, authentication, permissions, dependencies, or first-use path;
|
||||
- architecture boundaries or accepted constraints;
|
||||
- deployment ownership or persistent operating behavior;
|
||||
- troubleshooting, recovery, verification, or maintenance procedures;
|
||||
- the addition, replacement, or retirement of a service.
|
||||
|
||||
Keep one-time progress, implementation narration, and release-by-release history in commits, PRs, or tickets. Do not copy them into the wiki unless they change a durable stage summary. Implementation-specific parameters may remain in the source repository README or runbook when the wiki convention says to link rather than duplicate them.
|
||||
|
||||
When editing:
|
||||
|
||||
1. Inspect both the source-repository diff and the wiki working tree before writing. Preserve unrelated user changes in both repositories.
|
||||
2. Update the page closest to the fact first, then only the navigation, indexes, constraints, or verification records that the wiki rules require.
|
||||
3. Preserve evidence metadata. Never advance `last_verified` without performing the stated live verification; ordinary review may update only fields permitted by the wiki.
|
||||
4. Link related source commits, PRs, or paths when available. Clearly mark uncommitted sources and unfinished cross-repository synchronization.
|
||||
5. Record conflicts rather than resolving them by assumption. Ask before live inspection or before choosing among materially conflicting current-state claims.
|
||||
6. Keep credentials, tokens, private keys, Terraform state, secret values, and sensitive command output out of documentation. Never read or copy known sensitive files merely to improve the wiki.
|
||||
|
||||
Wiki edits are a separate repository change. Do not commit, push, open a PR, or modify a live system unless the user has authorized that action.
|
||||
|
||||
## Verify and report
|
||||
|
||||
After editing the wiki, run from its root:
|
||||
|
||||
```bash
|
||||
python3 scripts/check_docs.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
If the checker itself changed, also run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
In the final response, report source-repository changes and wiki changes separately, including validation performed and anything still awaiting verification or cross-repository linkage. If no wiki update was needed, state the concrete reason; do not merely say that documentation was unaffected.
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
# 合并前完成全量验证,合并到 main 后不重复运行同一套检查。
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -40,32 +40,4 @@ jobs:
|
||||
cache: true
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
make lint
|
||||
make lint-database-integration
|
||||
|
||||
database-integration:
|
||||
runs-on: [self-hosted, pod]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Runner 提供本 job 可用的 Docker;workflow 只验证,不重复启动 daemon。
|
||||
- name: Verify Docker availability
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker version
|
||||
docker info --format 'Server={{.ServerVersion}} StorageDriver={{.Driver}}'
|
||||
|
||||
- name: Test Database integration with real backends
|
||||
run: make test-database-integration
|
||||
run: make lint
|
||||
|
||||
@@ -3,36 +3,10 @@
|
||||
- 本仓库是 ddupan.top homelab 的内部基础设施控制平面,不以通用发行版为初期目标。
|
||||
- 提交、文档和代码注释优先使用中文;公共 API 标识符和代码遵循对应语言惯例。
|
||||
- 不要重新实现已有成熟后端的核心能力;新增实现前先确认能否通过稳定 API 进行薄适配。
|
||||
- 不要按传统私有云或公有云产品清单推导 Ayatori 应实现的资源。新增北向 API 前必须证明 homelab
|
||||
存在真实、重复的管理缺口,现有成熟 API/IaC 不能提供足够的生命周期、状态或权限体验;“后端
|
||||
能做到”或“其他云平台提供”本身不是产品需求。
|
||||
- 当前已确认的首要产品方向是 Database、LoadBalancer、Bucket/Object Storage;VirtualMachine
|
||||
也具有明确价值,但南向实现较重。Run/Job 是验证 controller 与 adapter 的内部执行切片,不应
|
||||
自动演化为 FaaS、Cloud Run 或应用托管产品。KaaS 仅在出现真实需求时评估,不是必达终点。
|
||||
- Ayatori 复用 Kubernetes 的核心目标是 API machinery:对象存储与并发控制、list/watch、
|
||||
informer、RBAC、admission、版本化 API 和审计;不要据此推断 Ayatori 是 Kubernetes
|
||||
workload 平台,也不要默认复用 Kubernetes 的调度与数据面语义。
|
||||
- 复用 Kubernetes 内置资源只表示采用其 API contract,不表示必须运行或模拟上游实现组件。
|
||||
例如 Ayatori Compute Agent 可以直接实现 `core/v1 Node` 与 Lease 的状态语义,Ayatori
|
||||
controller 可以自行消费 Node;不得仅因使用 Node 推导必须引入 kubelet、Pod、CRI、
|
||||
kube-scheduler 或 kube-controller-manager。对每个复用资源分别明确 producer、consumer、
|
||||
ownership 与实际采用的字段语义。
|
||||
- kube-apiserver 是 Ayatori 的 API 与状态协调平面,不是领域调度器。资源的调度、生命周期、
|
||||
故障恢复、垃圾回收和后端收敛由 Ayatori controllers 实现;新增能力前应明确其属于 API
|
||||
machinery、Ayatori 领域控制循环还是外部 backend,避免把职责放错层。
|
||||
- Kubernetes、OpenSandbox、Proxmox 等均是 Ayatori 的可替换 backend/executor。除管理组件自身
|
||||
的部署外,不得仅因 controller 运行在 Kubernetes 中,就把原生 Pod、Job、Service、
|
||||
NetworkPolicy、owner reference 或同 namespace 行为作为领域 API 的隐含语义;需要这些能力时
|
||||
必须由 adapter 契约显式表达,并考虑后端位于其他集群或完全不是 Kubernetes 的情况。
|
||||
- 不要以减少自有 controller 数量为目的引入 generic-apiserver、聚合 API Server 或自行实现
|
||||
API Server。只有 CRD/kube-apiserver 在存储、API 语义或扩展能力上形成已验证的阻碍时,才评估
|
||||
接管 watch、RBAC、版本兼容和存储迁移等复杂度;controller 工作本身不会因此消失。
|
||||
- 在自行设计通用控制循环、资源生命周期、调度、回收或故障恢复机制前,先调查 Kubernetes
|
||||
核心及成熟开源 controller/operator 的实现;优先复用经过验证的模式,并记录有意偏离的
|
||||
理由。
|
||||
- 不要引入统一包装所有能力的 Application CRD;应用应直接组合正交的平台资源。
|
||||
- Proxmox VM 的北向管理不能假定单一 API 覆盖完整生命周期。允许按能力组合 Proxmox API、节点
|
||||
上的受限强类型 Agent/CLI 操作和 ManualTask;节点 Agent 不得退化为无版本契约的任意远程 shell。
|
||||
- 所有 controller 必须考虑幂等、observe、finalizer、conditions、删除策略和恢复行为。
|
||||
- Ayatori 会联动 Kubernetes API、虚拟化、存储、网络及其他外部控制面;集成测试是功能完成
|
||||
标准的一部分,不得仅凭 fake client 或 mock 测试宣告 controller、adapter 或生命周期变更完成。
|
||||
|
||||
@@ -67,14 +67,6 @@ test: manifests generate fmt vet setup-envtest ## Run tests.
|
||||
lint: golangci-lint ## Run golangci-lint linter
|
||||
"$(GOLANGCI_LINT)" run
|
||||
|
||||
.PHONY: test-database-integration
|
||||
test-database-integration: setup-envtest ## 使用临时 API server、PostgreSQL 与 OpenBao 容器验证 Database 后端。
|
||||
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -tags=integration -race -count=1 ./internal/database/...
|
||||
|
||||
.PHONY: lint-database-integration
|
||||
lint-database-integration: golangci-lint ## 检查集成测试构建标签下的 Database 代码。
|
||||
"$(GOLANGCI_LINT)" run --build-tags=integration ./internal/database/...
|
||||
|
||||
.PHONY: lint-fix
|
||||
lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
|
||||
"$(GOLANGCI_LINT)" run --fix
|
||||
|
||||
@@ -28,16 +28,11 @@ Ayatori 是 `ddupan.top` homelab 的内部基础设施控制平面。它以 Kube
|
||||
- [执行模型](docs/concepts/execution-model.md)
|
||||
- [环境与发布](docs/concepts/environments.md)
|
||||
- [路线图](docs/roadmap.md)
|
||||
- [ADR-0001:采用 Kubernetes API machinery 作为状态协调平面](docs/decisions/0001-kubernetes-api-machinery.md)
|
||||
- [ADR-0001:采用 Kubernetes API 作为资源模型](docs/decisions/0001-kubernetes-api-machinery.md)
|
||||
- [ADR-0002:采用 k0s 与可选工作负载运行时](docs/decisions/0002-k0s-optional-workload-runtime.md)
|
||||
- [ADR-0003:直接连接 Dev API 的开发循环](docs/decisions/0003-dev-api-development-loop.md)
|
||||
- [ADR-0006:按实际管理缺口扩展资源 API](docs/decisions/0006-demand-driven-resource-scope.md)
|
||||
- [ADR-0007:复用 Node API 建立按需实现的 Compute 能力](docs/decisions/0007-compute-node-and-vm-boundary.md)
|
||||
- [ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块](docs/decisions/0008-merge-postgresql-tenant-operator.md)
|
||||
- [ADR-0009:分离 Database 资源与 Tenant 申请](docs/decisions/0009-database-resource-and-claim.md)
|
||||
|
||||
## 当前状态
|
||||
|
||||
Ayatori 处于设计与早期实现阶段。当前使用 Job controller 验证第一个完整控制循环与 adapter
|
||||
边界;它不是通用 Job Service 或 FaaS 产品承诺。首批实际产品方向是 Database、LoadBalancer
|
||||
和 Bucket/Object Storage,具体顺序按纵向价值决定。
|
||||
Ayatori 处于设计与早期实现阶段。第一个纵向切片计划是统一 Job API 与 Kubernetes
|
||||
Pod executor,随后接入 OpenSandbox executor。
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package v1alpha1
|
||||
|
||||
import "k8s.io/apimachinery/pkg/types"
|
||||
|
||||
// ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=253
|
||||
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`
|
||||
type ObjectName string
|
||||
|
||||
// PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
|
||||
// +kubebuilder:validation:MaxLength=63
|
||||
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]{0,62}$`
|
||||
type PostgreSQLIdentifier string
|
||||
|
||||
// InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
|
||||
type InstanceReference struct {
|
||||
Name ObjectName `json:"name"`
|
||||
}
|
||||
|
||||
// DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase 的选择。
|
||||
type DatabaseReference struct {
|
||||
Name ObjectName `json:"name"`
|
||||
}
|
||||
|
||||
// BoundDatabaseReference 记录已经参与绑定的对象身份,而非仅记录可复用的名称。
|
||||
type BoundDatabaseReference struct {
|
||||
Name ObjectName `json:"name"`
|
||||
// +kubebuilder:validation:Type=string
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=128
|
||||
UID types.UID `json:"uid"`
|
||||
}
|
||||
|
||||
// TenantReference 是 Database 的当前绑定记录,不是允许绑定名单。
|
||||
type TenantReference struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=63
|
||||
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
|
||||
Namespace string `json:"namespace"`
|
||||
Name ObjectName `json:"name"`
|
||||
// +kubebuilder:validation:Type=string
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=128
|
||||
UID types.UID `json:"uid"`
|
||||
}
|
||||
|
||||
// ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
|
||||
// +kubebuilder:validation:Enum=Retain;Delete
|
||||
type ReclaimPolicy string
|
||||
|
||||
const (
|
||||
ReclaimRetain ReclaimPolicy = "Retain"
|
||||
ReclaimDelete ReclaimPolicy = "Delete"
|
||||
)
|
||||
|
||||
// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||
// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
||||
type CredentialReference struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=253
|
||||
Mount string `json:"mount"`
|
||||
// Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=1024
|
||||
Path string `json:"path"`
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Package v1alpha1 定义 Database 领域的 Kubernetes API。
|
||||
// +kubebuilder:object:generate=true
|
||||
// +groupName=database.ayatori.ddupan.top
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
var (
|
||||
SchemeGroupVersion = schema.GroupVersion{Group: "database.ayatori.ddupan.top", Version: "v1alpha1"}
|
||||
GroupVersion = SchemeGroupVersion
|
||||
SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(SchemeGroupVersion,
|
||||
&PostgreSQLInstance{}, &PostgreSQLInstanceList{},
|
||||
&PostgreSQLDatabase{}, &PostgreSQLDatabaseList{},
|
||||
&PostgreSQLTenant{}, &PostgreSQLTenantList{},
|
||||
)
|
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||
return nil
|
||||
})
|
||||
AddToScheme = SchemeBuilder.AddToScheme
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
// PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
|
||||
// +kubebuilder:validation:XValidation:rule="(self.source == 'Import') == has(self.credentialRef)",message="only imported databases require an existing credentialRef"
|
||||
type PostgreSQLDatabaseSpec struct {
|
||||
InstanceRef InstanceReference `json:"instanceRef"`
|
||||
Database PostgreSQLIdentifier `json:"database"`
|
||||
LoginRole PostgreSQLIdentifier `json:"loginRole"`
|
||||
// Source 明确区分创建与只读导入,不从后端同名对象推断。
|
||||
// +kubebuilder:validation:Enum=Provision;Import
|
||||
Source string `json:"source"`
|
||||
// +optional
|
||||
CredentialRef *CredentialReference `json:"credentialRef,omitempty"`
|
||||
// +kubebuilder:default=Retain
|
||||
// +optional
|
||||
ReclaimPolicy ReclaimPolicy `json:"reclaimPolicy,omitempty"`
|
||||
// TenantRef 由 controller 先写入;Released 时仍保留旧身份。
|
||||
// +optional
|
||||
TenantRef *TenantReference `json:"tenantRef,omitempty"`
|
||||
}
|
||||
|
||||
type PostgreSQLDatabaseStatus struct {
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||
// +optional
|
||||
InstanceUID types.UID `json:"instanceUID,omitempty"`
|
||||
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
||||
// +optional
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster
|
||||
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts"
|
||||
// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.spec.instanceRef.name`
|
||||
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
||||
type PostgreSQLDatabase struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
||||
Spec PostgreSQLDatabaseSpec `json:"spec"`
|
||||
// +optional
|
||||
Status PostgreSQLDatabaseStatus `json:"status,omitzero"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
type PostgreSQLDatabaseList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitzero"`
|
||||
Items []PostgreSQLDatabase `json:"items"`
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package v1alpha1
|
||||
|
||||
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
// PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
|
||||
type PostgreSQLEndpoint struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=253
|
||||
Host string `json:"host"`
|
||||
// +kubebuilder:validation:MaxLength=45
|
||||
// +kubebuilder:validation:XValidation:rule="isIP(self)",message="hostaddr must be a single IPv4 or IPv6 address"
|
||||
HostAddr string `json:"hostaddr"`
|
||||
// +kubebuilder:default=5432
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=65535
|
||||
// +optional
|
||||
Port int32 `json:"port,omitempty"`
|
||||
// +kubebuilder:default=postgres
|
||||
// +optional
|
||||
Database PostgreSQLIdentifier `json:"database,omitempty"`
|
||||
// +kubebuilder:default=verify-full
|
||||
// +kubebuilder:validation:Enum=disable;require;verify-ca;verify-full
|
||||
// +optional
|
||||
SSLMode string `json:"sslMode,omitempty"`
|
||||
}
|
||||
|
||||
// AdminCredentialReference 只能读取 controller namespace 的 Secret。
|
||||
type AdminCredentialReference struct {
|
||||
Name ObjectName `json:"name"`
|
||||
// +kubebuilder:default=username
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=253
|
||||
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
|
||||
// +optional
|
||||
UsernameKey string `json:"usernameKey,omitempty"`
|
||||
// +kubebuilder:default=password
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=253
|
||||
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
|
||||
// +optional
|
||||
PasswordKey string `json:"passwordKey,omitempty"`
|
||||
}
|
||||
|
||||
type PostgreSQLInstanceSpec struct {
|
||||
Endpoint PostgreSQLEndpoint `json:"endpoint"`
|
||||
AdminCredentialRef AdminCredentialReference `json:"adminCredentialRef"`
|
||||
}
|
||||
|
||||
type PostgreSQLInstanceStatus struct {
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// +kubebuilder:validation:Enum=Pending;Validating;Ready;Deleting
|
||||
// +optional
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// +optional
|
||||
PostgreSQLVersion string `json:"postgresqlVersion,omitempty"`
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
||||
type PostgreSQLInstance struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
||||
Spec PostgreSQLInstanceSpec `json:"spec"`
|
||||
// +optional
|
||||
Status PostgreSQLInstanceStatus `json:"status,omitzero"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
type PostgreSQLInstanceList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitzero"`
|
||||
Items []PostgreSQLInstance `json:"items"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package v1alpha1
|
||||
|
||||
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
// DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按 Tenant 名称解析。
|
||||
type DatabaseProvisionRequest struct {
|
||||
InstanceRef InstanceReference `json:"instanceRef"`
|
||||
// +optional
|
||||
Database PostgreSQLIdentifier `json:"database,omitempty"`
|
||||
// +optional
|
||||
LoginRole PostgreSQLIdentifier `json:"loginRole,omitempty"`
|
||||
}
|
||||
|
||||
// PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.provision) != has(self.databaseRef)",message="exactly one of provision and databaseRef is required"
|
||||
type PostgreSQLTenantSpec struct {
|
||||
// +optional
|
||||
Provision *DatabaseProvisionRequest `json:"provision,omitempty"`
|
||||
// +optional
|
||||
DatabaseRef *DatabaseReference `json:"databaseRef,omitempty"`
|
||||
// Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
|
||||
// +listType=set
|
||||
// +optional
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
// SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
|
||||
// +optional
|
||||
SecretName ObjectName `json:"secretName,omitempty"`
|
||||
}
|
||||
|
||||
type PostgreSQLTenantStatus struct {
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// DatabaseRef 只有在资源侧确认绑定后才写入。
|
||||
// +optional
|
||||
DatabaseRef *BoundDatabaseReference `json:"databaseRef,omitempty"`
|
||||
// +optional
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
|
||||
// +optional
|
||||
SecretName ObjectName `json:"secretName,omitempty"`
|
||||
// CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
|
||||
// +optional
|
||||
CredentialURL string `json:"credentialURL,omitempty"`
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Namespaced
|
||||
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || ((has(self.spec.provision) == has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision == oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef)) && (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))",message="binding target cannot change after binding starts"
|
||||
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || (has(self.status) && has(self.status.phase) && self.status.phase in ['Binding', 'Bound', 'Deleting'])",message="binding progress cannot return to an unbound state"
|
||||
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.status.databaseRef.name`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
||||
type PostgreSQLTenant struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
||||
Spec PostgreSQLTenantSpec `json:"spec"`
|
||||
// +optional
|
||||
Status PostgreSQLTenantStatus `json:"status,omitzero"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
type PostgreSQLTenantList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitzero"`
|
||||
Items []PostgreSQLTenant `json:"items"`
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package v1alpha1_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apimachinery/pkg/util/yaml"
|
||||
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
)
|
||||
|
||||
const (
|
||||
testNamespace = "database-api"
|
||||
testInstanceName = "shared-postgres"
|
||||
readyPhase = "Ready"
|
||||
)
|
||||
|
||||
func TestDatabaseAPI(t *testing.T) {
|
||||
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
|
||||
t.Skip("KUBEBUILDER_ASSETS 未设置;运行 make test 执行真实 API server 测试")
|
||||
}
|
||||
scheme := runtime.NewScheme()
|
||||
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
crdPath, err := filepath.Abs("../../../config/crd/bases")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
|
||||
config, err := environment.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("启动 envtest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := environment.Stop(); err != nil {
|
||||
t.Errorf("停止 envtest: %v", err)
|
||||
}
|
||||
})
|
||||
client, err := ctrlclient.New(config, ctrlclient.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespace := &corev1.Namespace{}
|
||||
namespace.Name = testNamespace
|
||||
if err := client.Create(t.Context(), namespace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
|
||||
t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) })
|
||||
t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(t, client) })
|
||||
t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) })
|
||||
}
|
||||
|
||||
func testSamples(t *testing.T, client ctrlclient.Client, scheme *runtime.Scheme) {
|
||||
paths := []string{
|
||||
"database_v1alpha1_postgresqlinstance.yaml",
|
||||
"database_v1alpha1_postgresqldatabase.yaml",
|
||||
"database_v1alpha1_postgresqltenant.yaml",
|
||||
}
|
||||
for _, name := range paths {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
file, err := os.Open(filepath.Join("../../../config/samples", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := file.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
decoder := yaml.NewYAMLOrJSONDecoder(file, 4096)
|
||||
for {
|
||||
var raw runtime.RawExtension
|
||||
if err := decoder.Decode(&raw); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object, _, err := serializer.NewCodecFactory(scheme).UniversalDeserializer().Decode(raw.Raw, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource, ok := object.(ctrlclient.Object)
|
||||
if !ok {
|
||||
t.Fatalf("示例不是资源对象: %T", object)
|
||||
}
|
||||
if resource.GetNamespace() != "" {
|
||||
resource.SetNamespace(testNamespace)
|
||||
}
|
||||
if err := client.Create(t.Context(), resource); err != nil {
|
||||
t.Fatalf("示例未通过 API 校验: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testDefaults(t *testing.T, client ctrlclient.Client) {
|
||||
instance := validInstance("defaults")
|
||||
if err := client.Create(t.Context(), instance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint := instance.Spec.Endpoint
|
||||
if endpoint.Port != 5432 || endpoint.Database != "postgres" || endpoint.SSLMode != "verify-full" {
|
||||
t.Fatalf("连接默认值不符: %+v", endpoint)
|
||||
}
|
||||
credentials := instance.Spec.AdminCredentialRef
|
||||
if credentials.UsernameKey != "username" || credentials.PasswordKey != "password" {
|
||||
t.Fatal("管理 Secret 字段默认值不符")
|
||||
}
|
||||
database := validDatabase("defaults")
|
||||
if err := client.Create(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain {
|
||||
t.Fatalf("默认回收策略 = %q", database.Spec.ReclaimPolicy)
|
||||
}
|
||||
// 进入删除流程前允许双向修改策略,不要求第二次审批字段。
|
||||
for _, policy := range []databasev1alpha1.ReclaimPolicy{databasev1alpha1.ReclaimDelete, databasev1alpha1.ReclaimRetain} {
|
||||
database.Spec.ReclaimPolicy = policy
|
||||
if err := client.Update(t.Context(), database); err != nil {
|
||||
t.Fatalf("修改回收策略: %v", err)
|
||||
}
|
||||
}
|
||||
objects := []struct {
|
||||
object ctrlclient.Object
|
||||
namespaced bool
|
||||
}{
|
||||
{instance, false}, {database, false}, {validTenant("scope"), true},
|
||||
}
|
||||
for _, item := range objects {
|
||||
namespaced, err := client.IsObjectNamespaced(item.object)
|
||||
if err != nil || namespaced != item.namespaced {
|
||||
t.Fatalf("%T 作用域 = %v, error = %v", item.object, namespaced, err)
|
||||
}
|
||||
}
|
||||
// 导入不要求 Tenant 或 Instance 对象已经存在,跨对象就绪由 controller 判断。
|
||||
imported := validDatabase("imported")
|
||||
imported.Spec.Source = "Import"
|
||||
imported.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"}
|
||||
if err := client.Create(t.Context(), imported); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"first", "second"} {
|
||||
tenant := validTenant(name)
|
||||
tenant.Spec.Provision = nil
|
||||
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "imported"}
|
||||
if err := client.Create(t.Context(), tenant); err != nil {
|
||||
t.Fatalf("声明已有资源申请: %v", err)
|
||||
}
|
||||
}
|
||||
// 两个申请都可被 API 接受,不代表二者都已绑定或获得凭据。
|
||||
}
|
||||
|
||||
func testInvalidDeclarations(t *testing.T, client ctrlclient.Client) {
|
||||
instanceCases := []struct {
|
||||
name string
|
||||
mutate func(*databasev1alpha1.PostgreSQLInstance)
|
||||
}{
|
||||
{"port", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Port = -1 }},
|
||||
{"address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "localhost" }},
|
||||
{"scoped-address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "fe80::1%eth0" }},
|
||||
{"tls", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.SSLMode = "prefer" }},
|
||||
{"identifier", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Database = "bad-name" }},
|
||||
{"secret-key", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.AdminCredentialRef.PasswordKey = "bad/key" }},
|
||||
}
|
||||
for _, tc := range instanceCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
object := validInstance(tc.name)
|
||||
tc.mutate(object)
|
||||
requireInvalidCreate(t, client, object)
|
||||
})
|
||||
}
|
||||
databaseCases := []struct {
|
||||
name string
|
||||
mutate func(*databasev1alpha1.PostgreSQLDatabase)
|
||||
}{
|
||||
{"missing-instance", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.InstanceRef.Name = "" }},
|
||||
{"missing-role", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.LoginRole = "" }},
|
||||
{"unknown-source", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Adopt" }},
|
||||
{"missing-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Import" }},
|
||||
{"provision-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) {
|
||||
d.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing"}
|
||||
}},
|
||||
{"unknown-policy", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.ReclaimPolicy = "Recycle" }},
|
||||
{"binding-without-uid", func(d *databasev1alpha1.PostgreSQLDatabase) {
|
||||
d.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: testNamespace, Name: "tenant"}
|
||||
}},
|
||||
}
|
||||
for _, tc := range databaseCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
object := validDatabase(tc.name)
|
||||
tc.mutate(object)
|
||||
requireInvalidCreate(t, client, object)
|
||||
})
|
||||
}
|
||||
t.Run("互斥申请入口", func(t *testing.T) {
|
||||
tenant := validTenant("ambiguous")
|
||||
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "existing"}
|
||||
requireInvalidCreate(t, client, tenant)
|
||||
tenant.Spec.Provision = nil
|
||||
tenant.Spec.DatabaseRef = nil
|
||||
requireInvalidCreate(t, client, tenant)
|
||||
})
|
||||
}
|
||||
|
||||
// 本测试验证 API 写入语义,不模拟或宣称已经实现 controller 的恢复循环。
|
||||
func testBindingWrites(t *testing.T, client ctrlclient.Client) {
|
||||
ctx := t.Context()
|
||||
tenant := validTenant("binding")
|
||||
tenant.Status.Phase = readyPhase
|
||||
if err := client.Create(ctx, tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tenant.Status.Phase != "" {
|
||||
t.Fatal("普通 Create 不应写入 status")
|
||||
}
|
||||
database := validDatabase("binding")
|
||||
if err := client.Create(ctx, database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := database.DeepCopy()
|
||||
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
|
||||
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
|
||||
}
|
||||
if err := client.Update(ctx, database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: tenant.Namespace, Name: "other", UID: "other-uid"}
|
||||
if err := client.Update(ctx, stale); !apierrors.IsConflict(err) {
|
||||
t.Fatalf("过期并发写入 = %v, want Conflict", err)
|
||||
}
|
||||
// 换用 API 回读的对象补第二步,证明恢复所需记录不依赖先前内存。
|
||||
observedDatabase := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
if err := client.Get(ctx, ctrlclient.ObjectKeyFromObject(database), observedDatabase); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observedDatabase.Spec.TenantRef.UID != tenant.UID {
|
||||
t.Fatal("资源侧绑定被竞争写入覆盖")
|
||||
}
|
||||
beforeGeneration := tenant.Generation
|
||||
tenant.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
|
||||
Name: databasev1alpha1.ObjectName(database.Name), UID: database.UID,
|
||||
}
|
||||
if err := client.Status().Update(ctx, tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tenant.Generation != beforeGeneration || tenant.Status.DatabaseRef.UID != database.UID {
|
||||
t.Fatal("status 更新错误地影响 generation 或绑定身份")
|
||||
}
|
||||
tenant.Status.Phase = readyPhase
|
||||
if err := client.Update(ctx, tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tenant.Status.Phase != "" {
|
||||
t.Fatal("普通 Update 不应修改 status")
|
||||
}
|
||||
condition := metav1.Condition{Type: readyPhase, Status: metav1.ConditionFalse,
|
||||
Reason: "Pending", Message: "尚未验证后端", LastTransitionTime: metav1.Now()}
|
||||
tenant.Status.Conditions = []metav1.Condition{condition, condition}
|
||||
if err := client.Status().Update(ctx, tenant); !apierrors.IsInvalid(err) {
|
||||
t.Fatalf("重复 Condition = %v, want Invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireInvalidCreate(t *testing.T, client ctrlclient.Client, object ctrlclient.Object) {
|
||||
t.Helper()
|
||||
if err := client.Create(context.Background(), object); !apierrors.IsInvalid(err) {
|
||||
t.Fatalf("Create %T = %v, want Invalid", object, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validInstance(name string) *databasev1alpha1.PostgreSQLInstance {
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
object.Name = name
|
||||
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
|
||||
object.Spec.AdminCredentialRef.Name = "postgres-admin"
|
||||
return object
|
||||
}
|
||||
|
||||
func validDatabase(name string) *databasev1alpha1.PostgreSQLDatabase {
|
||||
object := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
object.Name = name
|
||||
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
|
||||
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
|
||||
Database: "app", LoginRole: "app", Source: "Provision",
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func validTenant(name string) *databasev1alpha1.PostgreSQLTenant {
|
||||
object := &databasev1alpha1.PostgreSQLTenant{}
|
||||
object.Name = name
|
||||
object.Namespace = testNamespace
|
||||
object.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
|
||||
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
|
||||
}
|
||||
return object
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *AdminCredentialReference) DeepCopyInto(out *AdminCredentialReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdminCredentialReference.
|
||||
func (in *AdminCredentialReference) DeepCopy() *AdminCredentialReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(AdminCredentialReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BoundDatabaseReference) DeepCopyInto(out *BoundDatabaseReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BoundDatabaseReference.
|
||||
func (in *BoundDatabaseReference) DeepCopy() *BoundDatabaseReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BoundDatabaseReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CredentialReference) DeepCopyInto(out *CredentialReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialReference.
|
||||
func (in *CredentialReference) DeepCopy() *CredentialReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(CredentialReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DatabaseProvisionRequest) DeepCopyInto(out *DatabaseProvisionRequest) {
|
||||
*out = *in
|
||||
out.InstanceRef = in.InstanceRef
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseProvisionRequest.
|
||||
func (in *DatabaseProvisionRequest) DeepCopy() *DatabaseProvisionRequest {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DatabaseProvisionRequest)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DatabaseReference) DeepCopyInto(out *DatabaseReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseReference.
|
||||
func (in *DatabaseReference) DeepCopy() *DatabaseReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DatabaseReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *InstanceReference) DeepCopyInto(out *InstanceReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceReference.
|
||||
func (in *InstanceReference) DeepCopy() *InstanceReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(InstanceReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLDatabase) DeepCopyInto(out *PostgreSQLDatabase) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabase.
|
||||
func (in *PostgreSQLDatabase) DeepCopy() *PostgreSQLDatabase {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLDatabase)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLDatabase) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLDatabaseList) DeepCopyInto(out *PostgreSQLDatabaseList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]PostgreSQLDatabase, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseList.
|
||||
func (in *PostgreSQLDatabaseList) DeepCopy() *PostgreSQLDatabaseList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLDatabaseList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLDatabaseList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLDatabaseSpec) DeepCopyInto(out *PostgreSQLDatabaseSpec) {
|
||||
*out = *in
|
||||
out.InstanceRef = in.InstanceRef
|
||||
if in.CredentialRef != nil {
|
||||
in, out := &in.CredentialRef, &out.CredentialRef
|
||||
*out = new(CredentialReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.TenantRef != nil {
|
||||
in, out := &in.TenantRef, &out.TenantRef
|
||||
*out = new(TenantReference)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseSpec.
|
||||
func (in *PostgreSQLDatabaseSpec) DeepCopy() *PostgreSQLDatabaseSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLDatabaseSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseStatus.
|
||||
func (in *PostgreSQLDatabaseStatus) DeepCopy() *PostgreSQLDatabaseStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLDatabaseStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLEndpoint) DeepCopyInto(out *PostgreSQLEndpoint) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLEndpoint.
|
||||
func (in *PostgreSQLEndpoint) DeepCopy() *PostgreSQLEndpoint {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLEndpoint)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLInstance) DeepCopyInto(out *PostgreSQLInstance) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstance.
|
||||
func (in *PostgreSQLInstance) DeepCopy() *PostgreSQLInstance {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLInstance)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLInstance) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLInstanceList) DeepCopyInto(out *PostgreSQLInstanceList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]PostgreSQLInstance, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceList.
|
||||
func (in *PostgreSQLInstanceList) DeepCopy() *PostgreSQLInstanceList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLInstanceList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLInstanceList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLInstanceSpec) DeepCopyInto(out *PostgreSQLInstanceSpec) {
|
||||
*out = *in
|
||||
out.Endpoint = in.Endpoint
|
||||
out.AdminCredentialRef = in.AdminCredentialRef
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceSpec.
|
||||
func (in *PostgreSQLInstanceSpec) DeepCopy() *PostgreSQLInstanceSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLInstanceSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLInstanceStatus) DeepCopyInto(out *PostgreSQLInstanceStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceStatus.
|
||||
func (in *PostgreSQLInstanceStatus) DeepCopy() *PostgreSQLInstanceStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLInstanceStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLTenant) DeepCopyInto(out *PostgreSQLTenant) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenant.
|
||||
func (in *PostgreSQLTenant) DeepCopy() *PostgreSQLTenant {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLTenant)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLTenant) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLTenantList) DeepCopyInto(out *PostgreSQLTenantList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]PostgreSQLTenant, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantList.
|
||||
func (in *PostgreSQLTenantList) DeepCopy() *PostgreSQLTenantList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLTenantList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PostgreSQLTenantList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLTenantSpec) DeepCopyInto(out *PostgreSQLTenantSpec) {
|
||||
*out = *in
|
||||
if in.Provision != nil {
|
||||
in, out := &in.Provision, &out.Provision
|
||||
*out = new(DatabaseProvisionRequest)
|
||||
**out = **in
|
||||
}
|
||||
if in.DatabaseRef != nil {
|
||||
in, out := &in.DatabaseRef, &out.DatabaseRef
|
||||
*out = new(DatabaseReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.Extensions != nil {
|
||||
in, out := &in.Extensions, &out.Extensions
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantSpec.
|
||||
func (in *PostgreSQLTenantSpec) DeepCopy() *PostgreSQLTenantSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLTenantSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PostgreSQLTenantStatus) DeepCopyInto(out *PostgreSQLTenantStatus) {
|
||||
*out = *in
|
||||
if in.DatabaseRef != nil {
|
||||
in, out := &in.DatabaseRef, &out.DatabaseRef
|
||||
*out = new(BoundDatabaseReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantStatus.
|
||||
func (in *PostgreSQLTenantStatus) DeepCopy() *PostgreSQLTenantStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PostgreSQLTenantStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TenantReference) DeepCopyInto(out *TenantReference) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantReference.
|
||||
func (in *TenantReference) DeepCopy() *TenantReference {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TenantReference)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
)
|
||||
|
||||
func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) {
|
||||
credentials, err := kubernetes.NewSecretCredentials(manager.GetConfig(), namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := application.NewInstanceService(credentials, postgresql.Connector{RootCert: rootCert})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: namespace}
|
||||
if err := reconciler.SetupWithManager(manager); err != nil {
|
||||
service.Close()
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
+10
-32
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"os"
|
||||
@@ -20,10 +19,8 @@ import (
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/controller"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
@@ -36,16 +33,11 @@ func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
|
||||
utilruntime.Must(executionv1alpha1.AddToScheme(scheme))
|
||||
utilruntime.Must(databasev1alpha1.AddToScheme(scheme))
|
||||
// +kubebuilder:scaffold:scheme
|
||||
}
|
||||
|
||||
// nolint:gocyclo
|
||||
func main() {
|
||||
var databaseNamespace, databaseRootCert string
|
||||
flag.StringVar(&databaseNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"),
|
||||
"固定管理 Secret namespace;为空时不启用 Instance 观测")
|
||||
flag.StringVar(&databaseRootCert, "database-root-cert", "", "PostgreSQL 管理连接信任的公开 CA bundle 路径")
|
||||
var metricsAddr string
|
||||
var metricsCertPath, metricsCertName, metricsCertKey string
|
||||
var webhookCertPath, webhookCertName, webhookCertKey string
|
||||
@@ -150,7 +142,7 @@ func main() {
|
||||
metricsServerOptions.KeyName = metricsCertKey
|
||||
}
|
||||
|
||||
managerOptions := ctrl.Options{
|
||||
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsServerOptions,
|
||||
WebhookServer: webhookServer,
|
||||
@@ -168,30 +160,21 @@ func main() {
|
||||
// if you are doing or is intended to do any operation such as perform cleanups
|
||||
// after the manager stops then its usage might be unsafe.
|
||||
// LeaderElectionReleaseOnCancel: true,
|
||||
}
|
||||
if databaseNamespace != "" {
|
||||
managerOptions.Cache = databasecontroller.InstanceCacheOptions(databaseNamespace)
|
||||
}
|
||||
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), managerOptions)
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "Failed to start manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
var instanceService *application.InstanceService
|
||||
if databaseNamespace != "" {
|
||||
instanceService, err = setupInstanceObservation(mgr, databaseNamespace, databaseRootCert)
|
||||
if err != nil {
|
||||
setupLog.Error(err, "Failed to set up Instance observation")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := (&databasecontroller.BindingReconciler{}).SetupWithManager(context.Background(), mgr); err != nil {
|
||||
setupLog.Error(err, "Failed to set up Database binding controller")
|
||||
if err := (&controller.JobReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "Failed to create controller", "controller", "Job")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
|
||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "Failed to set up health check")
|
||||
os.Exit(1)
|
||||
@@ -202,12 +185,7 @@ func main() {
|
||||
}
|
||||
|
||||
setupLog.Info("Starting manager")
|
||||
err = mgr.Start(ctrl.SetupSignalHandler())
|
||||
// worker 完全停止后才释放 pgxpool,避免与在途观察竞争。
|
||||
if instanceService != nil {
|
||||
instanceService.Close()
|
||||
}
|
||||
if err != nil {
|
||||
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
|
||||
setupLog.Error(err, "Failed to run manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.22.0
|
||||
name: postgresqldatabases.database.ayatori.ddupan.top
|
||||
spec:
|
||||
group: database.ayatori.ddupan.top
|
||||
names:
|
||||
kind: PostgreSQLDatabase
|
||||
listKind: PostgreSQLDatabaseList
|
||||
plural: postgresqldatabases
|
||||
singular: postgresqldatabase
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .spec.instanceRef.name
|
||||
name: Instance
|
||||
type: string
|
||||
- jsonPath: .spec.database
|
||||
name: Database
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=='Ready')].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
|
||||
properties:
|
||||
credentialRef:
|
||||
description: |-
|
||||
CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||
只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
||||
properties:
|
||||
mount:
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
type: string
|
||||
path:
|
||||
description: Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- mount
|
||||
- path
|
||||
type: object
|
||||
database:
|
||||
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
|
||||
maxLength: 63
|
||||
pattern: ^[a-z][a-z0-9_]{0,62}$
|
||||
type: string
|
||||
instanceRef:
|
||||
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
loginRole:
|
||||
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
|
||||
maxLength: 63
|
||||
pattern: ^[a-z][a-z0-9_]{0,62}$
|
||||
type: string
|
||||
reclaimPolicy:
|
||||
default: Retain
|
||||
description: ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
|
||||
enum:
|
||||
- Retain
|
||||
- Delete
|
||||
type: string
|
||||
source:
|
||||
description: Source 明确区分创建与只读导入,不从后端同名对象推断。
|
||||
enum:
|
||||
- Provision
|
||||
- Import
|
||||
type: string
|
||||
tenantRef:
|
||||
description: TenantRef 由 controller 先写入;Released 时仍保留旧身份。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
namespace:
|
||||
maxLength: 63
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
|
||||
type: string
|
||||
uid:
|
||||
description: |-
|
||||
UID is a type that holds unique ID values, including UUIDs. Because we
|
||||
don't ONLY use UUIDs, this is an alias to string. Being a type captures
|
||||
intent and helps make sure that UIDs and names do not get conflated.
|
||||
maxLength: 128
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- namespace
|
||||
- uid
|
||||
type: object
|
||||
required:
|
||||
- database
|
||||
- instanceRef
|
||||
- loginRole
|
||||
- source
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: only imported databases require an existing credentialRef
|
||||
rule: (self.source == 'Import') == has(self.credentialRef)
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
instanceUID:
|
||||
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||
type: string
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
phase:
|
||||
description: Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: managed database target cannot change after observation or binding
|
||||
starts
|
||||
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID)))
|
||||
|| (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database
|
||||
== oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole
|
||||
&& self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef)
|
||||
== has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|
||||
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.22.0
|
||||
name: postgresqlinstances.database.ayatori.ddupan.top
|
||||
spec:
|
||||
group: database.ayatori.ddupan.top
|
||||
names:
|
||||
kind: PostgreSQLInstance
|
||||
listKind: PostgreSQLInstanceList
|
||||
plural: postgresqlinstances
|
||||
singular: postgresqlinstance
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .status.conditions[?(@.type=='Ready')].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
properties:
|
||||
adminCredentialRef:
|
||||
description: AdminCredentialReference 只能读取 controller namespace 的
|
||||
Secret。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
passwordKey:
|
||||
default: password
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[-._a-zA-Z0-9]+$
|
||||
type: string
|
||||
usernameKey:
|
||||
default: username
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[-._a-zA-Z0-9]+$
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
endpoint:
|
||||
description: PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
|
||||
properties:
|
||||
database:
|
||||
default: postgres
|
||||
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
|
||||
使用的名称。
|
||||
maxLength: 63
|
||||
pattern: ^[a-z][a-z0-9_]{0,62}$
|
||||
type: string
|
||||
host:
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
type: string
|
||||
hostaddr:
|
||||
maxLength: 45
|
||||
type: string
|
||||
x-kubernetes-validations:
|
||||
- message: hostaddr must be a single IPv4 or IPv6 address
|
||||
rule: isIP(self)
|
||||
port:
|
||||
default: 5432
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
sslMode:
|
||||
default: verify-full
|
||||
enum:
|
||||
- disable
|
||||
- require
|
||||
- verify-ca
|
||||
- verify-full
|
||||
type: string
|
||||
required:
|
||||
- host
|
||||
- hostaddr
|
||||
type: object
|
||||
required:
|
||||
- adminCredentialRef
|
||||
- endpoint
|
||||
type: object
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
phase:
|
||||
enum:
|
||||
- Pending
|
||||
- Validating
|
||||
- Ready
|
||||
- Deleting
|
||||
type: string
|
||||
postgresqlVersion:
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -1,223 +0,0 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.22.0
|
||||
name: postgresqltenants.database.ayatori.ddupan.top
|
||||
spec:
|
||||
group: database.ayatori.ddupan.top
|
||||
names:
|
||||
kind: PostgreSQLTenant
|
||||
listKind: PostgreSQLTenantList
|
||||
plural: postgresqltenants
|
||||
singular: postgresqltenant
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .status.databaseRef.name
|
||||
name: Database
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=='Ready')].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
|
||||
properties:
|
||||
databaseRef:
|
||||
description: DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase
|
||||
的选择。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
extensions:
|
||||
description: Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: set
|
||||
provision:
|
||||
description: DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按
|
||||
Tenant 名称解析。
|
||||
properties:
|
||||
database:
|
||||
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
|
||||
使用的名称。
|
||||
maxLength: 63
|
||||
pattern: ^[a-z][a-z0-9_]{0,62}$
|
||||
type: string
|
||||
instanceRef:
|
||||
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group
|
||||
引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
loginRole:
|
||||
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
|
||||
使用的名称。
|
||||
maxLength: 63
|
||||
pattern: ^[a-z][a-z0-9_]{0,62}$
|
||||
type: string
|
||||
required:
|
||||
- instanceRef
|
||||
type: object
|
||||
secretName:
|
||||
description: SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: exactly one of provision and databaseRef is required
|
||||
rule: has(self.provision) != has(self.databaseRef)
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
credentialURL:
|
||||
description: CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
|
||||
type: string
|
||||
databaseRef:
|
||||
description: DatabaseRef 只有在资源侧确认绑定后才写入。
|
||||
properties:
|
||||
name:
|
||||
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
uid:
|
||||
description: |-
|
||||
UID is a type that holds unique ID values, including UUIDs. Because we
|
||||
don't ONLY use UUIDs, this is an alias to string. Being a type captures
|
||||
intent and helps make sure that UIDs and names do not get conflated.
|
||||
maxLength: 128
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- uid
|
||||
type: object
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
secretName:
|
||||
description: SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
|
||||
maxLength: 253
|
||||
minLength: 1
|
||||
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: binding target cannot change after binding starts
|
||||
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
|
||||
in [''Binding'', ''Bound'', ''Deleting'']) || ((has(self.spec.provision)
|
||||
== has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision
|
||||
== oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef))
|
||||
&& (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))'
|
||||
- message: binding progress cannot return to an unbound state
|
||||
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
|
||||
in [''Binding'', ''Bound'', ''Deleting'']) || (has(self.status) && has(self.status.phase)
|
||||
&& self.status.phase in [''Binding'', ''Bound'', ''Deleting''])'
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -2,9 +2,6 @@
|
||||
# since it depends on service name and namespace that are out of this kustomize package.
|
||||
# It should be run by config/default
|
||||
resources:
|
||||
- bases/database.ayatori.ddupan.top_postgresqlinstances.yaml
|
||||
- bases/database.ayatori.ddupan.top_postgresqldatabases.yaml
|
||||
- bases/database.ayatori.ddupan.top_postgresqltenants.yaml
|
||||
- bases/execution.ayatori.ddupan.top_jobs.yaml
|
||||
- bases/execution.ayatori.ddupan.top_jobclasses.yaml
|
||||
- bases/execution.ayatori.ddupan.top_kubernetesexecutionparameters.yaml
|
||||
|
||||
@@ -65,11 +65,6 @@ spec:
|
||||
- --health-probe-bind-address=:8081
|
||||
image: controller:latest
|
||||
name: manager
|
||||
env:
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
ports:
|
||||
- containerPort: 8081
|
||||
name: health
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: database-management-credentials
|
||||
namespace: system
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [get, list, watch]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: database-management-credentials
|
||||
namespace: system
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: database-management-credentials
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: controller-manager
|
||||
namespace: system
|
||||
@@ -7,7 +7,6 @@ resources:
|
||||
- service_account.yaml
|
||||
- role.yaml
|
||||
- role_binding.yaml
|
||||
- database_credentials_role.yaml
|
||||
- leader_election_role.yaml
|
||||
- leader_election_role_binding.yaml
|
||||
# The following RBAC configurations are used to protect
|
||||
|
||||
+33
-21
@@ -5,42 +5,54 @@ metadata:
|
||||
name: manager-role
|
||||
rules:
|
||||
- apiGroups:
|
||||
- database.ayatori.ddupan.top
|
||||
- ""
|
||||
resources:
|
||||
- postgresqldatabases
|
||||
- namespaces
|
||||
- serviceaccounts
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- jobs
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- execution.ayatori.ddupan.top
|
||||
resources:
|
||||
- jobclasses
|
||||
- kubernetesexecutionparameters
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- execution.ayatori.ddupan.top
|
||||
resources:
|
||||
- jobs
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
- apiGroups:
|
||||
- database.ayatori.ddupan.top
|
||||
- execution.ayatori.ddupan.top
|
||||
resources:
|
||||
- postgresqldatabases/finalizers
|
||||
- postgresqlinstances/finalizers
|
||||
- postgresqltenants/finalizers
|
||||
- jobs/finalizers
|
||||
verbs:
|
||||
- update
|
||||
- apiGroups:
|
||||
- database.ayatori.ddupan.top
|
||||
- execution.ayatori.ddupan.top
|
||||
resources:
|
||||
- postgresqldatabases/status
|
||||
- postgresqlinstances/status
|
||||
- postgresqltenants/status
|
||||
- jobs/status
|
||||
verbs:
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
- apiGroups:
|
||||
- database.ayatori.ddupan.top
|
||||
resources:
|
||||
- postgresqlinstances
|
||||
- postgresqltenants
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# 管理员登记已有数据库;不会因创建 CR 就修改数据库或凭据。
|
||||
apiVersion: database.ayatori.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLDatabase
|
||||
metadata:
|
||||
name: imported-app
|
||||
spec:
|
||||
instanceRef:
|
||||
name: shared-postgres
|
||||
database: existing_app
|
||||
loginRole: existing_app
|
||||
source: Import
|
||||
credentialRef:
|
||||
mount: secret
|
||||
path: existing/app/postgresql
|
||||
reclaimPolicy: Retain
|
||||
@@ -1,11 +0,0 @@
|
||||
# Instance 观察 controller 尚未接入;管理 Secret 由管理员在 controller namespace 提供。
|
||||
apiVersion: database.ayatori.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLInstance
|
||||
metadata:
|
||||
name: shared-postgres
|
||||
spec:
|
||||
endpoint:
|
||||
host: postgres.example.test
|
||||
hostaddr: 192.0.2.10
|
||||
adminCredentialRef:
|
||||
name: shared-postgres-admin
|
||||
@@ -1,25 +0,0 @@
|
||||
# 二选一:动态申请或显式引用已有 Database;当前只有绑定协调,没有供应/交付 controller。
|
||||
apiVersion: database.ayatori.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLTenant
|
||||
metadata:
|
||||
name: new-app
|
||||
namespace: default
|
||||
spec:
|
||||
provision:
|
||||
instanceRef:
|
||||
name: shared-postgres
|
||||
database: new_app
|
||||
loginRole: new_app
|
||||
extensions:
|
||||
- pgcrypto
|
||||
secretName: new-app-postgresql
|
||||
---
|
||||
apiVersion: database.ayatori.ddupan.top/v1alpha1
|
||||
kind: PostgreSQLTenant
|
||||
metadata:
|
||||
name: existing-app
|
||||
namespace: default
|
||||
spec:
|
||||
databaseRef:
|
||||
name: imported-app
|
||||
secretName: existing-app-postgresql
|
||||
@@ -1,8 +1,5 @@
|
||||
## Append samples of your project ##
|
||||
resources:
|
||||
- database_v1alpha1_postgresqlinstance.yaml
|
||||
- database_v1alpha1_postgresqldatabase.yaml
|
||||
- database_v1alpha1_postgresqltenant.yaml
|
||||
- execution_v1alpha1_job.yaml
|
||||
- execution_v1alpha1_jobclass.yaml
|
||||
- execution_v1alpha1_kubernetesexecutionparameters.yaml
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
Git / CLI / Backstage
|
||||
│
|
||||
▼
|
||||
kube-apiserver + etcd + CRD
|
||||
API / state coordination plane
|
||||
Kubernetes API + CRD
|
||||
│
|
||||
Ayatori controller-manager
|
||||
scheduling / lifecycle / recovery / GC
|
||||
Ayatori controllers
|
||||
│
|
||||
┌──────┼──────────────┐
|
||||
│ │ │
|
||||
@@ -21,20 +19,6 @@ Terraform OpenBao / DNS / KaaS
|
||||
Ansible
|
||||
```
|
||||
|
||||
Ayatori 复用 Kubernetes 的 API machinery,而不是 Kubernetes 的容器编排产品边界。
|
||||
kube-apiserver 提供版本化对象、并发控制、list/watch、RBAC、admission 和审计;Ayatori
|
||||
controller-manager 承担所有领域控制循环。Kubernetes workload 集群只是与 OpenSandbox、
|
||||
Proxmox 等并列的 executor/backend,不默认等于运行 controller 的 management environment。
|
||||
|
||||
因此,领域 API 不得依赖“资源最终一定变成同集群原生对象”的假设。原生 Pod、Job、Service、
|
||||
NetworkPolicy、namespace 共置与 owner reference 只有在 Kubernetes adapter 内才具有原生含义;
|
||||
跨后端所需能力必须由领域模型显式定义。
|
||||
|
||||
内置 API 类型也按相同原则选择性复用。采用 `core/v1 Node` 作为计算节点 API 时,可以由
|
||||
Ayatori Compute Agent 写入状态、由 Ayatori 自有调度 controller 消费;这不会引入 kubelet、
|
||||
Pod 或 kube-scheduler。API contract、负责实现它的 controller/agent 和数据面是三个独立决策,
|
||||
不得从其中一个自动推导另外两个。
|
||||
|
||||
## 控制面
|
||||
|
||||
Dev 与 Prod 使用独立的 Kubernetes API、数据库、身份和 controller 实例。两者可以
|
||||
@@ -50,16 +34,16 @@ Ayatori 不承载或重新实现数据面。控制面故障只应阻止创建与
|
||||
|
||||
## 资源分层
|
||||
|
||||
平台只为已经验证的管理缺口提供正交产品能力。当前优先资源为:
|
||||
平台提供正交产品能力,例如:
|
||||
|
||||
- `Job`、`Sandbox`、`ManualTask`
|
||||
- `VirtualMachine`
|
||||
- `LoadBalancer`
|
||||
- `Database`
|
||||
- `Bucket`
|
||||
- `VirtualMachine`
|
||||
|
||||
`Run`/当前实验性的 `Job`、`ManualTask` 等可以作为控制面执行原语,但不是因为底层能运行 OCI
|
||||
image 就自动成为面向使用者的计算产品。`DNSRecord`、`Credential`、`KubernetesCluster` 等只在
|
||||
出现独立生命周期和真实消费者后加入;尤其 KaaS 不是预定终点。
|
||||
- `DNSRecord`
|
||||
- `Credential`
|
||||
- `KubernetesCluster`
|
||||
|
||||
只有具备独立领域生命周期的能力才应成为高阶资源。应用本身通过 GitOps 组合上述资源,
|
||||
重复组合可通过模板或 Composition 表达,而不是扩展中央 Application API。
|
||||
@@ -72,17 +56,5 @@ image 就自动成为面向使用者的计算产品。`DNSRecord`、`Credential`
|
||||
2. 通过固定版本的 Terraform module 或 Ansible playbook 执行。
|
||||
3. 仅在必要时使用 GitOps bridge。
|
||||
|
||||
Proxmox 是已知例外:其远程 API 不能覆盖所需的完整 VM 生命周期。VirtualMachine adapter 可以
|
||||
按操作能力选择 Proxmox API、部署在节点上的受限强类型 Agent/CLI,或生成 `ManualTask`。Agent
|
||||
必须提供版本化操作、幂等查询、operation ID 与审计,不能暴露任意 shell,也不能把 CLI 输出
|
||||
直接当作长期稳定协议。
|
||||
|
||||
Controller 无论采用哪种执行方式,都必须提供一致的 ownership、conditions、删除语义、
|
||||
错误分类和恢复行为。
|
||||
|
||||
## API Server 边界
|
||||
|
||||
首选 kube-apiserver + CRD,持续复用其成熟的 watch、RBAC、版本化存储和 API 生态。
|
||||
generic-apiserver 或聚合 API Server 不会减少领域 controller 的数量,只会把资源服务端、
|
||||
兼容性和存储迁移责任转移给 Ayatori。只有 CRD 的限制已经形成可复现、不可通过合理领域建模
|
||||
解决的阻碍时,才重新评估自建 API Server。
|
||||
|
||||
@@ -26,11 +26,6 @@ cloud-init 设备、bridge/VLAN 映射和默认 placement 由平台维护。
|
||||
|
||||
## 生命周期基线
|
||||
|
||||
新增资源、绑定、回收或恢复语义前,先引用 Kubernetes 官方对应资源设计与成熟 controller
|
||||
模式,说明采用部分及有意偏离的原因。不要仅沿用字段名称而忽略生命周期与权限边界。
|
||||
例如 Database 借鉴 PV/PVC 的资源与申请分离、排他绑定和 Retain,但不引入 CSI 协议、
|
||||
存储调度或额外 registry;见 [ADR-0009](../decisions/0009-database-resource-and-claim.md)。
|
||||
|
||||
所有受管资源必须定义:
|
||||
|
||||
- `observedGeneration`
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
# 环境与发布
|
||||
|
||||
## CI 验证入口
|
||||
|
||||
Verify 工作流在 PR 上执行全量测试、lint 和 Database 集成测试;合并到 main 后不通过 push
|
||||
事件重复运行。需要排障或验证直接推送的紧急修复时,可通过 workflow_dispatch 手动运行。
|
||||
此约定不减少检查项目,也不修改分支保护设置;常规变更必须经过 PR,直接推送 main 不会自动验证。
|
||||
|
||||
Gitea 的 PR 工作流验证分支 head,而不是合并预览提交,见
|
||||
[官方事件说明](https://docs.gitea.com/usage/actions/faq/)。合并前必须确认最新 head 检查通过,
|
||||
且与当前 main 合并不会引入未经验证的内容组合;基线有实质变化时先更新分支并重验。
|
||||
只改变基线引用且目标文件树不变时,不需要为了合并提交的 SHA 不同重复全量验证。
|
||||
|
||||
## 环境与制品晋级
|
||||
|
||||
Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时
|
||||
没有生产制品需要承载,提前维护第二套环境没有收益。
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
# Database 模块
|
||||
|
||||
Database 是 Ayatori 首批实际产品领域之一。当前已包含三资源 API、分层绑定与 Instance 原生
|
||||
管理能力观测;尚未完成 Database 供应/导入、Tenant 凭据交付与资源回收链路。
|
||||
|
||||
## 当前设计(2026-09-24)
|
||||
|
||||
采用 Instance → Database → Tenant 的资源与申请模型;Database 独立存在,支持显式导入、
|
||||
Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership registry 及任意 status
|
||||
丢失自动恢复所有权的要求。未知同名资源或创建结果不确定时,清楚报告 Conflict 并人工处理。
|
||||
|
||||
依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见
|
||||
[系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。
|
||||
registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理
|
||||
能力观察直接判定 Ready。Database 资源与绑定已接入,导入、角色/凭据供应及回收仍未完成。wiki 同步位置见
|
||||
`homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。
|
||||
|
||||
## 来源基线
|
||||
|
||||
完整设计合同及首批领域模型与测试提取自原 PostgreSQL Tenant Operator:
|
||||
|
||||
- 仓库:`git.ddupan.top/panxiao81/postgresql-tenant-operator`
|
||||
- source commit:`dae546e58efa1be81e930861c87f7fb13bb12113`
|
||||
- 原路径:`internal/domain/instance/`、`docs/domain-instance.md`
|
||||
- 迁移日期:2026-09-20
|
||||
|
||||
本目录迁入该基线的 specification、architecture、API、领域、部署、安全、开发、迁移与运维
|
||||
文档,最初只进行 Ayatori 产品归属、API group、目录和链接适配;2026-09-24 经维护者
|
||||
批准的资源/申请分离修订明确替代 registry、自动恢复与原 Retain 合同,其余适用的安全约束保留。
|
||||
|
||||
迁移只使用该 commit 中已提交的文件。源仓库
|
||||
`feature/instance-extension-observations` 工作树中的 `instance.go` 修改与
|
||||
`instance_extensions_test.go` 未进入本切片。
|
||||
|
||||
代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新;
|
||||
首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新
|
||||
验证或删除时失效,且支持判定不授权 Tenant provisioning。已有 Ready 切片曾按原合同加入
|
||||
registry 准备决策;这一依赖现已从代码移除,不能把旧运行链路接回模型。
|
||||
各层验证边界见 [Instance 领域规格](domain-instance.md)。
|
||||
|
||||
## 边界
|
||||
|
||||
- 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。
|
||||
- CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。
|
||||
- Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。
|
||||
- 当前代码只检查 Instance 供应前置条件,不授予 Tenant 所有权或外部写入权限,也不表示
|
||||
Database API 已经可用。
|
||||
|
||||
## 管理凭据与连接切片
|
||||
|
||||
`application.InstanceService` 适配自原项目固定基线
|
||||
[`internal/instance/service.go`](https://git.ddupan.top/panxiao81/postgresql-tenant-operator/src/commit/dae546e58efa1be81e930861c87f7fb13bb12113/internal/instance/service.go),
|
||||
保留 CredentialReader、Connector、Database 的装配边界及串行操作/释放规则。
|
||||
具体连接池完全由 pgxpool v5.11.0 提供,PostgreSQL adapter 不拥有凭据缓存、轮换流程或任意观测回调。
|
||||
|
||||
相对源基线有两项按已批准合同作出的必要修改:管理凭据从固定 controller namespace 的
|
||||
Kubernetes Secret 直接读取;每轮比较有效用户名、密码,检测到变化即关闭旧连接并重新装配。
|
||||
Secret metadata 和无关字段变化不重建连接。观测后再次读取 Secret,中途有效值变化则丢弃结果,
|
||||
不把旧连接的成功作为新凭据有效的证据;这不构成跨 Kubernetes/PostgreSQL 的原子事务。
|
||||
Instance UID、endpoint 或凭据引用变化也会释放旧连接;Forget/Close 只释放本地资源。
|
||||
|
||||
`ObserveMetadata` 读取服务器版本和可用扩展,`ObserveVersion` 是版本读取便捷入口;两者
|
||||
不会填充管理检查,不能产生 Ready。`ObserveManagement` 使用同一凭据/连接边界读取完整
|
||||
原生管理检查,返回绑定当前 target 的 `InstanceObservation`。controller 的资源呈现适配器
|
||||
使用 resourceVersion 拒绝过期写入。应用层沿用串行处理,不引入新的调度框架。
|
||||
|
||||
运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部
|
||||
DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、
|
||||
namespace 边界、有效值轮换、metadata 无关变化、中途轮换、重建/重试、并发读取、Forget/Close
|
||||
与 TLS DNS/IP SAN、错误 CA/主机名和禁止明文降级。CI 使用 Pod runner 执行,由 runner 提供
|
||||
可用的 Docker,workflow 只做预检、不自行启动 daemon;不依赖 VM。普通 lint 之外还检查
|
||||
integration 标签代码。领域单测、真实 API 行为与真实 PostgreSQL 行为分别验收,不以本切片
|
||||
替代整个 Instance controller 的集成验收。
|
||||
|
||||
## 服务器 metadata 与扩展观测
|
||||
|
||||
SQL adapter 通过一条只读语句读取 `pg_catalog.current_setting('server_version')` 和
|
||||
`pg_catalog.pg_available_extensions`,避免从已安装列表推断可用列表,且不依赖可修改的
|
||||
`search_path`。查询失败丢弃整份结果;成功返回空列表与尚未观察严格区分。
|
||||
参考 PostgreSQL 的 [pg_available_extensions](https://www.postgresql.org/docs/18/view-pg-available-extensions.html)
|
||||
与 [CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html) 合同:可用列表
|
||||
表示服务器提供的扩展,不证明管理账号有安装权限,也不保证依赖和其他安装前提满足。
|
||||
|
||||
`InstanceService` 保留原有 CredentialReader → Connector → Database 边界,复用同一个
|
||||
凭据读取、连接刷新和串行释放流程,不新增连接池封装或任意查询回调。每次重新查询 metadata,
|
||||
并在 Secret 有效值回读一致后生成不可变的 `InstanceObservation`,绑定本次 target(含当前
|
||||
generation),不绑定建池时的旧 target。结果不包含凭据,扩展集合不与 driver 的可变 slice 共享。
|
||||
凭据中途变化、读取失败或查询失败时,返回零值观察并释放连接,不复用旧的扩展列表。
|
||||
|
||||
调用方可将 `Target()` 与 `Extensions()` 交给 Instance 的 `ObserveExtensions`;应用调用链
|
||||
仍负责同轮次使用,不能持久化或跨轮缓存这份证据。metadata 读取不安装扩展、不初始化 registry、
|
||||
不设置 Ready,也不授予 Tenant 写权限。管理权限检查使用下面的独立入口。
|
||||
|
||||
真实 API server + PostgreSQL 测试验证未安装扩展可被观察、名称保持大小写、search_path 遮蔽
|
||||
不改变查询来源、低权限账号读取、权限撤回失败与恢复、Secret 中途变化丢弃扩展结果。
|
||||
单元测试补充成功空列表、查询附带部分数据时丢弃、结果与可变 slice 隔离、每轮重新读取和
|
||||
generation 变化时的目标绑定;原凭据/TLS/超时/并发测试沿同一 metadata 路径继续运行。
|
||||
|
||||
## Registry 撤除
|
||||
|
||||
原 `adapter/postgresql/registry`、SQL 迁移、registry 专属集成测试及 tern 依赖已删除;
|
||||
未提交的 inspection 实验也已撤除。来源仍可在 Git 历史追溯,没有删除外部 PostgreSQL 对象。
|
||||
Instance 不再具有 InitializingRegistry 阶段、RegistryState、准备决策或回读方法;
|
||||
首次完整管理能力观察即可完成验证,重验失败则撤销本轮就绪证据。
|
||||
|
||||
保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力
|
||||
在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。
|
||||
这不等于三资源供应、交付和回收已实现。
|
||||
|
||||
## Instance 原生管理观测
|
||||
|
||||
2026-09-25 维护者确认先使用原生非 superuser 方案,不引入 SECURITY DEFINER 接口。
|
||||
`InspectManagement` 通过同一条只读语句读取当前执行角色的 `CREATEROLE`、`CREATEDB`、
|
||||
superuser 属性、服务器可写状态、版本和扩展列表。不创建探针数据库/角色,不初始化 schema。
|
||||
只有非 superuser、具备两项原生属性且当前服务器/会话可写时,基础管理能力才通过。
|
||||
角色属性不可从继承成员关系推导;具体已有资源仍须检查 owner、membership 与授权范围。
|
||||
|
||||
权限依据和真实测试对应:
|
||||
|
||||
- role:当前角色具有 CREATEROLE,可创建普通登录角色;
|
||||
- database:当前角色具有 CREATEDB,且会话非只读、服务器不在 recovery;
|
||||
- grant:使用自己新建角色的管理权限,显式建立 SET membership,再以 owner 管理数据库 ACL;
|
||||
- extension:新建数据库 owner 可安装 trusted 扩展;可用列表不是安装授权,非 trusted 或
|
||||
其他前提不满足的扩展仍可能失败,必须逐请求执行和回读。导入不继承此动态供应授权。
|
||||
|
||||
参考 PostgreSQL 官方 [CREATE ROLE](https://www.postgresql.org/docs/18/sql-createrole.html)、
|
||||
[CREATE DATABASE](https://www.postgresql.org/docs/18/sql-createdatabase.html) 与
|
||||
[CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html)。这些检查是基础
|
||||
能力观察,不是未来操作必然成功的保证;权限、容量、连接数等仍可能在执行时变化。
|
||||
|
||||
`InstanceReconciliation` 协调 finalizer、观察、领域判定和删除引用检查,controller 只连接
|
||||
事件、用例、状态呈现与重试。每轮重建无证据的领域对象;旧 Ready 不授权新一轮操作。
|
||||
失败清除当前版本结果并撤销 Ready;CR 在观察期间被修改则拒绝旧结果,下一轮重新读取。
|
||||
连接/权限变化由 Secret watch 和 30 秒重查驱动,单轮 IO 最长 15 秒;不持续写入相同状态。
|
||||
|
||||
manager 通过 `--database-secret-namespace`(默认 `POD_NAMESPACE`)启用 Instance 观测;
|
||||
为空时不启用。本地运行需显式提供该参数。Deployment 使用 downward API 获取自身 namespace;
|
||||
Secret 的 get/list/watch 权限由该 namespace 的 Role 单独授予,不放入 ClusterRole。
|
||||
watch 使用 controller-runtime 的 metadata-only cache,读取有效凭据仍直连 API server。
|
||||
TLS 使用既有 endpoint 合同,公开 CA bundle 可由 `--database-root-cert` 指定;不会自动挂载
|
||||
生产证书或创建管理 Secret。manager worker 停止后统一关闭 pgxpool。
|
||||
|
||||
Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Database(含 Released、删除中)
|
||||
或动态 Tenant 申请都会阻止 finalizer 解除;列表查询失败也等待。仅在引用全部解除后移除
|
||||
`database.ayatori.ddupan.top/instance-protection`,不删除 PostgreSQL、账号或凭据。
|
||||
引用查询与删除不是跨对象事务;后续供应仍必须拒绝已删除/删除中的 Instance。
|
||||
|
||||
验收使用真实 PostgreSQL + API server:原生管理账号实际建库、owner 授权、trusted 扩展
|
||||
安装/回读,拒绝非 trusted 扩展;权限撤回/恢复、只读会话、superuser 拒绝和中途轮换。
|
||||
实际 manager 在生成的资源 RBAC 和 namespaced Secret Role 下验证缺失 Secret 后出现、
|
||||
轮换、删除、跨 namespace 拒绝和 watch。API 测试另覆盖写入版本冲突、幂等、新 reconciler
|
||||
恢复与引用删除保护。完整 DBaaS 仍需供应/导入、OpenBao/ESO、Retain/Delete 集成验收。
|
||||
|
||||
## 应用凭据存储切片
|
||||
|
||||
`adapter/openbao` 使用官方 Go SDK `api/v2 v2.7.0` 的 KV v2 API,只有创建和读取,
|
||||
不维护 registry、不覆盖已有密码。动态路径由固定前缀与 Database UID 组成;所有访问都校验
|
||||
配置前缀,已有导入位置也不能绕过 controller 的凭据权限范围。
|
||||
|
||||
创建使用 CAS=0,随后回读七键和版本 1;已有值或软删除历史报冲突。关闭 SDK 自动重试,
|
||||
写入响应丢失、回读失败或内容变化均返回不确定结果,上层不得生成第二份密码或自动认领。
|
||||
`Read` 只适用于调用方已确认关联的路径,读取成功本身不是管理权证据。错误不传播 SDK
|
||||
响应体;内存凭据的普通格式化及 JSON 输出均脱敏,明确的 `SecretData` 才返回明文七键。
|
||||
|
||||
依据官方 [KV v2 CAS 合同](https://github.com/openbao/openbao/blob/main/internal/builtin/logical/kv/path_data.go)
|
||||
与 [Go SDK](https://github.com/openbao/openbao/tree/main/api)。`make test-database-integration`
|
||||
现包含独立 OpenBao dev 容器,固定摘要、随机回环端口、无持久卷,不接受外部地址。
|
||||
真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token
|
||||
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
|
||||
|
||||
这一切片尚未接入 manager:Kubernetes auth/token 生命周期、Database 状态中的稳定位置和
|
||||
已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。
|
||||
测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。
|
||||
|
||||
## 设计入口
|
||||
|
||||
- [系统规格](specification.md):规范性行为与验收标准;
|
||||
- [架构](architecture.md)与[API 合同](api-reference.md);
|
||||
- [领域模型](domain-model.md)与[Instance 领域规格](domain-instance.md);
|
||||
- [安全](security.md)、[迁移](migration.md)与[运维](operations.md);
|
||||
- [部署合同](deployment.md)与[开发测试合同](development.md)。
|
||||
|
||||
部署和开发文档描述目标合同,其中旧仓库专属的 Make target、脚手架版本和目录尚未接入 Ayatori;
|
||||
在对应实现切片完成前,不应把其中命令理解为当前仓库已经可执行的入口。
|
||||
@@ -1,202 +0,0 @@
|
||||
# v1alpha1 API 合同
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
|
||||
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
|
||||
| 最后更新 | 2026-09-25 |
|
||||
|
||||
以 [系统规格](specification.md) 与
|
||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
|
||||
尚未发布为可用 DBaaS。示例可进入绑定协调,但不代表创建对象后会供应数据库或交付凭据。
|
||||
|
||||
## 当前 API 切片
|
||||
|
||||
Go 类型位于 `api/database/v1alpha1`,CRD 随 `config/crd` 发布;manager 已注册 Scheme 和
|
||||
`internal/database/controller` 的绑定 controller。以下字段是本切片的具体实现:
|
||||
|
||||
| 资源 | 字段 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| Database | `spec.instanceRef.name` | 所属集群级 Instance |
|
||||
| Database | `spec.database`、`spec.loginRole` | 实际数据库与唯一登录 owner,必填 |
|
||||
| Database | `spec.source` | 必填 `Provision` 或 `Import`,不隐式认领 |
|
||||
| Database | `spec.credentialRef.mount/path` | Import 必填的已有 KV v2 凭据位置;Provision 禁止指定 |
|
||||
| Database | `spec.reclaimPolicy` | Retain 默认或 Delete |
|
||||
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
|
||||
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
|
||||
| Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 |
|
||||
| Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 |
|
||||
| Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance |
|
||||
| Tenant | `spec.extensions`、`spec.secretName` | 扩展集合与同 namespace 的交付目标 |
|
||||
| Tenant | `status.databaseRef.name/uid` | 资源侧绑定成功后写入 |
|
||||
| Tenant | `status.secretName`、`status.credentialURL` | 交付观察,不包含密码或认证信息 |
|
||||
|
||||
三资源均有 status subresource、observedGeneration 和按 type 唯一的 Conditions。
|
||||
Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应子阶段枚举。
|
||||
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
|
||||
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
|
||||
|
||||
示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、
|
||||
[导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、
|
||||
[动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。
|
||||
|
||||
当前 schema 验证名称、端口、IP、TLS 枚举、申请互斥、导入凭据要求和绑定 UID 完整性。
|
||||
API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排他绑定由 controller 协调。
|
||||
绑定 controller 解析动态 database/loginRole 的 Tenant 名称默认值;动态资源 CR 名称为
|
||||
`tenant-<Tenant UID>`,首次创建即包含资源侧绑定与 finalizer,不带 Tenant ownerReference。
|
||||
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
|
||||
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
|
||||
|
||||
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或
|
||||
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
|
||||
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
|
||||
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
|
||||
映射、finalizer 和状态呈现。呈现前若资源版本已变化,返回冲突供下一轮重读,不覆盖其他修改。
|
||||
双向记录完成后 Tenant 为 Bound,Ready=False/BindingComplete,明确尚未供应或交付。
|
||||
生成的 manager ClusterRole 授予资源读写,不包含 Secret 读取;Instance 观测的管理 Secret
|
||||
权限由固定 namespace 的独立 Role 授予。Instance controller 已接入原生管理观察与引用删除
|
||||
保护,启用方式及 Ready 边界见 [模块说明](README.md#instance-原生管理观测)。
|
||||
|
||||
当前有 Tenant/Database finalizer 保护,但**删除清理尚未实现**:Tenant 删除报告
|
||||
Ready=False/DeletionPending 并保留绑定与 finalizer,Database 的保护也不会被自动移除。
|
||||
还未实现删除流程开始后的回收策略固定、Retain 释放、Released 重新开放、外部清理、
|
||||
扩展只追加、Secret 默认名称解析与凭据交付。在后续清理协议和真实后端验收完成前,
|
||||
不能作为可用 DBaaS 部署,也不能通过强行移除 finalizer 把它视为已完成清理。
|
||||
|
||||
## 通用约定
|
||||
|
||||
- Instance 与 Database 是 cluster-scoped;Tenant 是 namespaced。
|
||||
- Tenant 按名称引用 Database,不提供 Database namespace;Database 绑定记录包含
|
||||
Tenant namespace/name/UID。字段见当前 API 切片;绑定采用下述资源侧先写顺序。
|
||||
- 每类资源提供唯一的 Ready Condition、observedGeneration;phase 用于进度展示,
|
||||
不能单独作为写权限或所有权证明。
|
||||
- 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。
|
||||
- controller 管理 status;资源侧管理与回收权限不得随 Tenant editor 权限自动授予。
|
||||
- 固定默认值使用 CRD defaulting,跨字段/不可变校验使用 CEL 或 controller;
|
||||
并发更新使用 resourceVersion,不增加 mutating webhook 或跨系统事务。
|
||||
- database/role identifier 继续匹配 `^[a-z][a-z0-9_]{0,62}$`。
|
||||
- 动态申请的 database/loginRole 省略时继续以 Tenant metadata.name 为语义默认值;
|
||||
显式导入资源使用实际目标,不从新 Tenant 名称重新推导。最终互斥字段须经 API 评审。
|
||||
- 投射仍位于 Tenant namespace,ExternalSecret 默认命名沿用
|
||||
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可由申请指定,省略时同名。
|
||||
有效 Instance 名称与 Tenant 名称合计不超过 241 字符;完整名称须满足 API 名称校验。
|
||||
- 任何 spec/status/错误不得出现密码、Token 或完整秘密响应。
|
||||
|
||||
## PostgreSQLInstance
|
||||
|
||||
保留管理入口字段:
|
||||
|
||||
| JSON path | 类型 | 必填/默认 | 合同 |
|
||||
| --- | --- | --- | --- |
|
||||
| `spec.endpoint.host` | string | 必填 | PostgreSQL DNS 名;必须被服务端证书 DNS SAN 覆盖 |
|
||||
| `spec.endpoint.hostaddr` | string | 必填 | 单个 IPv4/IPv6;必须被服务端证书 IP SAN 覆盖 |
|
||||
| `spec.endpoint.port` | int32 | `5432` | 1–65535 |
|
||||
| `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier |
|
||||
| `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` |
|
||||
| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 |
|
||||
| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
|
||||
| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
|
||||
|
||||
|
||||
管理 Secret 固定在 controller namespace,不接受 namespace 或 Bao path。
|
||||
管理员维护其 ExternalSecret,controller 只读;有效用户名/密码变化时重验连接。
|
||||
endpoint 变化使旧观察失效,不迁移旧服务器的数据,不自动接管旧 UID 的资源。
|
||||
|
||||
Status 保留 observedGeneration、postgresqlVersion 和 conditions;
|
||||
phase 为 Pending、Validating、Ready、Deleting,移除 InitializingRegistry。
|
||||
Ready 要求连接、metadata 与所需管理权限,不要求 registry。
|
||||
实例实际可用扩展来自查询,不提供 allowedExtensions 配置。
|
||||
|
||||
开始受管时保存 finalizer;删除时阻止新供应,并检查 Database(含 Released)及未绑定
|
||||
Tenant 引用。无引用才解除,不级联删除资源;查询失败不能视为无引用。
|
||||
|
||||
## Database 资源(工作 Kind:PostgreSQLDatabase)
|
||||
|
||||
v1alpha1 以一个 database、一个兼任 owner 的 login role 及其应用凭据作为 Database 的
|
||||
生命周期边界;不提供多账号字段或独立 Role/Credential CRD。多账号需求留待后续 API 版本。
|
||||
此决定不改变导入只读验证和显式管理授权的要求。
|
||||
|
||||
Database 是平台管理的集群级资源,不归属于应用 namespace,也不需要资源专用 namespace。
|
||||
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放;
|
||||
这些资源管理操作由平台管理员授权。controller 的绑定协调权限与用户申请权限分别配置。
|
||||
|
||||
以下是行为合同,具体 schema 见当前 API 切片与生成的 CRD;后端行为尚未实现:
|
||||
|
||||
| 内容 | 合同 |
|
||||
| --- | --- |
|
||||
| `instanceRef` | Database 自身必填;定位来源并记录绑定的 Instance UID,不依赖 Tenant 补齐 |
|
||||
| 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 |
|
||||
| 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 |
|
||||
| 绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份;无允许绑定名单 |
|
||||
| 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 |
|
||||
| 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 |
|
||||
|
||||
概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。
|
||||
Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。
|
||||
|
||||
导入的初始检查只读;存在不等于 Ready。未绑定且可用的资源允许 Tenant 显式申请,
|
||||
不要求管理员逐 Tenant 授权;已占用或 Released 的资源不能直接绑定。
|
||||
默认保留不隐含密码、owner、授权或删除的变更许可。
|
||||
|
||||
## PostgreSQLTenant
|
||||
|
||||
保留用户申请与交付职责:
|
||||
|
||||
- 动态申请描述 Instance、所需数据库/登录角色、扩展与目标 Secret;
|
||||
或显式引用管理员已登记的 Database。后者从 Database 获取 Instance,不重复指定来源;
|
||||
两条路径互斥,具体 schema 在 API 切片固定。
|
||||
- 绑定前固定有效需求,绑定/开始供应后不能通过改引用或名称迁移资源。
|
||||
- extensions 成功后只允许追加,不自动卸载。
|
||||
- status 展示绑定 Database 身份、Ready、交付 Secret 引用及不含认证信息的 OpenBao URL。
|
||||
- Tenant 删除释放申请,按照 Database 的回收策略处理,不独立持有最终删除授权。
|
||||
|
||||
旧 Tenant `spec.deletionPolicy` 不再作为最终资源回收策略;旧字段表、供应 phase 枚举及
|
||||
仅按 Tenant namespace/name 派生凭据路径的规则不再是实现合同。已有代码没有兼容负担,
|
||||
不保留两套相互覆盖的策略字段。
|
||||
|
||||
## 已确认的绑定顺序
|
||||
|
||||
1. 动态申请按 Tenant UID 确定 Database 名称并创建记录;已有资源申请使用指定的 Database,
|
||||
检查资源未绑定且可用,不增加反向授权名单。动态创建重试遇到同名记录时需核对身份与目标。
|
||||
2. 使用 resourceVersion 并发控制,先在 Database 记录 Tenant namespace/name/UID。
|
||||
已绑定其他 Tenant 时报告 Conflict,不抢占;版本冲突后重新读取、重新判断。
|
||||
3. 在 Tenant status 记录 Database name/UID。若上一步成功、本步失败,后续 reconcile
|
||||
核对身份后补齐,不回滚已经成功的资源侧绑定。
|
||||
4. 双向记录一致才允许动态供应或凭据交付;实际数据库与凭据验证通过后才可 Ready。
|
||||
|
||||
此顺序借鉴 PV/PVC 的资源侧先写模式,行为依据见
|
||||
[系统规格](specification.md#5-动态供应与排他绑定)。Retain 释放时保留旧绑定身份并进入
|
||||
Released,不自动清空后重新分配。API 记录部分写入由 reconcile 重试;外部创建结果
|
||||
无法确认时仍报告 Conflict,交给人工,不新增事务队列或 registry。
|
||||
|
||||
## 绑定协议剩余评审要求
|
||||
|
||||
实现前必须明确:
|
||||
|
||||
1. 引用字段的最终格式,以及落实管理员资源管理、controller 协调与普通申请权限的 RBAC 规则。
|
||||
2. 绑定记录的最终字段及校验规则,落实上述写入顺序与恢复行为。
|
||||
3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。
|
||||
4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。
|
||||
5. 已有凭据关联字段、旧访问处置与投射清理顺序;动态凭据路径已确定按 Database UID 定位。
|
||||
6. Tenant/Database finalizer 配合;回收策略默认 Retain,删除流程前可改,进入后固定。
|
||||
|
||||
资源管理者设置 Delete 即表示删除授权,不增加额外审批字段。导入显式关联已有凭据;
|
||||
Released 不自动改密,管理员处理旧访问后才重新开放。Tenant 不得自选任意 OpenBao 路径。
|
||||
|
||||
该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。
|
||||
未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。
|
||||
|
||||
## Conditions
|
||||
|
||||
每类资源至少提供唯一的 Ready;同类型 Condition 不重复,维护 lastTransitionTime 与
|
||||
observedGeneration。最低安全错误分类见 [系统规格](specification.md#10-conditions-与可观测性)。
|
||||
|
||||
Conflict 必须说明目标、步骤、已确认与不确定部分及人工核实建议;不能建议清空 status、
|
||||
伪造 Ready 或改密码绕过。可恢复依赖故障退避重试,冲突不忙循环。
|
||||
具体 Reason 在 API 切片固定,Released 不得误报为可立即交付。
|
||||
|
||||
## 实现验收
|
||||
|
||||
CRD defaulting、CEL、status subresource、resourceVersion、并发绑定、重启与依赖 watch
|
||||
使用真实 API server 验证;ownerReference/namespace 删除的实际 GC 使用测试集群。
|
||||
类型、CRD、sample 和 contract tests 必须一起对齐,不以 fake client 替代 API 语义。
|
||||
@@ -1,58 +0,0 @@
|
||||
# Database 系统架构
|
||||
|
||||
本页解释 [系统规格](specification.md) 的组件边界;资源与申请分离的依据见
|
||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。设计已确认,运行链路尚未完成。
|
||||
|
||||
## 资源与后端
|
||||
|
||||
```text
|
||||
Kubernetes API
|
||||
Instance ──引用── Database ──排他绑定── Tenant
|
||||
|
|
||||
Ayatori Database controllers
|
||||
| | |
|
||||
PostgreSQL OpenBao ExternalSecret
|
||||
catalog |
|
||||
ESO → Secret
|
||||
```
|
||||
|
||||
Kubernetes 保存声明、绑定与操作进度;PostgreSQL 保存实际数据库状态;OpenBao 保存应用凭据。
|
||||
不在 PostgreSQL 中再建立管理 registry。Instance 是管理入口而非 CSI 协议实现,adapter 是薄访问层。
|
||||
|
||||
Instance 验证当前目标的管理能力,不供应 Tenant 数据库,不因 registry 缺失初始化任何 schema。
|
||||
Database 用例负责独立资源的供应、显式导入、保留与回收;Tenant 用例负责申请、绑定与凭据交付。
|
||||
这是用例职责,不要求为每一步新增一个 controller 或通用控制循环。
|
||||
|
||||
## 协调与恢复
|
||||
|
||||
通过 Kubernetes API 的 resourceVersion 保护并发更新,watch 推动依赖恢复;外部操作前保存
|
||||
资源与意图,执行后观察并记录结果。数据库实际操作仍须处理后端竞态,列表检查不是唯一约束。
|
||||
|
||||
可靠确认的步骤允许幂等继续;外部创建与进度写入之间的失败若导致归属不确定,则停止写入并
|
||||
报告 Conflict。不创建第二套所有权存储,不承诺跨系统事务或任意 status 丢失自动认领。
|
||||
错误必须提供人工可用的步骤、资源与结果确定性信息,但不泄漏秘密。
|
||||
|
||||
## 绑定、导入与回收
|
||||
|
||||
Database 独立于 Tenant 存在,不能以会导致级联删除的 ownerReference 连接两者。
|
||||
Instance 删除检查 Database 引用,包括 Released 资源,不直接清理数据库。
|
||||
|
||||
动态供应和管理员导入使用同一种资源记录。导入验证初始只读;未知同名数据库仍为冲突。
|
||||
Retain 保留资源及旧绑定身份;重新绑定必须经过人工数据和访问权限处置,不自动分配。
|
||||
Delete 由资源侧明确授权,在 finalizer 保护下按管理范围清理并逐步回读。
|
||||
|
||||
角色、凭据与投射的具体管理字段和清理顺序仍需 API 评审,不能用 PV 类比代替数据库权限设计。
|
||||
|
||||
## 保持的访问边界
|
||||
|
||||
管理 Secret 固定在 controller namespace,管理员维护其 ExternalSecret;controller 只读。
|
||||
有效值变化刷新管理连接,不直接访问 Bao 获取管理凭据,不自行实现连接池。
|
||||
应用凭据写 OpenBao,由 ESO 投射;controller 不直接写明文 Secret。
|
||||
TLS、DNS/IP SAN、七键凭据输出和最小权限合同继续适用。
|
||||
|
||||
## 导航
|
||||
|
||||
- [API 合同与待细化字段](api-reference.md)
|
||||
- [领域模型](domain-model.md)与[Instance 规格](domain-instance.md)
|
||||
- [部署](deployment.md)、[安全](security.md)、[开发测试](development.md)
|
||||
- [导入与迁移](migration.md)、[运维](operations.md)
|
||||
@@ -1,139 +0,0 @@
|
||||
# 部署与配置
|
||||
|
||||
> 本页区分已实现的 Instance 观测配置与尚未接入的供应/交付目标合同。
|
||||
> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Review |
|
||||
| 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao |
|
||||
| 最后更新 | 2026-09-25 |
|
||||
|
||||
本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。Instance 观测已接入 manager;
|
||||
OpenBao、ESO 与完整供应装配仍是后续实现合同。
|
||||
|
||||
## 依赖与顺序
|
||||
|
||||
1. 准备 PostgreSQL VM、持久盘、备份和网络入口。
|
||||
2. 用 OpenBao PKI 签发 PostgreSQL server 证书,包含 Instance `host` 的 DNS SAN 与
|
||||
`hostaddr` 的 IP SAN;配置 PostgreSQL 强制 TLS。
|
||||
3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。
|
||||
4. 在 OpenBao KV v2 写入管理 role 凭据。
|
||||
5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。
|
||||
6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller
|
||||
namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的
|
||||
`ClusterSecretStore`。
|
||||
7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。
|
||||
8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。
|
||||
|
||||
任何一步都不得把真实密码、Token、kubeconfig 或 CA 私钥提交进 Git。
|
||||
|
||||
## Controller 配置合同
|
||||
|
||||
当前 manager 支持 `--database-secret-namespace`(默认 `POD_NAMESPACE`,为空则停用
|
||||
Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径)。Deployment
|
||||
通过 downward API 获取 namespace,Secret 权限由该 namespace 的 Role 授予。
|
||||
|
||||
以下是尚待实现的供应/交付配置合同,不表示当前 manager 接受这些 CLI flags。
|
||||
必填项缺失、路径无效或 duration 不为正数时,进程必须在启动 manager 前失败;
|
||||
不得等到 reconcile 时才逐个资源报告配置错误。
|
||||
|
||||
| CLI flag | 必填/默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `--openbao-address` | 必填 | controller 可访问的 OpenBao API address |
|
||||
| `--openbao-consumer-address` | 默认同 `--openbao-address` | 写入 Tenant status,必须能被预期外部消费者解析 |
|
||||
| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 |
|
||||
| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role |
|
||||
| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` |
|
||||
| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 |
|
||||
| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
|
||||
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
|
||||
| `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 |
|
||||
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
|
||||
|
||||
address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/`
|
||||
在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以
|
||||
`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata`
|
||||
API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。
|
||||
|
||||
Tenant 不能选择任意凭据路径。凭据必须能随 Database 保留并安全交付给被授权的新 Tenant;
|
||||
原 `<base-path>/<namespace>/<metadata.name>` 定位规则不再直接作为新 API 合同。
|
||||
动态供应位置使用 `<base-path>/<Database UID>`;导入使用 Database 的显式 credentialRef,
|
||||
不要求搬迁已有凭据。供应流程须先记录原 mount/path,不能在配置变化后重新推导位置。
|
||||
consumer URL 仍使用无认证信息的 KV v2 API URL。
|
||||
|
||||
base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空段、`.`、`..`、
|
||||
`data`/`metadata` API 层。ExternalSecret 固定命名为
|
||||
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可由 Tenant 指定,但名称必须
|
||||
满足 Kubernetes Secret 名称校验,不限制命名内容,默认与 ExternalSecret 同名。
|
||||
|
||||
配置变化不得隐式迁移既有凭据。修改 KV mount/base path 或 consumer address 前必须
|
||||
停止 controller、评估现有 Database 与绑定,并走明确迁移。资源记录应能定位原凭据,
|
||||
不能根据新部署参数静默切换;不再使用 registry 保存安装身份。
|
||||
|
||||
## PostgreSQL 管理 role
|
||||
|
||||
生产部署禁止使用 superuser。管理 role 至少需要:
|
||||
|
||||
- 连接管理 database、读取必要 catalog;
|
||||
- 创建/修改受管 login role;
|
||||
- 创建 database 并指定 owner;
|
||||
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
|
||||
- 连接租户 database 并创建实例实际支持、租户申请的 extension;
|
||||
- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。
|
||||
|
||||
部分 PostgreSQL 操作天然要求较高权限,尤其终止其他 session 和安装某些 extension。
|
||||
第一版使用原生非 superuser 的 CREATEDB/CREATEROLE 方案,不引入 SECURITY DEFINER
|
||||
管理接口。对自行创建的 owner 显式建立 SET membership,再以 owner 管理 ACL 与扩展;
|
||||
已有对象仍须逐资源核实授权,不能凭基础属性接管。需要 superuser 的扩展不能扩大 controller
|
||||
权限。真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。
|
||||
|
||||
## OpenBao 与 ESO
|
||||
|
||||
controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2
|
||||
data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。
|
||||
|
||||
管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
|
||||
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
|
||||
读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。
|
||||
`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。
|
||||
controller 创建的 ExternalSecret 与 Tenant 同 namespace;其 ownerReference 和 Retain 时的
|
||||
保留/清理须与凭据交付协议一起确定,不能把投射关系等同于 Database 的 GC 关系。目标
|
||||
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
|
||||
`sslmode`。
|
||||
|
||||
## Kubernetes RBAC
|
||||
|
||||
- controller 按用例读/写 Instance、Database、Tenant 及其 status/finalizer 和 Event。
|
||||
- Database 不设置随 Tenant 级联删除的 ownerReference;导入、预留、回收和重新绑定授权限管理员。
|
||||
- controller 可在 Tenant namespace 创建、读取、更新、删除 ExternalSecret,并只读检查
|
||||
对应 Secret 是否完成投射。
|
||||
- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller
|
||||
配置或其他 namespace 的 ExternalSecret。
|
||||
- controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace
|
||||
的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。
|
||||
- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用
|
||||
凭据,只检查 Secret 存在性和 ESO 状态。
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
v1alpha1 尚不承诺跨版本转换。升级前备份 CR/绑定、PostgreSQL 数据与 OpenBao,
|
||||
先在隔离 Kind 环境运行 E2E。禁止在同一组 CR 上同时运行两个 controller 版本。若新版本
|
||||
在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或凭据定位迁移时,
|
||||
必须先写独立升级规格和回滚步骤。
|
||||
|
||||
## 上线验证
|
||||
|
||||
```text
|
||||
PostgreSQL TLS 与备份验证
|
||||
-> OpenBao auth/policy 验证
|
||||
-> ClusterSecretStore Ready
|
||||
-> controller Ready/leader elected
|
||||
-> Instance Ready
|
||||
-> 测试 Tenant Ready
|
||||
-> DNS host 与 IP hostaddr 分别登录
|
||||
-> 删除测试 Tenant 并验证所选策略
|
||||
```
|
||||
|
||||
生产 homelab 上线前还必须完成 [`security.md`](security.md) 的权限检查和
|
||||
[`operations.md`](operations.md) 的备份/逃生检查。
|
||||
@@ -1,269 +0,0 @@
|
||||
# 开发与测试环境
|
||||
|
||||
> 本页迁入作为 Database 模块的测试分层与 fixture 合同。旧项目的 Make target、devcontainer
|
||||
> 和脚手架版本尚未适配 Ayatori;实现时应复用 Ayatori 现有工具链,并保持这里定义的测试边界。
|
||||
|
||||
## 当前设计验收(2026-09-24)
|
||||
|
||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 将资源生命周期从 Tenant 中分离。
|
||||
新增验收矩阵见 [系统规格](specification.md#11-验收)。registry 实现、专属测试与迁移依赖已撤除,不继续 schema 审计或自动所有权恢复切片。
|
||||
|
||||
| 层次 | 本次设计要求 |
|
||||
| --- | --- |
|
||||
| 纯领域 | Instance 无 registry 就绪判定、排他绑定、Released 不自动复用、管理范围与冲突规则 |
|
||||
| envtest | 三资源 schema/status、RBAC、resourceVersion 并发、绑定单边更新/重启、依赖 watch、finalizer |
|
||||
| 真实 PostgreSQL/OpenBao | 同名不修改、显式导入只读验证、创建不确定报冲突、可靠步骤幂等、Delete 故障重试 |
|
||||
| 测试集群 | Tenant/namespace 删除不 GC Database、ESO 交付/释放、人工重新绑定前的旧访问处置 |
|
||||
|
||||
需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、
|
||||
Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。
|
||||
envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。
|
||||
三资源 API 类型与 CRD 已实现,`make test` 包含真实 API server 验证:作用域、
|
||||
静态默认值、非法声明、status 写入隔离、Condition 唯一性、resourceVersion 冲突与绑定记录回读。
|
||||
另有绑定 controller 的真实 API server 测试:动态记录幂等、资源侧写入后故障注入、
|
||||
新 reconciler 回读补齐、双 Tenant 竞争、Released/旧 UID 拒绝、目标固定、陈旧观察、
|
||||
删除期间 finalizer 保留。实际 manager 在生成的 RBAC 角色下通过 watch/cache 处理依赖
|
||||
稍后出现,绑定角色不具备 Secret 读取权限。没有 PostgreSQL/OpenBao 写入;后端 Ready
|
||||
在测试中由 fixture 提供,不能把它当成完整 Instance/Database 观察验证。
|
||||
|
||||
Retain 释放和 Delete 清理仍未实现,当前删除会保持 DeletionPending 与 finalizer。
|
||||
真实后端供应、凭据交付与上述完整删除矩阵仍待后续切片验收。
|
||||
|
||||
绑定规则另有不依赖 Kubernetes 的单元测试;service 测试只验证操作顺序、失败停止与最终
|
||||
身份回读,不模拟 API server。原真实 API controller 测试覆盖完整分层调用,另验证 service
|
||||
返回后发生并发修改时,资源呈现拒绝过期结果,重试完成绑定且保留其他字段。
|
||||
|
||||
## Ayatori 已接入的凭据与 metadata 切片测试
|
||||
|
||||
本节命令已在 Ayatori 接入;以下历史 Compose/Kind 操作仍属于迁入的目标合同。
|
||||
|
||||
```sh
|
||||
make test
|
||||
make lint
|
||||
make lint-database-integration
|
||||
make test-database-integration
|
||||
```
|
||||
|
||||
最后一项要求本机 Docker 可用。它启动 envtest 的真实 API server/etcd 和固定镜像摘要的临时
|
||||
PostgreSQL 容器,随机绑定回环端口,不读取 kubeconfig,也不接受指向现有数据库的 DSN。
|
||||
每个凭据场景使用独立环境;测试清理仅关闭自己的进程和按确切 ID 删除自己的容器。
|
||||
TLS 测试在临时目录生成一次性证书与私钥,不使用生产 CA。
|
||||
|
||||
当前覆盖固定 namespace 的 Secret 读取与 RBAC、缺失/无效凭据恢复、有效凭据变化后的重连、
|
||||
无关字段更新不重连、中途轮换时丢弃观察、会话重建、并发读取、本地连接释放和 TLS 验证。
|
||||
快速测试、lint 和 Database 集成测试均使用 Pod runner。按维护者于 2026-09-21 更新的接口
|
||||
约定,runner 提供默认可用的 Docker;workflow 通过 `docker version` 和 `docker info` 预检,
|
||||
不自行启动 daemon、不强制 storage driver 或覆盖 Docker endpoint。该约定的 CI 验收依赖
|
||||
runner 后端修复上线,不能从本地测试通过推断远端已经可用。
|
||||
fixture 启动失败会保留退出错误与 stderr,并遮蔽测试密码,
|
||||
以区分缺少命令、daemon 不可达、权限和镜像拉取失败。
|
||||
|
||||
metadata 测试验证版本与可用扩展的只读查询,包括未安装扩展、大小写保持、search_path 遮蔽、
|
||||
低权限账号读取、catalog 访问被撤回后的失败与恢复,以及凭据中途变化时同时丢弃版本和扩展。
|
||||
权限撤回只修改每个场景自建 PostgreSQL 容器的 ACL;不连接现有服务。
|
||||
可用列表不等于安装权限,这些检查不替代后续的完整管理权限矩阵或 Instance Ready 验收。
|
||||
|
||||
Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的
|
||||
未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。
|
||||
|
||||
PostgreSQL adapter 测试不包含 controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。
|
||||
CRD 基础语义由前述 API 测试覆盖;版本查询成功不意味着 Instance Ready。
|
||||
|
||||
本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab
|
||||
中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性
|
||||
容器。这样既避免污染真实数据,也能把启动顺序固化为命令。
|
||||
|
||||
## 是否需要开发 VM
|
||||
|
||||
默认不需要。仓库的 devcontainer 使用独立 Docker-in-Docker daemon,Go 工具链、
|
||||
Kind 节点和依赖容器都与宿主机环境隔离。宿主机只需要能够运行支持 privileged
|
||||
container 的 Docker/Dev Container 环境。
|
||||
|
||||
只有以下情况才建议增加一台可随时重建的开发 VM:
|
||||
|
||||
- 宿主机不允许 privileged devcontainer;
|
||||
- 无法安全使用 Docker socket 或 Docker-in-Docker;
|
||||
- 本机地址段与 Kind/Docker 网络持续冲突;
|
||||
- 需要长期运行、接近 homelab 网络和 TLS 配置的验收环境。
|
||||
|
||||
即使使用 VM,也应在 VM 内继续执行本文相同的容器化流程;不要把 VM 配置成第二套
|
||||
手工维护的开发环境。
|
||||
|
||||
## 环境分层
|
||||
|
||||
| 层次 | Kubernetes | PostgreSQL / OpenBao | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| 单元测试 | fake client | fake client | SQL 计划、状态转换和错误分类 |
|
||||
| controller 集成测试 | envtest | fake adapter | CRD、watch、status、finalizer、ExternalSecret 对象 |
|
||||
| adapter 集成测试 | 不需要 | Docker Compose | 真实协议、权限和幂等行为 |
|
||||
| E2E | 一次性 Kind + ESO | Kind 内测试实例 | 凭据投射、TLS、完整网络和删除路径 |
|
||||
|
||||
envtest 只启动 API server 和 etcd,没有 kubelet、scheduler、ESO 或 controller-manager,
|
||||
因此不能用它验证 Deployment、Pod 调度或 Service 网络。此类行为必须留给 Kind
|
||||
E2E。
|
||||
|
||||
## 首次准备
|
||||
|
||||
推荐用支持 Dev Containers 的编辑器打开仓库。devcontainer 会提供 Go、Docker、
|
||||
Kubebuilder、Kind 和 kubectl。脚本固定 Kubebuilder 4.15.0、Kind 0.33.0 和
|
||||
kubectl 1.36.0,与当前脚手架和 Kubernetes Go module 对齐。容器启动后先确认:
|
||||
|
||||
```sh
|
||||
go version
|
||||
docker info
|
||||
kubebuilder version
|
||||
kind version
|
||||
kubectl version --client
|
||||
```
|
||||
|
||||
不要在仓库中保存真实 OpenBao Token、数据库密码或 kubeconfig。Compose 中的
|
||||
`postgres-dev-only` 和 `dev-only-root-token` 是仅绑定回环地址、随容器销毁的公开
|
||||
测试值,不得复制到其他环境。
|
||||
|
||||
## 日常开发的正确顺序
|
||||
|
||||
### 1. 生成并验证纯 Go/Kubernetes 部分
|
||||
|
||||
```sh
|
||||
make manifests generate
|
||||
make test
|
||||
make lint
|
||||
```
|
||||
|
||||
`make test` 会下载与 `go.mod` 中 Kubernetes minor 版本匹配的 envtest 二进制,
|
||||
启动临时 API server/etcd,测试结束后自动关闭。
|
||||
|
||||
规格实现后,快速测试必须覆盖默认值/校验、Condition `observedGeneration`、三资源的
|
||||
状态与绑定、不可变字段、extension 只追加、冲突和外部错误分类。envtest 只断言 controller 创建了正确
|
||||
的 ExternalSecret;它不能证明 ESO 已生成 Secret。
|
||||
|
||||
### 2. 启动 PostgreSQL/OpenBao adapter 依赖
|
||||
|
||||
只有开发 PostgreSQL/OpenBao adapter 或完整 reconcile 时才需要:
|
||||
|
||||
```sh
|
||||
make dev-up
|
||||
make dev-smoke
|
||||
```
|
||||
|
||||
启动顺序由 Compose healthcheck 保证:
|
||||
|
||||
1. 创建独立 Compose 网络;
|
||||
2. 启动 PostgreSQL 和 OpenBao;
|
||||
3. 等待 PostgreSQL `pg_isready` 成功;
|
||||
4. 等待 OpenBao `bao status` 成功;
|
||||
5. smoke test 执行 `SELECT 1`;
|
||||
6. smoke test 在 OpenBao dev server 默认的 `secret/` KV v2 mount 写入并读回测试管理
|
||||
凭据。
|
||||
|
||||
本机进程使用以下端点:
|
||||
|
||||
```text
|
||||
PostgreSQL: postgresql://postgres:[email protected]:15432/postgres
|
||||
OpenBao: http://127.0.0.1:18200
|
||||
Token: dev-only-root-token
|
||||
```
|
||||
|
||||
若端口冲突,可以只对当前命令覆盖:
|
||||
|
||||
```sh
|
||||
POSTGRES_DEV_PORT=25432 OPENBAO_DEV_PORT=28200 make dev-up
|
||||
```
|
||||
|
||||
后续执行 `dev-smoke` 和 controller 时必须使用相同端口变量。
|
||||
|
||||
Compose 使用明文 PostgreSQL/OpenBao dev 模式,不覆盖生产 TLS 合同。DNS SAN、IP SAN、
|
||||
Kubernetes auth、最小 policy 和 ESO 必须在 Kind E2E fixture 中验证。
|
||||
|
||||
### 3. 运行针对临时依赖的测试或 controller
|
||||
|
||||
adapter 集成测试通过独立 Make target 执行,不默认塞进快速单元测试:
|
||||
|
||||
```sh
|
||||
make test-integration
|
||||
```
|
||||
|
||||
该 target 会启动一次性 Compose 依赖,并通过 `POSTGRES_TEST_DSN` 把测试指向开发
|
||||
PostgreSQL。这是旧环境设计,不是当前 Ayatori 入口;当前 fixture 不接受外部 DSN,
|
||||
使用本页前部的 `make test-database-integration`,禁止把测试指向真实 homelab database。
|
||||
|
||||
Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问
|
||||
Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络,
|
||||
并通过 `postgres:5432` 运行集成测试;清理前先断开该网络,才能删除 Compose
|
||||
network。本机执行仍使用默认的 `127.0.0.1:15432`。
|
||||
|
||||
本机运行 controller 时,先确认当前 kubeconfig 指向专用 Kind,而不是真实 homelab:
|
||||
|
||||
```sh
|
||||
kubectl config current-context
|
||||
make setup-test-e2e
|
||||
kubectl config current-context
|
||||
make install
|
||||
make run
|
||||
```
|
||||
|
||||
此时 controller 运行在开发容器内,可以直接访问上面的回环端口。若要验证 Tenant
|
||||
Ready,专用 Kind 还必须安装 ESO、创建测试 ClusterSecretStore,并让 Kind workload
|
||||
能够访问测试 OpenBao。不要把包含
|
||||
`127.0.0.1` 端点的样例部署到 Kind 内;Pod 中的回环地址只指向 Pod 自身。
|
||||
|
||||
### 4. 清理
|
||||
|
||||
```sh
|
||||
make dev-down
|
||||
make cleanup-test-e2e
|
||||
```
|
||||
|
||||
`dev-down` 会删除 Compose volume;所有数据库和 OpenBao dev 数据都应视为一次性。
|
||||
|
||||
## E2E 顺序
|
||||
|
||||
CI 的 E2E 与本机 `make run` 不同:controller 会作为 Pod 运行在 Kind 中。因此完整
|
||||
E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等待依赖 Ready 后
|
||||
再创建 `PostgreSQLInstance` 和 `PostgreSQLTenant`:
|
||||
|
||||
```text
|
||||
创建 Kind
|
||||
-> 安装 CRD
|
||||
-> 部署 PostgreSQL/OpenBao fixture,签发含 DNS/IP SAN 的测试证书
|
||||
-> 安装 ESO,配置 OpenBao auth/policy 和 ClusterSecretStore
|
||||
-> 等待依赖 Ready 并写入测试管理凭据
|
||||
-> 构建并加载 controller image
|
||||
-> 部署 controller
|
||||
-> 创建 Instance
|
||||
-> 等待 Instance Ready
|
||||
-> 创建 Tenant
|
||||
-> 等待 Tenant Ready
|
||||
-> 验证 Database 绑定、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret
|
||||
-> 分别使用 DNS host 与 IP hostaddr 登录
|
||||
-> 删除 Tenant 并分别验证 Retain 与 Delete(含故障点重试)
|
||||
-> 删除 Kind
|
||||
```
|
||||
|
||||
以上 Compose/Kind 流程来自旧项目的环境设计,不是 Ayatori 已实现的运行状态。
|
||||
Ayatori 尚未完成 Instance/Database/Tenant controller 链路;当前可执行的切片命令以本页
|
||||
前部为准,不能从旧脚手架或 adapter 测试推断完整生命周期已经通过。
|
||||
|
||||
## 测试数据与泄漏检查
|
||||
|
||||
- 只使用显眼的固定 canary 测试密码,测试后扫描日志、Event、Condition、metrics 和
|
||||
CR dump,出现 canary 即失败。
|
||||
- 每个写入阶段注入中断:已确认步骤继续且密码不变;结果不确定则停止并明确报告 Conflict。
|
||||
- 缺少绑定/进度记录时不得凭同名外部对象恢复所有权;过期 phase 不得绕过实际观察。
|
||||
- 为未知同名 database、role、Bao record 和伪造 COMMENT 分别构造 Conflict。
|
||||
- Delete 在每个外部删除步骤失败后重试,确认未误删非当前 UID 资源。
|
||||
- 迁移测试按 [`migration.md`](migration.md) 完整执行,不以单纯 `pg_restore` 成功代替
|
||||
应用读写和回滚验证。
|
||||
|
||||
## 故障排查
|
||||
|
||||
查看依赖状态与日志:
|
||||
|
||||
```sh
|
||||
docker compose -f hack/dev/compose.yaml ps
|
||||
docker compose -f hack/dev/compose.yaml logs postgres openbao
|
||||
```
|
||||
|
||||
如果 envtest 报端口监听失败,通常是当前执行环境禁止监听回环端口,而非 controller
|
||||
失败;在 devcontainer 或允许本机监听的 runner 中执行。若 Kind 无法创建,先运行
|
||||
`docker info`,确认当前用户可以访问 devcontainer 内的 Docker daemon。
|
||||
@@ -1,90 +0,0 @@
|
||||
# Instance 领域对象规格
|
||||
|
||||
日期:2026-09-25。资源模型修订依据
|
||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以
|
||||
[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,Instance 应用/controller 观测链路已接入。
|
||||
|
||||
## 职责
|
||||
|
||||
Instance 是登记的 PostgreSQL 管理入口,只接收观察、判断规则,不直接或通过回调执行 IO。
|
||||
应用层读 Secret、调用 adapter、关联目标与观察,再将结果交给领域判定。
|
||||
领域不持有客户端、连接池、context 或完整 Database/Tenant 集合。
|
||||
|
||||
每轮由 CR 重建;连接可由应用层复用,但旧连接、旧 Ready 不是本轮能力证据。
|
||||
身份与 endpoint 以管理员声明为准,变更使观察失效,不验证物理服务器连续性,
|
||||
不迁移旧数据,不自动授权旧 UID 资源的操作。
|
||||
|
||||
## 字段与观察
|
||||
|
||||
| 内容 | 合同 |
|
||||
| --- | --- |
|
||||
| identity | Instance UID/name;同名新 UID 是新对象 |
|
||||
| revision | 当前 generation,不能与旧观察混用 |
|
||||
| definition | endpoint 与管理 Secret 引用,不含明文 |
|
||||
| checkpoint | Pending、Validating、Ready、Deleting |
|
||||
| observedRevision/readiness/version | 映射 CR status,只表示进度或最近结果 |
|
||||
| availableExtensions | 本轮实际可用集合;未观察与空集合不同 |
|
||||
| evidence | 本轮目标、版本与管理能力检查结果,不持久化为永久授权 |
|
||||
| deleting | 删除请求;禁止新的供应 |
|
||||
|
||||
CapabilityObservation 包含目标(UID、generation、endpoint、凭据引用)、server version、
|
||||
连接/metadata/role/database/grant/extension 管理检查项。各项区分成功、失败和未观察,
|
||||
不包含 registry 状态、凭据或驱动错误。缺项或目标不匹配不得 Ready。
|
||||
|
||||
实际可用扩展不等于安装权限,不做 allowlist,不改大小写;查询失败不当作不支持,
|
||||
列表变化不自动卸载。安装必须由实际操作及回读验证。
|
||||
resourceVersion 留在应用层处理 API 并发,不是领域版本或物理数据库身份。
|
||||
|
||||
## 行为
|
||||
|
||||
- Reconstitute 校验 definition,重建 checkpoint,丢弃旧 evidence。
|
||||
- BeginValidation 清空能力证据并进入 Validating。
|
||||
- AssessManagement/AssessReadiness 根据本轮完整观察判断 Ready 或安全失败;
|
||||
方法的具体合并方式在实现重构时决定,不保留无意义的中间初始化阶段。
|
||||
- CheckExtensions 判定请求集合,不能单独授权供应。
|
||||
- RequireProvisioningReady 检查本轮能力与删除状态;不授予 Database 所有权。
|
||||
- BeginDeletion 禁止新供应,不执行外部删除。
|
||||
- Snapshot 返回值副本,不序列化 evidence 或秘密。
|
||||
|
||||
撤销 PlanRegistryPreparation、AssessRegistryResult、RegistryPreparationResult、
|
||||
RegistryState 与 InitializingRegistry 的设计需求。不能用始终返回 Usable 的兼容层绕过旧逻辑。
|
||||
|
||||
## 应用与连接边界
|
||||
|
||||
管理凭据来自 controller namespace 的 Secret;管理员维护 ExternalSecret,ESO 同步。
|
||||
Instance 不直接访问 Bao。有效用户名/密码变化时应用层释放旧连接、用新值装配并重验;
|
||||
metadata/无关字段变化不重建。采集途中有效值变化必须丢弃观察,不因 generation 没变而复用。
|
||||
controller 不修改 PostgreSQL 密码、管理 Secret 或 Bao 管理凭据。
|
||||
|
||||
InspectManagement 是观察能力,不包含 schema 初始化/迁移。只读 metadata 查询仍不足以
|
||||
证明管理权限;真实权限矩阵由 adapter 定义和测试。连接复用由 pgxpool 提供,不自建池。
|
||||
已可用管理凭据下,Bao/ESO 当前故障不单独撤销 Instance Ready。
|
||||
|
||||
## 状态与删除
|
||||
|
||||
```text
|
||||
Pending → Validating → Ready
|
||||
Ready → Validating(配置/凭据变更或能力失效)
|
||||
任意阶段 → Deleting
|
||||
```
|
||||
|
||||
应用层保存意图、获取观察、领域判定、按 resourceVersion 保存结果;
|
||||
保存冲突重新装载,不能覆盖较新配置。Instance 观察本身不创建外部资源,
|
||||
初始 status 缺失可重新探测,不由此推出 Database 所有权可自动重建。
|
||||
|
||||
受管前先保存 finalizer。删除期间查询 Database 引用(包括 Released/删除中)及未绑定
|
||||
Tenant;存在引用或查询失败都等待,无引用才解除。不得级联删除数据库或凭据。
|
||||
finalizer 不阻止并发申请 CR 创建;新请求见 Instance 删除中/不存在时不得供应。
|
||||
不引入跨对象锁,不声称列表与删除之间存在原子事务。
|
||||
|
||||
## 验收与实现差距
|
||||
|
||||
领域单测覆盖缺项、旧配置、错误目标、扩展空集合与未观察、重验与删除禁用。
|
||||
真实 API server 验证 Secret、resourceVersion、watch、finalizer;真实 PostgreSQL 验证
|
||||
权限、TLS、凭据更新和查询失败,不以领域布尔值或 server_version 查询代替管理权限验收。
|
||||
|
||||
Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察
|
||||
直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项
|
||||
在这两个入口的失败与恢复。原生权限检查、Instance controller、metadata-only Secret watch
|
||||
和引用删除保护已接入,验证矩阵见 [模块说明](README.md#instance-原生管理观测);
|
||||
Database/Tenant 的供应、交付和回收仍未完成。
|
||||
@@ -1,94 +0,0 @@
|
||||
# Database 领域模型
|
||||
|
||||
状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-25。
|
||||
行为以 [系统规格](specification.md) 为准;决策依据见
|
||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。
|
||||
|
||||
## 统一语言与关系
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| Instance | 平台登记的 PostgreSQL 资源来源与管理入口 |
|
||||
| Database | 独立存在的数据库资源,保存目标、管理范围、绑定与回收策略 |
|
||||
| Tenant | 用户对数据库的申请与使用合同 |
|
||||
| Binding | Database 与 Tenant 的排他关联,不是独立 Claim 或 registry |
|
||||
| LoginRole | 当前单数据库场景中兼任 owner 的登录角色 |
|
||||
| CredentialLocation | 与资源生命周期一致的凭据定位,不是密码 |
|
||||
| CredentialProjection | 面向当前使用者的凭据投射要求与观察 |
|
||||
|
||||
Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。
|
||||
Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。
|
||||
|
||||
Instance 与 Database 是集群级资源,Tenant 位于 namespace。Tenant 按名称引用 Database,
|
||||
Database 记录所绑定 Tenant 的 namespace/name/UID。Database 不属于应用 namespace;
|
||||
平台管理员管理资源及回收策略,普通申请者不能自行将 Released 资源重新开放。
|
||||
|
||||
Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。
|
||||
Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。
|
||||
释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。
|
||||
|
||||
Database 不再是 Tenant 内的无独立生命周期描述。原 OwnershipClaim 不再作为独立领域能力:
|
||||
排他绑定是资源自身的不变量,Kubernetes 保存记录,不另建 PostgreSQL 所有权存储。
|
||||
|
||||
## 职责
|
||||
|
||||
Instance 只接收观察并判断当前连接、metadata、管理权限和扩展支持,不访问 IO、不初始化
|
||||
registry。管理凭据来源和连接刷新由应用层协调,连接池由 pgxpool 实现。见
|
||||
[Instance 规格](domain-instance.md)。
|
||||
|
||||
Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前
|
||||
必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。
|
||||
|
||||
Tenant 表达申请与交付要求;可显式申请未绑定且可用的资源,不增加反向授权名单。
|
||||
绑定后检查数据库满足要求、
|
||||
应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。
|
||||
|
||||
第一版 Database 的生命周期边界包含一个 database、一个兼任 owner 的 LoginRole 及其
|
||||
应用凭据。LoginRole 与 CredentialLocation 随 Database 保留,不能因为 Tenant 消失就
|
||||
失去定位或未经授权被删除;导入时的管理授权与具体字段仍需评审。不新增 Role、Credential
|
||||
或 Claim CRD,也不预留多账号集合;若出现一库多账号的实际需求,再通过后续 API 版本演进。
|
||||
|
||||
## 生命周期与恢复
|
||||
|
||||
- 动态创建与显式导入最终形成同一种 Database 资源,但导入本身不允许改密、改 owner 或删除。
|
||||
- Retain 后 Database 保持 Released 与旧绑定身份,人工确认数据、权限和凭据后才可重新绑定。
|
||||
- 回收策略属于资源侧;Tenant 与 Database 不是可随申请级联 GC 的父子关系。
|
||||
- 回收策略默认 Retain,进入删除流程前可由资源管理者修改,进入后固定;Delete 无额外审批。
|
||||
- 动态凭据位置按 Database UID 确定,导入显式关联已有凭据;Released 不自动改密。
|
||||
- 绑定 UID 防止同名新申请继承权限。双向记录的单边写入不代表绑定完成。
|
||||
- 动态 Database 名称由 Tenant UID 确定;先持久化资源侧 Tenant 引用,再更新 Tenant status
|
||||
的 Database 引用。后一写入失败由 reconcile 核对身份后补齐,不回滚资源侧记录;
|
||||
其他 Tenant 已占用则报冲突。双向一致后才供应或交付,绑定不等于 Ready。
|
||||
- 普通失败按 reconcile 重试;可靠确认的步骤幂等继续;不确定创建/未知同名对象报告 Conflict。
|
||||
- Kubernetes status 是持久进度和观察,不是外部事实,也不是 controller 内存。
|
||||
不引入“status 任意丢失后自动恢复所有权”的附加要求。
|
||||
|
||||
## 分层
|
||||
|
||||
| 层 | 责任 |
|
||||
| --- | --- |
|
||||
| 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO |
|
||||
| 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 |
|
||||
| controller | watch/调度、调用用例、请求资源呈现与安排重试;不判断绑定资格 |
|
||||
| adapter | Kubernetes 资源映射与呈现(含 conditions/status/finalizer)、后端访问与安全错误分类 |
|
||||
| 装配 | 客户端与成熟连接池的生命周期,不是领域状态 |
|
||||
|
||||
不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。
|
||||
resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。
|
||||
|
||||
绑定实现中,`domain/binding` 承载请求默认值、资源身份匹配、实例就绪与排他绑定规则;
|
||||
`application/BindingService` 协调固定申请、资源侧写入和回读确认,返回待呈现结果。
|
||||
两层均不依赖 Kubernetes API 类型。`adapter/kubernetes/BindingResources` 将 CR 转换为事实
|
||||
快照,并负责保留其他字段、检查快照版本、写入 finalizer 和呈现 Conditions/status。
|
||||
controller 仅连接事件、service 与呈现层,不把资源写入细节和领域判断塞进 Reconcile。
|
||||
这里的接口只列出绑定用例所需操作,不扩展成通用 CRUD、Repository 或事务框架。
|
||||
|
||||
## API 切片前需明确
|
||||
|
||||
- 引用与绑定字段的最终格式及校验、管理员与 controller 的权限落实。
|
||||
- 资源侧回收策略与 Tenant/Database finalizer 配合。
|
||||
- 导入时角色/凭据的管理范围及关联字段、旧使用者撤权及投射清理。
|
||||
- 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。
|
||||
- 管理员确认冲突、解除旧绑定的具体可审计操作入口。
|
||||
|
||||
这些细节不阻止已确认的三资源设计,但必须先于对应 API 与生命周期实现获得评审。
|
||||
@@ -1,147 +0,0 @@
|
||||
# 现有数据库导入与迁移 Runbook
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Review;尚未在临时 PostgreSQL 演练 |
|
||||
| 适用范围 | 管理员显式导入,或通过 dump/restore 迁移到新资源 |
|
||||
| 最后更新 | 2026-09-24 |
|
||||
|
||||
当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。
|
||||
导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API schema 与绑定协调已实现,
|
||||
但导入观察和凭据交付尚未实现,以下导入步骤
|
||||
是验收要求而非可直接执行的命令。
|
||||
|
||||
## 显式导入与保留资源复用
|
||||
|
||||
1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。
|
||||
2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。
|
||||
3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。
|
||||
4. Tenant 显式引用未绑定且可用的 Database;controller 验证要求并建立排他绑定,无额外名单审批。
|
||||
5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。
|
||||
|
||||
Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到
|
||||
人工处理完成,不仅靠清空 claimRef 或修改 UID 授予新使用权。
|
||||
|
||||
导入失败时原数据库应保持不变;撤回登记不得触发 Delete。绑定后的回退按 Retain 释放,
|
||||
检查新投射与访问授权的影响,不能承诺撤回 CR 自动恢复此前所有外部访问状态。
|
||||
|
||||
## 可选的 dump/restore 路径
|
||||
|
||||
不适合直接导入、需要改变 owner/权限模型或移动数据时,可使用下述逻辑迁移流程。
|
||||
它不是纳管现有数据库的唯一路径;保留旧资源作为限时回滚点。
|
||||
|
||||
以下命令是顺序模板,不可原样复制到真实环境。先把尖括号变量解析成明确值,确认当前
|
||||
连接目标,再逐条执行。dump 可能包含敏感业务数据,必须放在加密临时存储且不得提交 Git。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已验证 PostgreSQL/OpenBao 备份和恢复;记录恢复点。
|
||||
- Instance 已 Ready,目标 namespace 存在,ESO ClusterSecretStore Ready。
|
||||
- 最终 database/login role 当前由旧应用占用,但改名后的保留名称、新推导的 Bao path
|
||||
均不存在。
|
||||
- 已记录旧 database owner、grants、extensions、locale/encoding、连接配置和验证清单。
|
||||
- 已确认应用可停止写入,并确定回滚窗口和负责人。
|
||||
- 已确认旧 login role 不被其他 database/应用共享,且角色改名不会破坏未纳入本次维护
|
||||
的依赖。
|
||||
|
||||
## 迁移顺序
|
||||
|
||||
### 1. 盘点与预演
|
||||
|
||||
```sh
|
||||
pg_dump --schema-only --no-owner --no-privileges \
|
||||
--dbname='<old-admin-connection>' > schema-preview.sql
|
||||
```
|
||||
|
||||
检查不受 v1alpha1 管理的对象:额外 roles、跨库依赖、FDW、large objects、订阅、显式
|
||||
tablespace、owner/grant 和目标实例不支持的 extension。无法映射为单 database + 单 login
|
||||
owner 的环境必须先人工简化,不能让 controller 猜测。
|
||||
|
||||
### 2. 创建一致性 dump
|
||||
|
||||
停止应用写入并确认活跃写事务结束,然后创建最终 custom-format dump:
|
||||
|
||||
```sh
|
||||
pg_dump --format=custom --no-owner --no-privileges \
|
||||
--file='<secure-temp>/tenant.dump' \
|
||||
--dbname='<old-admin-connection>'
|
||||
pg_restore --list '<secure-temp>/tenant.dump'
|
||||
```
|
||||
|
||||
不要删除旧 database/role。记录停写时间、dump checksum 和 PostgreSQL 版本。
|
||||
|
||||
### 3. 释放最终名称
|
||||
|
||||
保持应用停写,终止旧 database 的应用连接。连接其他管理 database,以管理员身份把旧
|
||||
database 和旧 login role 改为明确的保留名称:
|
||||
|
||||
```sql
|
||||
ALTER DATABASE <old_database> RENAME TO <old_database>_retained_<timestamp>;
|
||||
ALTER ROLE <old_login_role> RENAME TO <old_login_role>_retained_<timestamp>;
|
||||
```
|
||||
|
||||
identifier 必须由管理员工具安全引用,不能把未经校验的值直接拼入 SQL。PostgreSQL 在
|
||||
角色改名时会清除以旧角色名加盐的 MD5 密码;使用 MD5 的旧环境必须在维护前准备安全的
|
||||
密码重设/回滚方法。SCRAM verifier 不受角色名改动影响,但仍须实际验证回滚登录。
|
||||
|
||||
### 4. 创建受管空目标
|
||||
|
||||
应用 `PostgreSQLTenant`,使用未被占用的 database/loginRole,等待 Ready。确认:
|
||||
|
||||
- Database/Instance 身份及 Tenant 排他绑定正确;
|
||||
- OpenBao 凭据位置与 Database 管理范围及当前交付授权一致;
|
||||
- ExternalSecret Ready 且目标 Secret 已投射;
|
||||
- 新凭据可以通过 DNS host 和 IP hostaddr 分别登录空 database。
|
||||
|
||||
### 5. Restore
|
||||
|
||||
从 OpenBao 或目标 Secret 安全取得新应用凭据,不要把密码放进 shell history。以新 login
|
||||
owner 连接目标 database:
|
||||
|
||||
```sh
|
||||
pg_restore --exit-on-error --no-owner --no-privileges \
|
||||
--dbname='<new-application-connection>' \
|
||||
'<secure-temp>/tenant.dump'
|
||||
```
|
||||
|
||||
extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore
|
||||
行为幂等;目标实例不支持的 extension 必须在迁移前解决。
|
||||
|
||||
### 6. 验证并切换
|
||||
|
||||
- 对比关键 schema、表数、行数/校验和、sequence、function 和 migration version。
|
||||
- 用新 login 验证读写、migration 和应用健康检查。
|
||||
- 将应用配置切换到新 Secret 或 OpenBao URL,保持旧数据库只读/停写。
|
||||
- 观察一个约定窗口,确认错误率、连接数和关键业务功能。
|
||||
|
||||
### 7. 收尾
|
||||
|
||||
回滚窗口结束后,按独立变更删除旧 database/role/旧凭据;它们不属于 controller,禁止
|
||||
通过新 Database 的 `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。
|
||||
|
||||
## 回滚
|
||||
|
||||
在新目标出现问题且旧资源仍保留时:
|
||||
|
||||
1. 立即停止新目标写入。
|
||||
2. 评估切换后是否产生新数据;若有,先决定反向迁移或接受丢弃,不能盲目切回。
|
||||
3. 将应用连接切回 retained database/role;若必须恢复原名称,先确保新受管目标已用
|
||||
资源侧 `Delete` 完整清理或改用不同名称,再安全地反向执行 rename。
|
||||
4. 恢复旧凭据(MD5 环境可能需要重设),验证旧服务。
|
||||
5. 保留失败 Tenant 供排障;修改 Database 的 Retain/Delete 策略前明确其外部资源后果。
|
||||
|
||||
若已经删除旧资源,则只能使用已验证备份恢复,不再属于本 runbook 的快速回滚。
|
||||
|
||||
## 演练验收
|
||||
|
||||
发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录:
|
||||
|
||||
- 显式导入的管理权限、Released 重新开放前的旧访问处置和失败不修改原资源;
|
||||
- 使用的 PostgreSQL major version 和命令版本;
|
||||
- dump/restore 返回码和对象差异;
|
||||
- DNS/IP TLS 登录结果;
|
||||
- ESO 投射与应用启动结果;
|
||||
- 回滚演练结果;
|
||||
- 哪些命令或前置检查需要修订。
|
||||
|
||||
完成演练前,本文不得标记为 `Verified`。
|
||||
@@ -1,83 +0,0 @@
|
||||
# 运维与故障处理
|
||||
|
||||
状态:设计合同,操作入口待 API 实现与隔离环境演练。日期:2026-09-24。
|
||||
依据 [系统规格](specification.md),不再查询或维护 PostgreSQL registry。
|
||||
|
||||
## 当前绑定切片的限制
|
||||
|
||||
源码已接入绑定 controller,未接入 PostgreSQL 供应、OpenBao/ESO 交付或删除清理。
|
||||
Bound/BindingComplete 只表示 Kubernetes 双向记录一致,Ready 仍为 False。
|
||||
Tenant 删除会保留 `database.ayatori.ddupan.top/tenant-protection` 并报告 DeletionPending;
|
||||
Database 的 `database.ayatori.ddupan.top/database-protection` 也尚无清理后移除路径。
|
||||
这是未完成能力的明确边界,不是已经实现的 Retain/Delete 恢复逻辑。不要将此切片部署为
|
||||
业务 DBaaS,也不要为了消除等待状态直接移除 finalizer;后续必须补齐清理与验收。
|
||||
|
||||
## 日常检查
|
||||
|
||||
Instance 观察已实现:先确认 manager 配置了 `--database-secret-namespace` 或 `POD_NAMESPACE`,
|
||||
再检查 Ready Reason、observedGeneration 与管理 Secret 名称/字段映射,切勿导出其 data。
|
||||
`InsufficientPrivileges` 表示当前原生方案要求的非 superuser、CREATEDB/CREATEROLE 不满足;
|
||||
`CredentialsChanged` 会丢弃中途轮换的结果并重验;`InstanceInUse` 消息定位阻塞删除的资源。
|
||||
Secret 事件立即入队,30 秒重查覆盖 PostgreSQL 权限等没有 Kubernetes 事件的外部变化。
|
||||
Instance 删除不要求 PostgreSQL 可达,但必须可读取所有 Database/Tenant 引用。
|
||||
|
||||
先看 Instance、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration,
|
||||
再核对 PostgreSQL catalog、OpenBao metadata、ExternalSecret 与 Secret 投射状态。
|
||||
具体 kubectl 资源名、finalizer 名称与人工确认字段在 API 实现后补齐,不提供猜测的 patch 命令。
|
||||
不得把 Secret data、密码或带 Token 的请求粘贴到 issue/日志。
|
||||
|
||||
## 故障分类
|
||||
|
||||
| Reason/状态 | 首要检查 |
|
||||
| --- | --- |
|
||||
| InvalidSpec / ImmutableField | 请求、名称、不可变目标与只追加扩展约束 |
|
||||
| DependencyUnavailable | 网络、DNS、服务状态和超时;恢复后退避重试 |
|
||||
| AuthenticationFailed | 管理 Secret、TLS、OpenBao auth |
|
||||
| InsufficientPrivileges | PostgreSQL/OpenBao 权限与 Kubernetes RBAC |
|
||||
| InstanceNotReady | 当前目标的管理能力,不检查 registry |
|
||||
| Conflict | 绑定 UID、未知同名资源、失败步骤与外部结果确定性 |
|
||||
| CredentialProjectionFailed | 授权的凭据位置、Store、ESO 与目标 Secret |
|
||||
| Released | 资源已保留,不代表可直接交给另一个 Tenant |
|
||||
|
||||
## 创建不确定或同名冲突
|
||||
|
||||
1. 保留 CR、绑定与安全诊断,不清空 status、不反复删除重建申请。
|
||||
2. 核对确切 Instance/database/role 和凭据位置;区分已确认完成与结果不确定的操作。
|
||||
3. 使用只读检查确认资源内容、使用者和权限,不通过重设密码来“验证归属”。
|
||||
4. 管理员决定清理确定的残留后重试,或显式导入保留资源;涉及删除需另有明确授权。
|
||||
5. 记录处理依据,再按 API 的受控入口恢复协调。
|
||||
|
||||
普通依赖故障可以自动继续,未知归属不得因后端恢复就自动认领。
|
||||
controller 重启保留 Kubernetes 中的记录,不需要恢复第二套 registry。
|
||||
|
||||
## Retain 与重新绑定
|
||||
|
||||
Tenant 删除后 Database 及实际资源保留,进入 Released,保存旧绑定身份。
|
||||
不要删除 Database 对象来“释放名称”,也不要只修改 UID 或 Ready 强行交付。
|
||||
|
||||
管理员先确认数据是否允许交给新使用者、旧角色是否共享、旧账号访问如何撤销或保留、
|
||||
新使用者如何获得凭据,以及原 ExternalSecret/Secret 的处置。删除 Secret 不会撤销已持有密码
|
||||
的 PostgreSQL 访问。完成这些处置后,才通过显式授权重新绑定;不自动回到可分配状态。
|
||||
具体凭据关联与解除绑定字段尚待 API 评审,当前不能宣称已有可执行恢复命令。
|
||||
|
||||
## Delete 卡住
|
||||
|
||||
核对 Database/Instance/绑定身份、资源侧 Delete 授权及实际管理范围,修复相关依赖,
|
||||
让 controller 从已确认的步骤继续。不要删除共享角色或未纳管凭据,不使用扩大范围的 CASCADE。
|
||||
|
||||
依赖永久丢失时列出每个可能残留的数据库、角色、凭据与投射。只有管理员接受残留与后续处置
|
||||
责任后才人工移除确切对象的 finalizer。该操作不会完成清理,也不会授权新申请使用残留资源。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
分别备份 PostgreSQL 数据、Kubernetes 资源与绑定记录、OpenBao 数据及必要配置。
|
||||
不再要求备份专用 registry。只复制在线磁盘不等于有效数据库备份;秘密备份必须加密并限制访问。
|
||||
|
||||
灾难恢复先暂停 controller,核对三者恢复点、UID、外部目标与凭据的一致性,再恢复协调。
|
||||
不一致时按 Conflict 人工处理,不承诺仅凭外部同名数据库重建丢失绑定。
|
||||
在隔离环境演练登录、导入、Retain、重新绑定与 Delete 后才能标记验证通过。
|
||||
|
||||
## 紧急停止
|
||||
|
||||
疑似越权删除或秘密泄漏时暂停 controller,保留 CR 与脱敏证据,限制相关管理身份权限,
|
||||
在隔离环境复现并确认修复后恢复。一般依赖失败不需要停机。
|
||||
@@ -1,78 +0,0 @@
|
||||
# 安全模型
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Review |
|
||||
| 最后更新 | 2026-09-25 |
|
||||
|
||||
## 保护目标
|
||||
|
||||
- 应用密码只存在于 OpenBao、ESO 投射的目标 Secret 和需要使用它的进程内存中。
|
||||
- controller 只能在 Database 已确认的管理范围和当前绑定/操作授权内修改资源;未知同名对象报冲突。
|
||||
- namespace 租户不能越权管理 Instance、Database 导入/回收、其他 namespace 或 controller 配置。
|
||||
- PostgreSQL 和 OpenBao 的网络身份使用受信 CA 验证,不因 DNS 不可用而降级 TLS。
|
||||
|
||||
## 信任边界
|
||||
|
||||
Kubernetes 管理员、OpenBao 管理员和 PostgreSQL 管理员是平台信任主体。能读取 Tenant
|
||||
目标 Secret 或对应 OpenBao path 的主体等同于持有数据库账号。database owner 可以
|
||||
改变自己 database 内的对象,因此 COMMENT 不能作为 controller 所有权依据。
|
||||
|
||||
数据库备份包含业务数据;Kubernetes 保存资源与绑定记录;OpenBao 保存应用凭据。
|
||||
完整灾难恢复必须分别保护三者并核对恢复点,不依靠数据库内 registry 重建绑定。
|
||||
|
||||
## 凭据处理
|
||||
|
||||
- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。
|
||||
- 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到
|
||||
CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。
|
||||
- 动态供应密码使用密码学安全随机源;已有可靠关联时复用 OpenBao 现值,结果不确定时停止并报冲突。
|
||||
- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。
|
||||
- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、
|
||||
DSN、Authorization header、密码或完整 OpenBao URL path 作为 metric label。
|
||||
- panic、错误包装和测试失败输出必须经过凭据泄漏测试。
|
||||
|
||||
## TLS
|
||||
|
||||
- homelab 默认 `verify-full`,`disable` 只允许显式开发配置。
|
||||
- server 证书同时覆盖 DNS `host` 和 IP `hostaddr`;消费者自行选择连接目标。
|
||||
- OpenBao PKI 保管 CA 私钥并负责签发/续期。controller Deployment 只挂载公开 CA
|
||||
bundle,挂载只读且使用最小文件权限。
|
||||
- 证书轮换必须先发布同时信任新旧 CA 的 bundle,再轮换服务端证书,最后移除旧 CA。
|
||||
|
||||
## 最小权限
|
||||
|
||||
OpenBao controller identity 只管理固定 tenant base path,不读取管理凭据。管理凭据
|
||||
ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者隔离,Tenant 不得
|
||||
使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance
|
||||
不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。
|
||||
|
||||
2026-09-25 维护者确认第一版使用原生非 superuser 管理 role,具有 CREATEDB/CREATEROLE,
|
||||
不引入 SECURITY DEFINER 接口。Instance 检查拒绝 superuser;具体已有资源的 owner 和
|
||||
membership 仍需逐资源验证,不能把基础能力用于接管他人资源。扩展按实际权限安装,
|
||||
不因可用列表包含某个扩展就默认能安装它。controller 不调用 shell 或 `psql` 拼接用户输入。
|
||||
当前检查与真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。
|
||||
|
||||
Kubernetes RBAC 应把 Instance 管理、Database 导入、Released 重新开放和回收限制给平台管理员。
|
||||
有权创建 Tenant 的申请者可显式申请未绑定且可用的 Database,不增加资源侧允许绑定名单
|
||||
或逐 Tenant 审批。Released 必须先由管理员处理旧访问并重新开放。Tenant editor
|
||||
不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。
|
||||
|
||||
## 删除保护
|
||||
|
||||
资源侧 Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance/Database UID、
|
||||
绑定、实际目标及角色/凭据管理范围;CR 中记录了意图不等于外部对象由本系统创建。
|
||||
禁止对未知对象使用 `CASCADE`。删除 finalizer 卡住时只能按
|
||||
[`operations.md`](operations.md) 核实外部状态后人工移除;该操作可能遗留资源。
|
||||
|
||||
## 发布前安全验收
|
||||
|
||||
- 使用错误 CA、错误 DNS 名和错误 IP 时连接失败;正确 DNS/IP SAN 均成功。
|
||||
- namespace 用户不能修改 Instance 或跨 namespace Tenant/ExternalSecret。
|
||||
- controller/ESO 的 OpenBao policy 互相隔离,越权请求被拒绝。
|
||||
- 应用 login 不能创建 role/database,也不能连接其他租户 database。
|
||||
- 日志、Event、Condition、metrics、CR 导出和测试 artifact 不含 canary password/token。
|
||||
- 伪造 COMMENT、同名 database/role 或错误 UID metadata 均不能绕过 Conflict。
|
||||
- Delete 只销毁 Database 已确认管理范围内且获得删除授权的资源。
|
||||
- 导入检查不改密码/owner,Released 不自动授权新使用者;旧访问处理后才能重新交付。
|
||||
- 导入默认 Retain;角色/凭据的管理与删除范围未明确时不得扩大操作范围。
|
||||
@@ -1,232 +0,0 @@
|
||||
# Ayatori Database 系统规格
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 |
|
||||
| 目标 API | `database.ayatori.ddupan.top/v1alpha1` |
|
||||
| 最后更新 | 2026-09-25 |
|
||||
| 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) |
|
||||
|
||||
本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry
|
||||
持久所有权、任意 status 丢失自动恢复的设计。批准设计不表示实现已完成。
|
||||
未决字段不能由实现自行补成新产品约定。
|
||||
|
||||
## 1. 范围
|
||||
|
||||
在已存在的 PostgreSQL 实例上供应独立数据库、一个兼任 owner 的 login role、申请的扩展及
|
||||
应用凭据;支持管理员显式导入已有数据库。Kubernetes API 管理声明与绑定,OpenBao 保存
|
||||
应用凭据,ESO 向应用 namespace 投射 Secret。
|
||||
|
||||
不运行 PostgreSQL、VM、存储、备份或 OpenBao;不提供跨实例数据迁移、自动密码轮换、
|
||||
多角色权限产品或跨系统事务。备份与数据恢复仍由管理员负责。
|
||||
|
||||
## 2. 资源与职责
|
||||
|
||||
```text
|
||||
Instance
|
||||
└─ Database × N 独立持久资源
|
||||
└─ Tenant × 0..1 排他绑定的用户申请
|
||||
```
|
||||
|
||||
| 资源 | 职责 | 不承担的职责 |
|
||||
| --- | --- | --- |
|
||||
| Instance | 登记实例、管理连接、能力与供应前置条件 | 持有租户集合、保存所有权表 |
|
||||
| Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 |
|
||||
| Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 |
|
||||
|
||||
Instance 与 Database 为 cluster-scoped,Tenant 为 namespaced。Database 的工作名称是
|
||||
PostgreSQLDatabase;字段拼写与导入授权细节待 API 评审。
|
||||
Database 由平台管理员管理,不属于应用 namespace,不引入资源专用 namespace。
|
||||
Tenant 按名称引用 Database;Database 绑定记录包含 Tenant 的 namespace/name/UID。
|
||||
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放。
|
||||
|
||||
2026-09-25 确认:第一版以一个 database、一个兼任 owner 的 login role 及其应用凭据
|
||||
作为 Database 的生命周期边界,Tenant 负责申请与交付,不单独拥有账号或凭据生命周期。
|
||||
不预留多账号字段,不新增独立 Role、Credential 或 Claim CRD。一库多账号若出现实际需求,
|
||||
通过后续 API 版本演进处理,不纳入 v1alpha1。此边界不扩大导入资源的管理授权。
|
||||
|
||||
Database 自身必须声明 `instanceRef`,手工登记时同时指定实际数据库名;无需先存在 Tenant,
|
||||
即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database;
|
||||
选择已有 Database 的 Tenant 从资源获取 Instance,不重复指定另一份来源。资源与实例的归属
|
||||
独立于使用绑定,Tenant 删除后仍保留;修改引用不是数据库迁移。
|
||||
|
||||
参考 [Kubernetes PV/PVC](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 的
|
||||
资源/申请分离与绑定生命周期,不复制存储调度和 CSI 协议。资源与申请的关系不是 GC 所有关系。
|
||||
|
||||
## 3. 身份与事实来源
|
||||
|
||||
- Kubernetes spec 保存声明;受保护的资源绑定记录与 status 保存身份关联、操作进度和观察。
|
||||
它们通过 API 持久化,不因 controller 重启而消失。
|
||||
- PostgreSQL catalog 是 database、role、grant、extension 实际状态的来源。
|
||||
- OpenBao 是应用凭据的事实来源;ESO 状态和目标 Secret 存在性说明投射结果。
|
||||
- Instance、Database、Tenant 以 UID 区分对象身份;namespace/name 用于定位,
|
||||
同名新 UID 不继承旧绑定。数据库 OID 仅供诊断,不是永久身份或删除授权。
|
||||
- 不新增 PostgreSQL 所有权表、安装身份表或 Retain 墓碑;COMMENT 也不能授权认领。
|
||||
- 记录操作意图不等于外部操作成功,phase 不等于外部所有权;执行前后仍须观察实际状态。
|
||||
|
||||
## 4. Instance 合同
|
||||
|
||||
Instance 声明 host、hostaddr、port(默认 5432)、管理 database(默认 postgres)、
|
||||
TLS mode(默认 verify-full)及 controller namespace 的管理 Secret 名称和字段映射。
|
||||
禁止隐式 TLS 降级。凭据引用不接受自选 namespace 或 OpenBao path。
|
||||
|
||||
管理员维护 ExternalSecret,由 ESO 同步管理 Secret;controller 只读,不修改管理密码,
|
||||
不直接从 Bao 取管理凭据。有效用户名或密码变化时释放旧连接并重验;仅 metadata 或无关
|
||||
字段变化不重建。中途凭据变化必须丢弃旧观察。已有有效管理凭据时,Bao/ESO 故障本身
|
||||
不撤销 Instance Ready;首次缺少有效 Secret 时不能 Ready。
|
||||
|
||||
Ready 要求当前目标的连接、服务器 metadata 和所需管理能力检查通过,不要求创建、
|
||||
迁移或读取 registry,也不证明备份或高可用。阶段简化为 Pending → Validating → Ready,
|
||||
删除进入 Deleting。实际扩展可用列表不等于安装权限;查询失败不等于不支持。
|
||||
|
||||
endpoint 变更由管理员负责评估,不验证物理服务器连续性,不迁移或清理旧目标;
|
||||
旧观察失效。新 UID 不接管旧资源。Instance 开始受管前保存 finalizer;删除时停止新供应,
|
||||
只要有引用它的 Database(包括 Released/删除中)或尚未绑定的 Tenant 就等待。
|
||||
查询失败不视为无引用;无引用才解除 finalizer,不级联删除任何业务资源。
|
||||
引用检查不是跨对象事务,正在删除或不存在的 Instance 不允许开始新的供应/绑定。
|
||||
|
||||
## 5. 动态供应与排他绑定
|
||||
|
||||
1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。
|
||||
绑定 controller 在触及资源侧绑定前将 Tenant 进度记为 Binding,固定申请目标,
|
||||
避免两次绑定写入之间修改引用占用第二个资源;该进度不是已绑定的声明。
|
||||
2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。
|
||||
动态创建的 Database 名称由 Tenant UID 确定;重试复用同一记录,不重复创建。
|
||||
3. 先在 Database 写入 Tenant namespace/name/UID,再在 Tenant status 写入 Database
|
||||
name/UID;双向记录一致后才允许供应或交付。
|
||||
4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。
|
||||
5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。
|
||||
|
||||
绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。
|
||||
每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。
|
||||
绑定 API 写入采用 resourceVersion 并发控制;双向记录不原子,单边完成不得授予使用权限。
|
||||
采用 Kubernetes PV/PVC 的资源侧先写模式,参考
|
||||
[官方 bind 实现](https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/volume/persistentvolume/pv_controller.go)。
|
||||
Database 已绑定其他 Tenant 时报告 Conflict,不抢占;API 更新版本冲突时重新读取并判断,
|
||||
不能盲目覆盖。资源侧成功而 Tenant status 写入失败时,下一次 reconcile 核对双方身份后
|
||||
补写,不因单次失败撤销资源侧绑定。普通 controller 重启沿用这些持久记录继续协调。
|
||||
这只处理 Kubernetes 绑定记录的部分完成,不提供外部数据库不确定创建结果的自动认领。
|
||||
绑定成功不代表 Ready,具体字段及并发、重启、单边写入恢复必须由真实 API server 测试验证。
|
||||
|
||||
不同 Database 记录请求同一外部名称仍可能竞争,不能仅靠 Kubernetes 中的列表检查保证
|
||||
PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。
|
||||
不为此新增跨系统锁或 registry。管理员也不得把同一物理数据库登记成多个可绑定资源。
|
||||
|
||||
## 6. 显式导入
|
||||
|
||||
管理员创建资源声明,明确 Instance、已有数据库和允许管理的范围,构成导入授权。
|
||||
初始检查只读验证存在性、owner、角色权限和扩展等是否匹配;不匹配报告清楚的差异,
|
||||
不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。
|
||||
|
||||
未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。
|
||||
有权创建 Tenant 的申请者可以显式引用已登记、未绑定且可用的 Database;不增加资源侧
|
||||
允许绑定名单或逐 Tenant 的管理员审批。绑定仍检查目标、可用状态与排他关系。
|
||||
Released 不在可申请范围,必须由管理员处理旧访问并重新开放。导入时显式关联已有凭据,
|
||||
不通过隐式改密生成替代凭据;具体关联字段在 API 中定义。
|
||||
|
||||
## 7. Retain、重新绑定与 Delete
|
||||
|
||||
回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。
|
||||
有资源管理权限的主体可在进入删除流程前修改 Retain/Delete;进入删除流程后策略固定。
|
||||
显式设置 Delete 就是删除授权,不增加第二次审批或确认字段。
|
||||
Database 不得设置会让它随 Tenant 消失的 ownerReference。
|
||||
|
||||
### Retain
|
||||
|
||||
- 保留 Database 对象、外部数据以及与资源关联的角色和凭据,不自动删除或重置。
|
||||
- Tenant 释放后 Database 进入 Released,保留旧绑定身份用于诊断和防止自动复用。
|
||||
Released 不是 Available,不再向原申请交付新状态,也不自动分配给同名新 Tenant。
|
||||
- 保留策略不要求 PostgreSQL/Bao 在线才能完成申请释放,但必须先将释放关系安全记录到
|
||||
Kubernetes;API 写入失败时不能宣称释放完成。仍有在途操作时不得跳过必要协调。
|
||||
- 管理员检查数据、旧账号访问与凭据后,显式授权重新绑定。保留数据的复用可以不清空数据,
|
||||
但必须由管理员确认新使用者应获得这些数据及旧使用者的权限处置。
|
||||
- 删除旧投射 Secret 或解除绑定不等于撤销 PostgreSQL 访问;Retain 不承诺自动撤权。
|
||||
ExternalSecret/Secret 的保留与清理细节需随凭据交付协议明确。
|
||||
|
||||
### Delete
|
||||
|
||||
必须由有权限的主体在资源侧明确授权,并核对 Database 身份、绑定、实际对象和管理范围。
|
||||
在相关 finalizer 保护下清理投射、阻止新登录、处理已有连接、删除 database,再按已确认的
|
||||
独占管理范围清理 role 与凭据;共享或未纳管的对象不得删除,禁止扩大 CASCADE 范围。
|
||||
|
||||
每步回读,失败保持进度与 finalizer;确认已删除的对象可幂等跳过,未知同名对象不能继续删除。
|
||||
数据库被使用时,直接删除 Database 不得绕过绑定保护。资源已释放后才按策略处理。
|
||||
具体 Tenant/Database finalizer 配合与投射清理顺序须经 API 设计及故障注入验收。
|
||||
|
||||
## 8. 幂等、失败与人工处理
|
||||
|
||||
普通依赖故障退避重试。已持久确认且仍与观察一致的步骤可以幂等继续;controller 重启
|
||||
不重新生成密码,不重复创建已确认资源。
|
||||
|
||||
外部创建成功但记录尚未保存,或超时导致结果不确定时,若不能可靠确认归属,报告
|
||||
Conflict 并停止相关写入;不得仅凭名称相同、曾记录意图或字段相似自动认领。
|
||||
失败恢复不承诺全部自动完成,也不实现队列模拟事务。
|
||||
|
||||
status 缺失不假定发生于正常重启。Instance 可重新探测能力;Database/Tenant 缺少绑定或
|
||||
操作确认时不能从外部同名对象推导所有权。按冲突/灾难恢复处理,不自动重建所有权表。
|
||||
|
||||
人工处理必须能看到:请求与资源身份、目标 Instance/database/role、失败步骤、已确认完成
|
||||
与结果不确定的操作、冲突原因、下一步核实建议。保留现场,不自动删除疑似残留或改密。
|
||||
管理员核实后可清理确定的残留再重试,或走显式导入;不能通过伪造 Ready/清空 status 强行继续。
|
||||
|
||||
## 9. 权限、凭据与扩展
|
||||
|
||||
2026-09-25 确认第一版管理账号使用原生非 superuser + CREATEDB/CREATEROLE 方案,
|
||||
不引入 SECURITY DEFINER 接口;权限检查与限制见 [安全合同](security.md#最小权限)。
|
||||
|
||||
动态供应继续使用一个兼任 database owner 的 LOGIN role;应用角色不得具备 superuser、
|
||||
CREATEDB、CREATEROLE 或 replication 权限。撤销 PUBLIC CONNECT,再授予目标角色;
|
||||
不修改无关数据库和角色。identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`,SQL 安全引用。
|
||||
|
||||
请求扩展按实例实际可用集合判断,成功后只追加,不自动 DROP EXTENSION。
|
||||
可用列表查询失败按依赖错误处理;实际安装仍检查权限与结果。
|
||||
|
||||
应用密码使用安全随机源,只写 OpenBao;已有可信凭据可复用,不因失败生成第二份密码。
|
||||
新建时先安全保存并回读凭据,再创建角色;凭据写入本身结果不确定也适用人工冲突规则。
|
||||
Kubernetes 应用由 ESO 投射同 namespace Secret,controller 不直接写明文 Secret。
|
||||
|
||||
凭据仍输出 username/password/database/host/hostaddr/port/sslmode 七键,不生成带密码 URI。
|
||||
Tenant status 提供 Secret 引用与无认证信息的 OpenBao API URL。
|
||||
mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenant namespace/name 固定
|
||||
推导路径的规则撤除。动态供应的凭据路径按 Database UID 确定;导入时显式关联已有凭据
|
||||
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
|
||||
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
|
||||
|
||||
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
|
||||
见 [安全模型](security.md)。这些安全约束继续适用。
|
||||
|
||||
## 10. Conditions 与可观测性
|
||||
|
||||
三类资源均提供唯一的 Ready Condition 及 observedGeneration,phase 只辅助表示阶段。
|
||||
Ready=True 必须有当前目标的实际验证;Database 已绑定不等于 Tenant 凭据交付已经完成。
|
||||
|
||||
至少区分 Reconciling、InvalidSpec、ImmutableField、DependencyUnavailable、
|
||||
AuthenticationFailed、InsufficientPrivileges、InstanceNotReady、Conflict、
|
||||
ProvisioningFailed、CredentialProjectionFailed。Released 应明确显示未可供重新绑定,
|
||||
具体 Condition Reason 由 API 细化,不假定仅靠 phase 判断授权。
|
||||
|
||||
使用结构化日志、Events 和低基数失败分类 metrics;Condition 是面向使用者的主要诊断入口。
|
||||
禁止在任何 CR、Event、日志、metric、trace 或测试输出中出现密码、Token、完整秘密响应。
|
||||
|
||||
## 11. 验收
|
||||
|
||||
| 场景 | 必须验证的结果 |
|
||||
| --- | --- |
|
||||
| Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 |
|
||||
| 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 |
|
||||
| 显式导入 | 无数据/密码/owner 隐式修改;错误目标、已占用或 Released 资源的申请被拒绝 |
|
||||
| 同名未知资源 | Conflict,原数据库/角色/凭据不变 |
|
||||
| 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 |
|
||||
| controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 |
|
||||
| Retain 与 namespace/Tenant 删除 | Database 不被 GC,外部数据保留,Released 不自动复用 |
|
||||
| 人工重新绑定 | 旧 UID 不继承使用权;确认数据及凭据/旧访问处置后才能交付 |
|
||||
| Delete 每步中断 | finalizer 保留,可重试,不误删未知/共享/未纳管对象 |
|
||||
| 依赖稍后出现/权限恢复 | 安全重试,过期观察不授权写入 |
|
||||
| status/备份恢复不一致 | 不凭同名推导归属,明确人工处理范围 |
|
||||
| 泄漏与权限 | canary 不出现在输出;namespace 用户不能导入/回收他人资源 |
|
||||
|
||||
纯规则用单元测试;schema/CEL/status/watch/resourceVersion/绑定事件链用 envtest;
|
||||
真实 PostgreSQL/OpenBao 验证后端行为;GC、ESO 与完整交付用具备相应控制器的测试集群。
|
||||
envtest 不运行 GC/ESO,不能据此宣称这两类验收完成。详细测试与实现差距见
|
||||
[开发文档](development.md)。导入不是数据迁移,dump/restore 仍是可选路径,见
|
||||
[迁移文档](migration.md)。
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-0001:采用 Kubernetes API machinery 作为状态协调平面
|
||||
# ADR-0001:采用 Kubernetes API 作为资源模型
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-09-17
|
||||
@@ -10,49 +10,16 @@ homelab 的基础设施状态分散在多套工具和后端中。仅集中 IaC
|
||||
|
||||
## 决策
|
||||
|
||||
Ayatori 使用 kube-apiserver、etcd、Kubernetes API machinery 与 CRD 构成 API 和状态协调
|
||||
平面。主要复用的是以下难以可靠重建的能力:
|
||||
|
||||
- 版本化对象 API、schema、defaulting、validation 与 admission;
|
||||
- 带 `resourceVersion` 的乐观并发、list/watch 与断线恢复;
|
||||
- informer/cache/workqueue 生态;
|
||||
- authentication、RBAC、namespace、审计与 API discovery;
|
||||
- spec/status、conditions、finalizer 等控制面约定。
|
||||
|
||||
这项选择不把 Ayatori 限定为容器编排系统,也不意味着原生 Kubernetes workload API 是领域
|
||||
模型。kube-apiserver 保存期望、引用和观察状态;Ayatori controller-manager 实现平台领域的
|
||||
调度、生命周期、故障恢复、垃圾回收和后端收敛。Controller 可以运行于专用 management
|
||||
environment,并管理集群外的 VM、LB、数据库、对象存储、DNS、凭据和托管 Kubernetes 控制面。
|
||||
|
||||
Ayatori 可以选择性复用 Kubernetes 内置资源的 API contract,而不采用其上游实现组件。例如,
|
||||
`core/v1 Node` 可以表达计算节点身份、capacity、conditions、labels、taints 和维护状态,由
|
||||
Ayatori Compute Agent 更新并由 Ayatori controller 消费;这不要求部署或模拟 kubelet,也不
|
||||
要求存在 Pod、CRI、kube-scheduler 或 kube-controller-manager。`Lease`、`Namespace`、
|
||||
`Secret`、`ConfigMap`、`Event` 和 RBAC 等资源同样按各自适用的 API 语义独立选择。
|
||||
|
||||
复用内置资源前必须明确其 producer、consumer、ownership、采用的字段和未采用的上游语义。
|
||||
不能因为 Kubernetes 通常将若干组件一起部署,就把这些实现关系重新带入 Ayatori。
|
||||
|
||||
Kubernetes workload 集群与 OpenSandbox、Proxmox 等一样,是通过 adapter 接入的 backend 或
|
||||
executor。它可以是远端集群,也可以完全不存在。除 Flux 和 Ayatori controllers 等管理组件的
|
||||
部署外,领域 API 不得隐含依赖 controller 所在集群的 Pod、Job、Service、NetworkPolicy、
|
||||
namespace 共置或 owner reference 语义;确有需要的能力必须由领域 API 和 adapter 契约显式表达。
|
||||
Ayatori 使用 Kubernetes API machinery 与 CRD 表达平台资源、引用和状态,但不将平台
|
||||
限定为容器编排系统。Controller 可以运行于专用 management environment,并管理集群外
|
||||
的 VM、LB、数据库、对象存储、DNS、凭据和托管 Kubernetes 控制面。
|
||||
|
||||
GitOps 是长期期望状态的主要提交入口;API 是当前意图、关系和状态的在线控制面;真实后端
|
||||
仍是运行事实来源。Controller 负责三者之间持续收敛。
|
||||
|
||||
`generic-apiserver` 或 Kubernetes API aggregation 只会让 Ayatori 接管资源的服务端实现,并不会
|
||||
替代上述领域 controller。除非 CRD/kube-apiserver 的存储模型、API 语义或扩展边界形成经过验证的
|
||||
阻碍,Ayatori 不自行承担 watch、RBAC、API 兼容、存储版本迁移和高可用 API Server 的实现与运维。
|
||||
|
||||
## 结果
|
||||
|
||||
- 获得统一声明式 API、watch、RBAC、admission、conditions 和 controller 生态。
|
||||
- Ayatori controller-manager 实际承担类似 kube-controller-manager 的领域控制循环职责,必须把
|
||||
reconcile、状态迁移、恢复与后端契约作为产品核心,而不是把它们误交给 kube-apiserver。
|
||||
- 原生 Kubernetes workload 对象不能成为所有 adapter 的最低公共语义;Kubernetes 只是其中一种
|
||||
执行后端。
|
||||
- 允许由 Ayatori 自己实现合适的内置 API 资源语义;API 类型与上游 controller/runtime 不绑定。
|
||||
- 可以把机器与人工执行统一建模为异步控制循环。
|
||||
- 必须维护 CRD 版本、conversion、认证、备份和控制面升级。
|
||||
- 不在 API 中保存日志、指标、大对象或业务数据,只保存控制所需状态及引用。
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# ADR-0006:按实际管理缺口扩展资源 API
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-09-20
|
||||
|
||||
## 背景
|
||||
|
||||
Ayatori 可以在技术上逐步加入 VM、任务、数据库、负载均衡、对象存储、KaaS、FaaS 与应用
|
||||
托管等能力。如果按传统私有云产品目录推进,项目会把后端“能够实现”的能力误当成 homelab
|
||||
实际需要的产品,并承担没有消费者的 API、controller、升级和恢复成本。
|
||||
|
||||
当前真正反复出现的问题,是 Database、LoadBalancer 和 Bucket/Object Storage 缺少符合本环境
|
||||
需求的稳定管理 API。Proxmox VM 也存在明确缺口:远程 API 能力有限,一部分操作只能登录节点
|
||||
使用 CLI 完成,因此单靠 Terraform provider 或 Proxmox API 无法覆盖期望生命周期。
|
||||
|
||||
当前 `Job` controller 是验证 Kubernetes API machinery、状态机、finalizer、回收和 adapter 边界
|
||||
的首个纵向切片。OpenSandbox 和 microVM 可以成为内部执行后端,但这不等于平台需要 Lambda、
|
||||
Cloud Run 或其他 FaaS/PaaS 产品。
|
||||
|
||||
## 决策
|
||||
|
||||
Ayatori 不设置必须完成的云产品清单。新增北向资源必须由现实消费者、重复管理缺口和持续
|
||||
reconcile 的明确收益驱动。
|
||||
|
||||
当前优先方向是:
|
||||
|
||||
1. `Database`;
|
||||
2. `LoadBalancer`;
|
||||
3. `Bucket` / Object Storage;
|
||||
4. `VirtualMachine`,其价值已确认,但实现成本更高。
|
||||
|
||||
`Run`/当前实验性的 `Job` 定位为控制面执行原语和架构验证切片,不自动扩展为面向用户的计算
|
||||
产品。KaaS 是可能有真实需求的候选能力,但不是必达终点。FaaS、Cloud Run 和应用托管默认不做,
|
||||
除非未来以新的需求和 ADR 改变决定。
|
||||
|
||||
VirtualMachine controller 对外提供稳定北向 API;南向允许根据操作选择 Proxmox API、节点上的
|
||||
受限强类型 Agent/CLI 或 `ManualTask`。节点 Agent 必须提供版本化、幂等、可观察和可审计的操作,
|
||||
不能退化为任意远程 shell。
|
||||
|
||||
## 结果
|
||||
|
||||
- 路线图可以根据当前收益调整,不把技术可行性误作产品承诺。
|
||||
- 第一个 Job controller 的实现仍有测试和架构验证价值,但其 API 不约束长期产品形态。
|
||||
- VM 被保留为核心高价值方向,同时承认其南向集成不是单一 provider 能解决的问题。
|
||||
- 每个新增资源都要独立证明生命周期和管理价值;已有 backend 不自动产生新的产品层。
|
||||
@@ -1,61 +0,0 @@
|
||||
# ADR-0007:复用 Node API 建立按需实现的 Compute 能力
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-09-20
|
||||
- 实施优先级:Deferred;当前优先 Database、LoadBalancer 与 Bucket
|
||||
|
||||
## 背景
|
||||
|
||||
Ayatori 长期可能需要管理现有 Proxmox VM、当前 libvirt VM,以及允许普通计算节点临时加入、
|
||||
排空和退出。Proxmox 的远程 API 不能覆盖全部所需操作;若 Ayatori 进一步实现节点 inventory、
|
||||
简单 placement、fencing 和安全 reschedule,Proxmox 的控制面价值会逐步被替代。
|
||||
|
||||
同一物理节点未来也可能运行 OpenSandbox/Kata 等执行后端。Kata 虽然以 microVM 隔离 Pod 或
|
||||
container,但其公开生命周期是 Sandbox/Run,不是具有磁盘、NIC、console、placement、迁移和
|
||||
长期身份的 VirtualMachine 产品。
|
||||
|
||||
## 决策
|
||||
|
||||
### 节点 API
|
||||
|
||||
Ayatori 选择性复用 `core/v1 Node` 与 `coordination.k8s.io/v1 Lease` 表达计算节点身份、能力、
|
||||
容量、健康、维护状态与心跳。它们只是 API contract:由 Ayatori Compute Agent 写入,并由
|
||||
Ayatori 自有 controller 消费。
|
||||
|
||||
这项选择不引入 kubelet、Pod、CRI、kube-scheduler 或 kube-controller-manager。Compute Agent
|
||||
不是对 kubelet 的模拟或兼容实现,而是 Node API 在 Ayatori Compute 领域中的正式 producer。
|
||||
每个 Node 必须带 Ayatori ownership label;Agent 只能更新自己的 Node/status 与 Lease。
|
||||
|
||||
初版 VirtualMachine 显式指定 Node。出现实际需求后,再由 Ayatori controller 基于 Node 的
|
||||
Ready、unschedulable、taints、labels、capacity 和已有 allocation 实现小规模 filter/score。
|
||||
具体资源分配不能依靠多个 controller 反复改写 `Node.status.allocatable`;需要并发预留时增加
|
||||
独立 Allocation 资源或等价的原子分配记录。
|
||||
|
||||
### VM 数据面
|
||||
|
||||
长期主路径可以是普通 Linux Compute Node 上的 libvirt/QEMU,由受限的 Compute Agent 执行
|
||||
版本化、强类型、幂等且可观察的 VM 操作。Agent 不提供任意远程 shell。
|
||||
|
||||
Proxmox 是 brownfield 迁移后端:初期用于 adopt 现有 VM,并继续提供当前已有的集群、存储、
|
||||
备份与 HA 能力。若 Ayatori Compute 已经可靠覆盖所需 placement、fencing、存储可移植性和恢复
|
||||
语义,可以逐步把 PVE 节点迁移为普通 Compute Node;不为维持虚假 backend 对等性承诺永久支持
|
||||
所有 Proxmox 特性。
|
||||
|
||||
### HA 边界
|
||||
|
||||
自动 reschedule 必须满足:旧节点已经可靠 fenced,且 Volume 明确报告可在目标节点使用。
|
||||
任一条件无法证明时,VM 进入 Blocked/ManualTask,不得冒险在第二个节点启动。首版允许完全
|
||||
人工 placement 与恢复;不以通用 Placement、透明 live migration、多租户 SDN 或 Nova 兼容为目标。
|
||||
|
||||
### Sandbox 边界
|
||||
|
||||
OpenSandbox/Kata microVM 归属于 Run/Sandbox backend 的隔离实现,不创建 VirtualMachine 资源。
|
||||
若未来 VM 与 Sandbox 共享物理节点,容量协调必须另行形成经过验证的设计;不能仅因两者底层
|
||||
都使用 KVM 就合并其北向生命周期。
|
||||
|
||||
## 结果
|
||||
|
||||
- 复用成熟 Node/Lease API,而不继承 Kubernetes workload plane。
|
||||
- Compute 能力可以按 homelab 所需规模实现,不必复制完整 Nova。
|
||||
- PVE 帮助现有资源平滑迁移,但不是长期架构必须保留的一层。
|
||||
- Compute 方向已记录,但不改变当前 Database、LoadBalancer、Bucket 的产品优先级。
|
||||
@@ -1,96 +0,0 @@
|
||||
# ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-09-20
|
||||
|
||||
2026-09-24 修订:[ADR-0009](0009-database-resource-and-claim.md) 已明确替代本文对 ownership
|
||||
registry、任意 status 丢失自动恢复及原 Retain 合同的沿用要求。Database 合并归属、来源保护、
|
||||
无旧部署兼容负担及其他仍适用的安全边界继续有效;以下保留当时迁移决策的历史背景。
|
||||
|
||||
## 背景
|
||||
|
||||
独立仓库 `postgresql-tenant-operator` 已经为 homelab 共享 PostgreSQL 设计了
|
||||
`PostgreSQLInstance` 与 `PostgreSQLTenant` API,并包含批准的行为规格、领域值对象、状态机、
|
||||
PostgreSQL ownership registry、OpenBao/External Secrets 边界、迁移与恢复文档及测试。
|
||||
|
||||
Database 是 Ayatori 当前最优先的真实管理缺口之一。继续把该 controller 作为独立产品,会重复
|
||||
维护 manager、API machinery、发布、认证、可观测性和通用 controller 约定,也会使后续应用组合
|
||||
必须跨两个控制平面理解状态。
|
||||
|
||||
截至 2026-09-20,源仓库已经合并 Instance 的 Endpoint、凭据引用、身份/版本、定义与观测目标
|
||||
等值对象,以及扩展支持模型和最小生命周期/checkpoint。它们尚未接入实际运行链路。完整 Ready
|
||||
判定、Kubernetes Secret 管理凭据与连接刷新、应用层/数据库 adapter/controller 接入、CRD 规格
|
||||
对齐及集成验证仍未完成;Tenant 的创建、凭据交付与 Retain/Delete 生命周期也未落地。
|
||||
|
||||
现有运行链路仍是直接读取 OpenBao 管理凭据的旧实现,不能作为新设计已经可用的证据。源仓库
|
||||
本地 `feature/instance-extension-observations` 还保留两个未提交文件,用于 Instance 接受扩展观测
|
||||
及测试;该工作已暂停,不能作为已合并能力或迁移基线。部分生成的 CRD/API 代码也仍落后于批准
|
||||
规范,因此迁移不能把当前工作树或全部脚手架原样复制到 Ayatori。
|
||||
|
||||
## 决策
|
||||
|
||||
PostgreSQL Tenant Operator 合并为 Ayatori 的 Database 领域模块。保留已经批准且仍适用的安全、
|
||||
所有权、幂等与删除行为,不重新发明 database、role、credential 和 registry 语义。
|
||||
|
||||
当前没有可用发布版本、没有被该 operator 托管的 PostgreSQL 实例或 Tenant,也没有需要在线
|
||||
转换的已部署 CR。因此此次合并不承担旧实现兼容性:旧运行链路可以直接撤销,不保留直接读取
|
||||
OpenBao 管理凭据的路径,也不兼容落后于规范的旧 CRD、samples 或实现细节。
|
||||
|
||||
没有部署兼容负担不等于重新设计已经批准的产品合同。源项目的系统规格、API 语义、Instance 与
|
||||
Tenant 领域模型、状态机、ownership registry、OpenBao/ExternalSecret 凭据交付、Retain/Delete、
|
||||
恢复与测试设计整体作为 Ayatori Database 模块的规范基线。除 API group、项目归属和装配结构外,
|
||||
迁移不得静默改变这些行为;确需改变时必须先单独修订规格并记录决定。
|
||||
|
||||
目标结构遵守 Ayatori 的模块化单体边界:
|
||||
|
||||
```text
|
||||
api/database/v1alpha1/
|
||||
internal/database/domain/
|
||||
internal/database/controller/
|
||||
internal/database/adapter/postgresql/
|
||||
internal/database/adapter/openbao/
|
||||
internal/database/adapter/externalsecrets/
|
||||
docs/database/
|
||||
```
|
||||
|
||||
最终目录可按 Kubebuilder 与现有模块约定微调,但 Database 不依赖 execution/Job 模块,也不把
|
||||
PostgreSQL、OpenBao 或 External Secrets 客户端放入共享万能 service/repository 层。
|
||||
|
||||
Database API 直接重构为 Ayatori 统一结构:API group 使用
|
||||
`database.ayatori.ddupan.top/v1alpha1`,Go package 使用 `api/database/v1alpha1`,controller、
|
||||
domain 与 adapter 放入 Ayatori 对应 Database 模块。原 `database.ddupan.top/v1alpha1` 不保留
|
||||
别名、conversion 或兼容入口。
|
||||
|
||||
迁移前逐项核对批准规格、领域模型与当前 Go types;冲突时以批准规格为准。代码质量通过重写
|
||||
旧运行链路、清晰 application/adapter 边界和测试实现,不通过改变已批准行为获得。无需实现在线
|
||||
CRD conversion 或数据迁移。
|
||||
|
||||
## 迁移方式
|
||||
|
||||
1. 以包含已合并 Instance 领域基础和 CI #14 的最新 `main` commit 作为 source reference;记录
|
||||
commit,将完整批准规格与设计文档迁入 Ayatori Database 文档,并迁移领域模型和纯单元测试。
|
||||
设计合同直接复用;旧运行代码不逐文件复制。
|
||||
2. 保留源仓库暂停中的脏工作树,不移动、提交或复制两个 extension observation 文件。以后可以
|
||||
先在源仓库形成独立 commit,或在 Ayatori 根据批准合同重新实现,但不得把未提交内容描述为来源。
|
||||
3. 在 Ayatori multi-group 项目中用 Kubebuilder 注册 Database API,按批准规格迁移 types,重新
|
||||
生成 `database.ayatori.ddupan.top` CRD、DeepCopy 与 RBAC;不直接复制旧生成文件或旧 `PROJECT`。
|
||||
4. 删除旧运行链路假设,以 Ayatori 当前 Go、Kubernetes 与 controller-runtime 版本重新建立
|
||||
application ports 和 adapter contract;先恢复 PostgreSQL registry/adapter contract tests。
|
||||
5. 逐片实现 Instance observe、Kubernetes Secret 管理凭据与连接刷新、Tenant provisioning、
|
||||
OpenBao、ExternalSecret、删除与恢复流程;
|
||||
每片必须包含对应单元、envtest 和真实 PostgreSQL/OpenBao 集成测试。
|
||||
6. Ayatori 中的 Database 模块达到原项目验收标准并完成迁移演练后,冻结旧仓库并将其 README
|
||||
指向 Ayatori;不同时运行两个 controller 管理同一组 CR。
|
||||
|
||||
不通过一次性 unrelated-history merge 或整仓复制保留表面上的 Git 历史。旧仓库和 source commit
|
||||
保留完整来源历史;Ayatori 迁移提交按可审阅行为切片记录 provenance。
|
||||
|
||||
## 结果
|
||||
|
||||
- Ayatori 获得第一个真实产品领域,而不是继续围绕实验性 Job 扩张。
|
||||
- 已批准的 DBaaS 设计与测试投资得到保留。
|
||||
- 单一 manager/release 不意味着领域耦合;Database 仍保持独立 package、adapter 和测试边界。
|
||||
- 可以从已合并的领域基础开始迁移;旧运行链路和未提交 extension observation 不进入首个切片。
|
||||
- 无部署兼容负担允许彻底重写旧运行链路,不为尚未使用的实现技术债保留兼容层;已批准设计合同
|
||||
仍然有效。
|
||||
- Database 使用 Ayatori 统一 API group 与目录结构,不为未投入使用的旧 group 保留入口。
|
||||
@@ -1,65 +0,0 @@
|
||||
# ADR-0009:分离 Database 资源与 Tenant 申请
|
||||
|
||||
- 状态:Accepted(资源模型与生命周期);API 字段细节待评审
|
||||
- 日期:2026-09-24
|
||||
- 依据:维护者本轮设计讨论;本决定部分替代 [ADR-0008](0008-merge-postgresql-tenant-operator.md)
|
||||
对 ownership registry、自动恢复与 Retain 的沿用要求,不改变 Database 合并归属。
|
||||
|
||||
## 问题
|
||||
|
||||
原模型让 Tenant 同时表示用户申请和外部资源生命周期,又用 PostgreSQL registry 在 Tenant
|
||||
删除后保留所有权。它把简单的一对多关系扩展成额外持久化协议,并为“外部创建成功但 status
|
||||
未写入”承诺自动认领恢复。这里并没有自动恢复所有不确定结果的产品要求;清楚报告冲突、
|
||||
保留现场并允许人工处理是可接受的合同。
|
||||
|
||||
Kubernetes status 存储在 API/etcd 中,不是 controller 重启即丢失的内存。外部操作与 API
|
||||
写入之间确实存在失败窗口,但不因此引入第二套所有权数据库或模拟跨系统事务。
|
||||
|
||||
## 参考与取舍
|
||||
|
||||
参考 Kubernetes 官方 [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/):
|
||||
资源独立于申请存在,绑定排他;Retain 释放后需要人工处理;已有资源可以静态登记。
|
||||
同时参考 [owner references](https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/)
|
||||
与 [finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/) 的生命周期边界。
|
||||
|
||||
采用这些模式,不直接使用 PV/PVC 类型,不实现 CSI 协议,不引入 StorageClass、容量匹配、
|
||||
调度器、通用 Claim、事件总线或额外 registry。Instance 是资源来源与管理入口,PostgreSQL
|
||||
adapter 承担类似驱动的访问职责;Instance 本身不是 CSI 驱动。
|
||||
|
||||
## 决策
|
||||
|
||||
- Instance 对应多个独立 Database;每个 Database 同时最多绑定一个 Tenant,Tenant 最多绑定
|
||||
一个 Database。Tenant 是用户申请,不再直接承担持久资源的全部生命周期。
|
||||
- Database 是新增 Kubernetes 资源;本文采用 PostgreSQLDatabase 作为工作名称,具体字段、
|
||||
scope 与短名称在 API 评审中确定,不从类比自动推导。
|
||||
- 动态供应先建立资源记录;已有数据库只能由管理员显式登记导入。仅发现同名数据库不是授权。
|
||||
- Database 自带 instanceRef,手工登记不依赖 Tenant。动态申请由 Tenant 选择 Instance;
|
||||
引用已有 Database 的 Tenant 从资源获取 Instance,不重复声明另一份来源。
|
||||
- Retain 默认保留 Database 与外部资源;Tenant 删除后资源进入 Released,保留旧绑定身份,
|
||||
不自动重新分配。管理员处理数据、账号权限与凭据后,才可授权重新绑定。
|
||||
- 回收策略在 Database 一侧。Delete 必须具备明确管理范围、删除授权、finalizer 和回读;
|
||||
导入不隐含授权改密码、改 owner、撤权或删除。
|
||||
- Kubernetes CR 保存资源身份、绑定与操作进度,PostgreSQL catalog 保存实际数据库状态;
|
||||
不再维护 PostgreSQL ownership registry,不把 registry 初始化或回读作为 Instance Ready 条件。
|
||||
- 普通依赖失败继续 reconcile;同名未知资源、创建结果无法确认时报告 Conflict,停止对相关
|
||||
资源的危险操作,提供人工诊断。已有可靠记录支持的幂等步骤可以继续,不将每次重启都变成冲突。
|
||||
- status 不承诺在任意删除后自动重建所有权。灾难恢复按备份与人工核实处理。
|
||||
- Database 不受 Tenant 的级联 GC 控制;Instance 删除也不能级联删除 Database 或业务数据。
|
||||
|
||||
## 保留的合同与未决项
|
||||
|
||||
管理 Secret 来源、凭据变化后刷新连接、TLS、实际扩展观察、OpenBao 应用凭据与 ESO 投射、
|
||||
最小权限和分层集成验证继续适用。单 database、单 login owner 的首版使用场景不变。
|
||||
|
||||
Database 对 role/凭据的具体管理边界、持久凭据定位与重新授权方式、资源 scope、绑定字段、
|
||||
预留及并发绑定的 API 更新协议需在 API/实现切片前细化。旧的 Tenant namespace/name 固定
|
||||
凭据路径不能未经评估直接用于跨 Tenant 重新绑定;不为填满字段表而默认授权搬迁或改密。
|
||||
|
||||
## 实施边界
|
||||
|
||||
本次只修订设计。现有 registry adapter、Instance 中的 registry 判定及相关测试是待撤换的旧实现,
|
||||
不是新合同的前提;未提交的 registry inspection 不继续接入。后续按新规格撤除这些依赖,
|
||||
再实现 Database 资源、绑定、导入与回收的纵向切片,不保留未部署实现的兼容层。
|
||||
|
||||
详见 [系统规格](../database/specification.md)、[领域模型](../database/domain-model.md)、
|
||||
[API 设计状态](../database/api-reference.md)和[测试合同](../database/development.md)。
|
||||
+27
-26
@@ -9,52 +9,53 @@
|
||||
- 定义 API、conditions、ownership 和 executor 公共约定。
|
||||
- 建立不可变制品与 Dev 到 Prod promotion。
|
||||
|
||||
## 1. Controller 纵向验证切片
|
||||
## 1. Job Service
|
||||
|
||||
- 使用当前最小 `Job` API 验证 watch、状态机、finalizer、取消、TTL、external reference 与
|
||||
backend adapter。
|
||||
- Kubernetes executor 不能与 management API client 或同集群 namespace 语义绑定。
|
||||
- 验证完成后,将可复用机制收敛为内部 `Run`/execution 能力;不把这一切片扩展为 FaaS、
|
||||
Cloud Run 或通用 Job Service。
|
||||
- 实现最小 `Job` API。
|
||||
- Kubernetes Pod executor。
|
||||
- 统一日志、退出状态、超时、workspace、cache 与 artifact。
|
||||
- 接入 Gitea Actions 和平台内部 IaC 执行。
|
||||
|
||||
## 2. 首批资源产品
|
||||
## 2. OpenSandbox Executor
|
||||
|
||||
- `Database`:PostgreSQL database、role、credential 与回收。
|
||||
- `LoadBalancer`:Envoy 配置/xDS、健康检查、固定 VIP 与 GoBGP 路由宣告。
|
||||
- `Bucket`:SeaweedFS bucket、policy、credential 与删除策略。
|
||||
- 按纵向价值选择先后,不为三者预先建立统一 provider 框架。
|
||||
- 通过 OpenSandbox lifecycle 与 execd API 创建、执行和清理 sandbox。
|
||||
- 支持强隔离任务、未知代码、嵌套容器和 AI agent。
|
||||
- 增加交互式 `Sandbox` API、TTL、endpoint 与 snapshot。
|
||||
|
||||
## 3. Human Executor 与延迟自动化
|
||||
## 3. Human Executor
|
||||
|
||||
- `ManualTask`、`TaskReport` 和版本化 Runbook。
|
||||
- Telegram/Email 通知、领取、提醒和升级。
|
||||
- 后端验证与上游 reconcile 恢复。
|
||||
|
||||
## 4. Compute 与节点生命周期
|
||||
## 4. LBaaS
|
||||
|
||||
- 建立稳定的 `VirtualMachine` 北向 API,并支持现有资源 adopt。
|
||||
- 南向按能力组合 Proxmox API、节点受限 Agent/CLI 与 `ManualTask`,不假设 Proxmox API 完整。
|
||||
- Node 加入、drain 和 `SafeToRemove`;Node API 由 Ayatori Compute Agent 实现,不依赖 kubelet。
|
||||
- Envoy 配置/xDS adapter。
|
||||
- 健康检查与 GoBGP 路由宣告。
|
||||
- 固定 VIP、listener/backend 引用和故障恢复。
|
||||
|
||||
## 5. Compute 与节点生命周期
|
||||
|
||||
- Proxmox VM adapter 与现有资源 adopt。
|
||||
- ComputeNode 加入、drain 和 `SafeToRemove`。
|
||||
- StorageClass、StoragePool、Volume 与迁移计划。
|
||||
- 先支持人工磁盘迁移,再按实际收益自动化。
|
||||
- 先支持人工磁盘迁移,再通过 Job executor 自动化。
|
||||
|
||||
## 5. 条件性扩展
|
||||
## 6. 数据服务
|
||||
|
||||
- OpenSandbox/microVM 可以作为内部 Run backend,但不由此产生 FaaS 产品承诺。
|
||||
- DNS、证书和 Credential 只有在跨系统协调收益明确时形成独立资源。
|
||||
- KaaS 只有出现托管控制面、租户隔离或频繁集群生命周期的真实需求时才立项。
|
||||
- PostgreSQL database/role/credential。
|
||||
- SeaweedFS bucket/policy/credential。
|
||||
- DNS 与证书资源。
|
||||
|
||||
### KaaS 候选方案
|
||||
## 7. KaaS
|
||||
|
||||
- 采用成熟 hosted-control-plane 后端。
|
||||
- 组合控制面、worker、LB、DNS、网络和凭据。
|
||||
- 用户集群只暴露 worker node,控制面完全由平台托管。
|
||||
- 本节记录候选实现边界,不构成路线图承诺。
|
||||
|
||||
## 后续 Compute 验收场景
|
||||
## 首个业务里程碑
|
||||
|
||||
Database 等首批资源优先落地。Compute 开始实施后,以 Laptop Rebuild Readiness 验证节点
|
||||
生命周期与恢复能力;该场景不作为首批 Database、LoadBalancer 或 Bucket 的交付前置条件:
|
||||
完成 Laptop Rebuild Readiness:
|
||||
|
||||
1. 临时节点加入。
|
||||
2. laptop 上的 workload 被重建、迁移或形成可执行人工任务。
|
||||
|
||||
@@ -29,20 +29,6 @@ Ayatori 是具有产品质量的内部平台,而非初期即面向公众的通
|
||||
平台允许对当前环境形成明确意见:Proxmox、OpenSandbox、Envoy、GoBGP、OpenBao、
|
||||
PostgreSQL、SeaweedFS、Samba AD DNS、Cloudflare 和 Flux 都可以是已知实现。
|
||||
|
||||
Ayatori 不以补齐传统私有云或公有云的产品目录为目标。一个资源只有同时满足以下条件,才进入
|
||||
北向 API:
|
||||
|
||||
1. homelab 存在现实消费者和重复需求;
|
||||
2. 现有后端 API 或 IaC 无法提供足够的管理体验;
|
||||
3. 持续 observe/reconcile 明显优于一次性自动化;
|
||||
4. 统一生命周期、状态、组合或权限能产生可验证的收益;
|
||||
5. 收益足以承担长期 API 兼容、controller 和恢复测试成本。
|
||||
|
||||
当前最明确的管理缺口是 Database、LoadBalancer 与 Bucket/Object Storage。VirtualMachine 同样
|
||||
具有明确价值:Proxmox 的 API 不能覆盖所需的全部生命周期,一部分操作必须在节点上通过 CLI
|
||||
完成,因此 Ayatori 可以提供稳定北向 API,并在南向组合 Proxmox API、受限节点 Agent 与人工
|
||||
任务。KaaS 只有在出现托管控制面的实际需求时才进入实现,不是产品路线的必达终点。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不替代 hypervisor、microVM runtime、数据库、对象存储或网络协议栈。
|
||||
@@ -50,5 +36,3 @@ Ayatori 不以补齐传统私有云或公有云的产品目录为目标。一个
|
||||
- 不以隐藏全部后端信息或制造虚假多云可移植性为目标。
|
||||
- 不创建理解所有应用需求的中央 Application controller。
|
||||
- 不要求所有人工步骤立即自动化。
|
||||
- 不因为已有 Run、OpenSandbox 或 microVM backend,就构建 FaaS、Cloud Run 或应用托管产品。
|
||||
- 不预先承诺 KaaS;它是需求驱动的候选能力。
|
||||
|
||||
@@ -3,18 +3,14 @@ module git.ddupan.top/panxiao81/ayatori
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/openbao/openbao/api/v2 v2.7.0
|
||||
k8s.io/api v0.37.0
|
||||
k8s.io/apimachinery v0.37.0
|
||||
k8s.io/client-go v0.37.0
|
||||
sigs.k8s.io/controller-runtime v0.25.0
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
@@ -26,7 +22,6 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // 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
|
||||
@@ -44,25 +39,12 @@ require (
|
||||
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/cel-go v0.29.2 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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
|
||||
@@ -71,7 +53,6 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
@@ -87,14 +68,14 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.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/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
@@ -114,4 +95,5 @@ require (
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
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=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -23,16 +23,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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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=
|
||||
@@ -76,10 +72,6 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg
|
||||
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k=
|
||||
@@ -97,47 +89,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/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/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=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -150,8 +113,6 @@ 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/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=
|
||||
@@ -165,9 +126,9 @@ github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLA
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -177,9 +138,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -210,15 +170,14 @@ 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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
@@ -227,12 +186,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/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/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/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=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
@@ -246,11 +205,12 @@ google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4=
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
ControllerName = "execution.ayatori.ddupan.top/kubernetes"
|
||||
ReferenceType = "Job"
|
||||
JobUIDLabel = "execution.ayatori.ddupan.top/job-uid"
|
||||
)
|
||||
|
||||
var ayatoriJobGVK = schema.GroupVersionKind{
|
||||
Group: executionv1alpha1.GroupVersion.Group,
|
||||
Version: executionv1alpha1.GroupVersion.Version,
|
||||
Kind: "Job",
|
||||
}
|
||||
|
||||
// BuildJob translates the stable execution API into the Kubernetes adapter's
|
||||
// backend object. It intentionally does not accept or expose a PodSpec.
|
||||
func BuildJob(
|
||||
job *executionv1alpha1.Job,
|
||||
parameters *executionv1alpha1.KubernetesExecutionParameters,
|
||||
resources executionv1alpha1.ExecutionResourceRequirements,
|
||||
) *batchv1.Job {
|
||||
backoffLimit := int32(0)
|
||||
controller := true
|
||||
blockOwnerDeletion := true
|
||||
|
||||
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
|
||||
return &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: job.Name,
|
||||
Namespace: job.Namespace,
|
||||
Labels: map[string]string{
|
||||
JobUIDLabel: string(job.UID),
|
||||
},
|
||||
OwnerReferences: []metav1.OwnerReference{{
|
||||
APIVersion: ayatoriJobGVK.GroupVersion().String(),
|
||||
Kind: ayatoriJobGVK.Kind,
|
||||
Name: job.Name,
|
||||
UID: job.UID,
|
||||
Controller: &controller,
|
||||
BlockOwnerDeletion: &blockOwnerDeletion,
|
||||
}},
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
BackoffLimit: &backoffLimit,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{JobUIDLabel: string(job.UID)}},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: parameters.Spec.ServiceAccountName,
|
||||
RuntimeClassName: optionalString(parameters.Spec.RuntimeClassName),
|
||||
NodeSelector: parameters.Spec.Scheduling.NodeSelector,
|
||||
Tolerations: parameters.Spec.Scheduling.Tolerations,
|
||||
SecurityContext: parameters.Spec.PodSecurityContext,
|
||||
ImagePullSecrets: job.Spec.Task.ImagePullSecrets,
|
||||
Containers: []corev1.Container{{
|
||||
Name: "task",
|
||||
Image: job.Spec.Task.Image,
|
||||
ImagePullPolicy: parameters.Spec.ImagePullPolicy,
|
||||
Command: job.Spec.Task.Command,
|
||||
Args: job.Spec.Task.Args,
|
||||
WorkingDir: job.Spec.Task.WorkingDir,
|
||||
Env: environment(job.Spec.Task.Env),
|
||||
Resources: resourceRequirements(resources),
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateOwnership(owner *executionv1alpha1.Job, backend *batchv1.Job) error {
|
||||
if backend.Labels[JobUIDLabel] != string(owner.UID) {
|
||||
return fmt.Errorf("backend Job %s/%s is not owned by Ayatori Job UID %s", backend.Namespace, backend.Name, owner.UID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func environment(values []executionv1alpha1.EnvVar) []corev1.EnvVar {
|
||||
result := make([]corev1.EnvVar, 0, len(values))
|
||||
for _, value := range values {
|
||||
env := corev1.EnvVar{Name: value.Name}
|
||||
if value.Value != nil {
|
||||
env.Value = *value.Value
|
||||
}
|
||||
if value.ValueFrom != nil {
|
||||
env.ValueFrom = &corev1.EnvVarSource{
|
||||
SecretKeyRef: value.ValueFrom.SecretKeyRef,
|
||||
ConfigMapKeyRef: value.ValueFrom.ConfigMapKeyRef,
|
||||
}
|
||||
}
|
||||
result = append(result, env)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resourceRequirements(resources executionv1alpha1.ExecutionResourceRequirements) corev1.ResourceRequirements {
|
||||
return corev1.ResourceRequirements{
|
||||
Requests: resourceList(resources.Requests),
|
||||
Limits: resourceList(resources.Limits),
|
||||
}
|
||||
}
|
||||
|
||||
func resourceList(values executionv1alpha1.ResourceValues) corev1.ResourceList {
|
||||
result := corev1.ResourceList{}
|
||||
if values.CPU != nil {
|
||||
result[corev1.ResourceCPU] = values.CPU.DeepCopy()
|
||||
}
|
||||
if values.Memory != nil {
|
||||
result[corev1.ResourceMemory] = values.Memory.DeepCopy()
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
func TestBuildJob(t *testing.T) {
|
||||
literal := "world"
|
||||
cpuRequest := resource.MustParse("100m")
|
||||
memoryLimit := resource.MustParse("128Mi")
|
||||
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
|
||||
job := &executionv1alpha1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "hello", Namespace: "ci", UID: types.UID("job-uid")},
|
||||
Spec: executionv1alpha1.JobSpec{Task: executionv1alpha1.TaskSpec{
|
||||
Image: "alpine:3.22", Command: []string{"echo"}, Args: []string{"hello"},
|
||||
Env: []executionv1alpha1.EnvVar{
|
||||
{Name: "TARGET", Value: &literal},
|
||||
{Name: "TOKEN", ValueFrom: &executionv1alpha1.EnvVarSource{
|
||||
//nolint:modernize // LocalObjectReference is an embedded Kubernetes API field.
|
||||
SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "token"}, Key: "value"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
parameters := &executionv1alpha1.KubernetesExecutionParameters{Spec: executionv1alpha1.KubernetesExecutionParametersSpec{
|
||||
ServiceAccountName: "runner", RuntimeClassName: "runc", ImagePullPolicy: corev1.PullIfNotPresent,
|
||||
Scheduling: executionv1alpha1.KubernetesSchedulingParameters{NodeSelector: map[string]string{"role": "execution"}},
|
||||
}}
|
||||
resources := executionv1alpha1.ExecutionResourceRequirements{
|
||||
Requests: executionv1alpha1.ResourceValues{CPU: &cpuRequest},
|
||||
Limits: executionv1alpha1.ResourceValues{Memory: &memoryLimit},
|
||||
}
|
||||
|
||||
backend := BuildJob(job, parameters, resources)
|
||||
pod := backend.Spec.Template.Spec
|
||||
if backend.Spec.BackoffLimit == nil || *backend.Spec.BackoffLimit != 0 {
|
||||
t.Fatalf("backoffLimit = %v, want 0", backend.Spec.BackoffLimit)
|
||||
}
|
||||
if pod.RestartPolicy != corev1.RestartPolicyNever || pod.ServiceAccountName != "runner" {
|
||||
t.Fatalf("unexpected pod execution policy: %#v", pod)
|
||||
}
|
||||
if pod.RuntimeClassName == nil || *pod.RuntimeClassName != "runc" {
|
||||
t.Fatalf("runtimeClassName = %v, want runc", pod.RuntimeClassName)
|
||||
}
|
||||
container := pod.Containers[0]
|
||||
if container.Resources.Requests.Cpu().Cmp(cpuRequest) != 0 || container.Resources.Limits.Memory().Cmp(memoryLimit) != 0 {
|
||||
t.Fatalf("resources were not mapped: %#v", container.Resources)
|
||||
}
|
||||
if container.Env[1].ValueFrom == nil || container.Env[1].ValueFrom.SecretKeyRef.Name != "token" {
|
||||
t.Fatalf("secret reference was not preserved: %#v", container.Env[1])
|
||||
}
|
||||
if backend.Labels[JobUIDLabel] != "job-uid" || backend.OwnerReferences[0].UID != job.UID {
|
||||
t.Fatalf("ownership identity was not preserved: %#v", backend.ObjectMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOwnership(t *testing.T) {
|
||||
job := &executionv1alpha1.Job{}
|
||||
job.UID = types.UID("expected")
|
||||
backend := BuildJob(job, &executionv1alpha1.KubernetesExecutionParameters{}, executionv1alpha1.ExecutionResourceRequirements{})
|
||||
backend.Labels[JobUIDLabel] = "different"
|
||||
if err := ValidateOwnership(job, backend); err == nil {
|
||||
t.Fatal("ValidateOwnership() succeeded for a different Job UID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
jobFinalizer = "execution.ayatori.ddupan.top/job-cleanup"
|
||||
reasonResultUnknown = "ResultUnknown"
|
||||
)
|
||||
|
||||
// JobReconciler executes Ayatori Jobs using supported adapters.
|
||||
type JobReconciler struct {
|
||||
client.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobs/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups=execution.ayatori.ddupan.top,resources=jobclasses;kubernetesexecutionparameters,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete
|
||||
// +kubebuilder:rbac:groups="",resources=namespaces;serviceaccounts,verbs=get;list;watch
|
||||
|
||||
func (r *JobReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
job := &executionv1alpha1.Job{}
|
||||
if err := r.Get(ctx, request.NamespacedName, job); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
if !job.DeletionTimestamp.IsZero() {
|
||||
return ctrl.Result{}, r.finalize(ctx, job)
|
||||
}
|
||||
if isTerminal(job) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
if job.Spec.DesiredState == executionv1alpha1.JobDesiredStateCancelled {
|
||||
return ctrl.Result{}, r.cancel(ctx, job)
|
||||
}
|
||||
|
||||
if !containsString(job.Finalizers, jobFinalizer) {
|
||||
job.Finalizers = append(job.Finalizers, jobFinalizer)
|
||||
if err := r.Update(ctx, job); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
if job.Status.Execution != nil {
|
||||
return ctrl.Result{}, r.observeExisting(ctx, job)
|
||||
}
|
||||
|
||||
class, parameters, resources, waiting, err := r.resolve(ctx, job)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if waiting {
|
||||
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
|
||||
}
|
||||
|
||||
backend := &batchv1.Job{}
|
||||
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
|
||||
err = r.Get(ctx, key, backend)
|
||||
if apierrors.IsNotFound(err) {
|
||||
backend = kubernetesadapter.BuildJob(job, parameters, resources)
|
||||
if err := r.Create(ctx, backend); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Info("Created Kubernetes backend Job", "backend", key)
|
||||
return ctrl.Result{}, r.markScheduled(ctx, job, class, parameters, resources, backend)
|
||||
}
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
|
||||
return ctrl.Result{}, r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionScheduled, Status: metav1.ConditionFalse,
|
||||
Reason: "BackendConflict", Message: err.Error(),
|
||||
})
|
||||
}
|
||||
if !conditionTrue(job.Status.Conditions, executionv1alpha1.JobConditionScheduled) {
|
||||
return ctrl.Result{}, r.markScheduled(ctx, job, class, parameters, resources, backend)
|
||||
}
|
||||
return ctrl.Result{}, r.observe(ctx, job, backend)
|
||||
}
|
||||
|
||||
func (r *JobReconciler) observeExisting(ctx context.Context, job *executionv1alpha1.Job) error {
|
||||
if job.Status.Execution.Adapter != "kubernetes" {
|
||||
return r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
|
||||
Reason: reasonResultUnknown, Message: fmt.Sprintf("adapter %q is not available", job.Status.Execution.Adapter),
|
||||
})
|
||||
}
|
||||
backend := &batchv1.Job{}
|
||||
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
|
||||
if err := r.Get(ctx, key, backend); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
|
||||
Reason: reasonResultUnknown, Message: "Kubernetes backend Job is missing",
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
|
||||
return r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown,
|
||||
Reason: reasonResultUnknown, Message: err.Error(),
|
||||
})
|
||||
}
|
||||
return r.observe(ctx, job, backend)
|
||||
}
|
||||
|
||||
func (r *JobReconciler) resolve(
|
||||
ctx context.Context,
|
||||
job *executionv1alpha1.Job,
|
||||
) (*executionv1alpha1.JobClass, *executionv1alpha1.KubernetesExecutionParameters, executionv1alpha1.ExecutionResourceRequirements, bool, error) {
|
||||
if job.Spec.JobClassName == "" {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "NoDefaultJobClass", "spec.jobClassName is required in the first implementation slice")
|
||||
}
|
||||
|
||||
class := &executionv1alpha1.JobClass{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: job.Spec.JobClassName}, class); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "JobClassNotFound", fmt.Sprintf("JobClass %q does not exist", job.Spec.JobClassName))
|
||||
}
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
|
||||
}
|
||||
if class.Spec.ControllerName != kubernetesadapter.ControllerName {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "UnsupportedController", fmt.Sprintf("controller %q is not supported", class.Spec.ControllerName))
|
||||
}
|
||||
ref := class.Spec.ParametersRef
|
||||
if ref.Group != executionv1alpha1.GroupVersion.Group || ref.Kind != "KubernetesExecutionParameters" {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "InvalidParametersReference", "JobClass must reference KubernetesExecutionParameters")
|
||||
}
|
||||
if allowed, err := r.namespaceAllowed(ctx, job.Namespace, class.Spec.AllowedNamespaces); err != nil {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
|
||||
} else if !allowed {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "NamespaceNotAllowed", fmt.Sprintf("namespace %q is not allowed by JobClass %q", job.Namespace, class.Name))
|
||||
}
|
||||
|
||||
parameters := &executionv1alpha1.KubernetesExecutionParameters{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: ref.Name}, parameters); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "ParametersNotFound", fmt.Sprintf("KubernetesExecutionParameters %q does not exist", ref.Name))
|
||||
}
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
|
||||
}
|
||||
serviceAccount := &corev1.ServiceAccount{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: parameters.Spec.ServiceAccountName}, serviceAccount); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "ServiceAccountNotFound", fmt.Sprintf("ServiceAccount %q does not exist", parameters.Spec.ServiceAccountName))
|
||||
}
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
|
||||
}
|
||||
|
||||
resources := applyResourceDefaults(job.Spec.Resources, class.Spec.Resources.Defaults)
|
||||
if err := validateResources(resources); err != nil {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, true, r.reject(ctx, job, "InvalidResources", err.Error())
|
||||
}
|
||||
if err := r.accept(ctx, job, class, parameters, resources); err != nil {
|
||||
return nil, nil, executionv1alpha1.ExecutionResourceRequirements{}, false, err
|
||||
}
|
||||
return class, parameters, resources, false, nil
|
||||
}
|
||||
|
||||
func (r *JobReconciler) namespaceAllowed(ctx context.Context, namespace string, selector *metav1.LabelSelector) (bool, error) {
|
||||
if selector == nil {
|
||||
return true, nil
|
||||
}
|
||||
ns := &corev1.Namespace{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
|
||||
return false, err
|
||||
}
|
||||
compiled, err := metav1.LabelSelectorAsSelector(selector)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return compiled.Matches(labels.Set(ns.Labels)), nil
|
||||
}
|
||||
|
||||
func (r *JobReconciler) accept(ctx context.Context, job *executionv1alpha1.Job, class *executionv1alpha1.JobClass, parameters *executionv1alpha1.KubernetesExecutionParameters, resources executionv1alpha1.ExecutionResourceRequirements) error {
|
||||
job.Status.ResolvedJobClass = &executionv1alpha1.ResolvedJobClassReference{
|
||||
Name: class.Name, UID: class.UID, ControllerName: class.Spec.ControllerName,
|
||||
ParametersRef: executionv1alpha1.ParametersReference{
|
||||
Group: class.Spec.ParametersRef.Group, Kind: class.Spec.ParametersRef.Kind,
|
||||
Name: parameters.Name, UID: parameters.UID,
|
||||
},
|
||||
}
|
||||
job.Status.EffectiveResources = resources
|
||||
return r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionAccepted, Status: metav1.ConditionTrue,
|
||||
Reason: "Accepted", Message: fmt.Sprintf("JobClass %q accepted", class.Name),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *JobReconciler) reject(ctx context.Context, job *executionv1alpha1.Job, reason, message string) error {
|
||||
return r.setCondition(ctx, job, metav1.Condition{
|
||||
Type: executionv1alpha1.JobConditionAccepted, Status: metav1.ConditionFalse,
|
||||
Reason: reason, Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *JobReconciler) markScheduled(ctx context.Context, job *executionv1alpha1.Job, class *executionv1alpha1.JobClass, parameters *executionv1alpha1.KubernetesExecutionParameters, resources executionv1alpha1.ExecutionResourceRequirements, backend *batchv1.Job) error {
|
||||
job.Status.ResolvedJobClass = &executionv1alpha1.ResolvedJobClassReference{
|
||||
Name: class.Name, UID: class.UID, ControllerName: class.Spec.ControllerName,
|
||||
ParametersRef: executionv1alpha1.ParametersReference{Group: class.Spec.ParametersRef.Group, Kind: class.Spec.ParametersRef.Kind, Name: parameters.Name, UID: parameters.UID},
|
||||
}
|
||||
job.Status.EffectiveResources = resources
|
||||
job.Status.Execution = &executionv1alpha1.ExecutionStatus{
|
||||
Adapter: "kubernetes",
|
||||
References: []executionv1alpha1.ExecutionReference{{Type: kubernetesadapter.ReferenceType, ID: string(backend.UID)}},
|
||||
}
|
||||
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionAccepted, metav1.ConditionTrue, "Accepted", "Job accepted"))
|
||||
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionScheduled, metav1.ConditionTrue, "BackendCreated", "Kubernetes Job created"))
|
||||
meta.SetStatusCondition(&job.Status.Conditions, condition(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown, "Pending", "Waiting for task to start"))
|
||||
job.Status.ObservedGeneration = job.Generation
|
||||
return r.Status().Update(ctx, job)
|
||||
}
|
||||
|
||||
func (r *JobReconciler) observe(ctx context.Context, job *executionv1alpha1.Job, backend *batchv1.Job) error {
|
||||
if job.Status.StartTime == nil && backend.Status.StartTime != nil {
|
||||
job.Status.StartTime = backend.Status.StartTime.DeepCopy()
|
||||
}
|
||||
for _, backendCondition := range backend.Status.Conditions {
|
||||
switch {
|
||||
case backendCondition.Type == batchv1.JobComplete && backendCondition.Status == corev1.ConditionTrue:
|
||||
completion := backend.Status.CompletionTime
|
||||
if completion == nil {
|
||||
now := metav1.NewTime(r.now())
|
||||
completion = &now
|
||||
}
|
||||
job.Status.CompletionTime = completion.DeepCopy()
|
||||
job.Status.Result = &executionv1alpha1.JobResult{Reason: "Completed"}
|
||||
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionTrue, Reason: "Completed", Message: backendCondition.Message})
|
||||
case backendCondition.Type == batchv1.JobFailed && backendCondition.Status == corev1.ConditionTrue:
|
||||
completion := metav1.NewTime(r.now())
|
||||
job.Status.CompletionTime = &completion
|
||||
job.Status.Result = &executionv1alpha1.JobResult{Reason: "ProcessFailed"}
|
||||
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionFalse, Reason: "ProcessFailed", Message: backendCondition.Message})
|
||||
}
|
||||
}
|
||||
reason := "Pending"
|
||||
message := "Waiting for task to start"
|
||||
if backend.Status.StartTime != nil || backend.Status.Active > 0 {
|
||||
reason = "Running"
|
||||
message = "Task is running"
|
||||
}
|
||||
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionUnknown, Reason: reason, Message: message})
|
||||
}
|
||||
|
||||
func (r *JobReconciler) cancel(ctx context.Context, job *executionv1alpha1.Job) error {
|
||||
backend := &batchv1.Job{}
|
||||
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
|
||||
err := r.Get(ctx, key, backend)
|
||||
if err == nil {
|
||||
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, backendCondition := range backend.Status.Conditions {
|
||||
if (backendCondition.Type == batchv1.JobComplete || backendCondition.Type == batchv1.JobFailed) &&
|
||||
backendCondition.Status == corev1.ConditionTrue {
|
||||
return r.observe(ctx, job, backend)
|
||||
}
|
||||
}
|
||||
if err := r.Delete(ctx, backend, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
now := metav1.NewTime(r.now())
|
||||
job.Status.CompletionTime = &now
|
||||
job.Status.Result = &executionv1alpha1.JobResult{Reason: "Cancelled"}
|
||||
return r.setCondition(ctx, job, metav1.Condition{Type: executionv1alpha1.JobConditionSucceeded, Status: metav1.ConditionFalse, Reason: "Cancelled", Message: "Execution cancelled"})
|
||||
}
|
||||
|
||||
func (r *JobReconciler) finalize(ctx context.Context, job *executionv1alpha1.Job) error {
|
||||
if !containsString(job.Finalizers, jobFinalizer) {
|
||||
return nil
|
||||
}
|
||||
backend := &batchv1.Job{}
|
||||
key := types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
|
||||
if err := r.Get(ctx, key, backend); err == nil {
|
||||
if err := kubernetesadapter.ValidateOwnership(job, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.Delete(ctx, backend, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
job.Finalizers = removeString(job.Finalizers, jobFinalizer)
|
||||
return r.Update(ctx, job)
|
||||
}
|
||||
|
||||
func (r *JobReconciler) setCondition(ctx context.Context, job *executionv1alpha1.Job, next metav1.Condition) error {
|
||||
meta.SetStatusCondition(&job.Status.Conditions, condition(job, next.Type, next.Status, next.Reason, next.Message))
|
||||
job.Status.ObservedGeneration = job.Generation
|
||||
return r.Status().Update(ctx, job)
|
||||
}
|
||||
|
||||
func condition(job *executionv1alpha1.Job, conditionType string, status metav1.ConditionStatus, reason, message string) metav1.Condition {
|
||||
return metav1.Condition{Type: conditionType, Status: status, Reason: reason, Message: message, ObservedGeneration: job.Generation}
|
||||
}
|
||||
|
||||
func conditionTrue(conditions []metav1.Condition, conditionType string) bool {
|
||||
current := meta.FindStatusCondition(conditions, conditionType)
|
||||
return current != nil && current.Status == metav1.ConditionTrue
|
||||
}
|
||||
|
||||
func isTerminal(job *executionv1alpha1.Job) bool {
|
||||
current := meta.FindStatusCondition(job.Status.Conditions, executionv1alpha1.JobConditionSucceeded)
|
||||
return current != nil && (current.Status == metav1.ConditionTrue || current.Status == metav1.ConditionFalse)
|
||||
}
|
||||
|
||||
func applyResourceDefaults(requested, defaults executionv1alpha1.ExecutionResourceRequirements) executionv1alpha1.ExecutionResourceRequirements {
|
||||
result := requested.DeepCopy()
|
||||
if result.Requests.CPU == nil && defaults.Requests.CPU != nil {
|
||||
result.Requests.CPU = copyQuantity(defaults.Requests.CPU)
|
||||
}
|
||||
if result.Requests.Memory == nil && defaults.Requests.Memory != nil {
|
||||
result.Requests.Memory = copyQuantity(defaults.Requests.Memory)
|
||||
}
|
||||
if result.Limits.CPU == nil && defaults.Limits.CPU != nil {
|
||||
result.Limits.CPU = copyQuantity(defaults.Limits.CPU)
|
||||
}
|
||||
if result.Limits.Memory == nil && defaults.Limits.Memory != nil {
|
||||
result.Limits.Memory = copyQuantity(defaults.Limits.Memory)
|
||||
}
|
||||
return *result
|
||||
}
|
||||
|
||||
func copyQuantity(value *resource.Quantity) *resource.Quantity {
|
||||
copy := value.DeepCopy()
|
||||
return ©
|
||||
}
|
||||
|
||||
func validateResources(resources executionv1alpha1.ExecutionResourceRequirements) error {
|
||||
if resources.Requests.CPU != nil && resources.Limits.CPU != nil && resources.Requests.CPU.Cmp(*resources.Limits.CPU) > 0 {
|
||||
return fmt.Errorf("CPU request must not exceed limit")
|
||||
}
|
||||
if resources.Requests.Memory != nil && resources.Limits.Memory != nil && resources.Requests.Memory.Cmp(*resources.Limits.Memory) > 0 {
|
||||
return fmt.Errorf("memory request must not exceed limit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
return slices.Contains(values, target)
|
||||
}
|
||||
|
||||
func removeString(values []string, target string) []string {
|
||||
result := values[:0]
|
||||
for _, value := range values {
|
||||
if value != target {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *JobReconciler) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (r *JobReconciler) SetupWithManager(manager ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(manager).
|
||||
For(&executionv1alpha1.Job{}).
|
||||
Owns(&batchv1.Job{}).
|
||||
Named("execution-job").
|
||||
Complete(r)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
)
|
||||
|
||||
const (
|
||||
integrationNamespace = "controller-integration"
|
||||
integrationClass = "integration"
|
||||
)
|
||||
|
||||
//nolint:modernize // Kubernetes API structs expose ObjectMeta through embedded TypeMeta fields.
|
||||
func TestJobControllerIntegration(t *testing.T) {
|
||||
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
|
||||
t.Skip("KUBEBUILDER_ASSETS is unset; run make test to execute controller integration tests")
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
for _, addToScheme := range []func(*runtime.Scheme) error{
|
||||
corev1.AddToScheme,
|
||||
batchv1.AddToScheme,
|
||||
executionv1alpha1.AddToScheme,
|
||||
} {
|
||||
if err := addToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
crdPath, err := filepath.Abs("../../config/crd/bases")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}}
|
||||
config, err := environment.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("start envtest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := environment.Stop(); err != nil {
|
||||
t.Errorf("stop envtest: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
manager, err := ctrl.NewManager(config, ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsserver.Options{BindAddress: "0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&JobReconciler{Client: manager.GetClient()}).SetupWithManager(manager); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
managerContext, cancelManager := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancelManager)
|
||||
managerErrors := make(chan error, 1)
|
||||
go func() {
|
||||
managerErrors <- manager.Start(managerContext)
|
||||
}()
|
||||
if !manager.GetCache().WaitForCacheSync(managerContext) {
|
||||
t.Fatal("manager cache did not synchronize")
|
||||
}
|
||||
|
||||
directClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
objects := []client.Object{
|
||||
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: integrationNamespace, Labels: map[string]string{testLabelKey: testLabelEnabled}}},
|
||||
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: testSAName, Namespace: integrationNamespace}},
|
||||
&executionv1alpha1.KubernetesExecutionParameters{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: integrationClass},
|
||||
Spec: executionv1alpha1.KubernetesExecutionParametersSpec{
|
||||
ServiceAccountName: testSAName,
|
||||
ImagePullPolicy: corev1.PullIfNotPresent,
|
||||
},
|
||||
},
|
||||
&executionv1alpha1.JobClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: integrationClass},
|
||||
Spec: executionv1alpha1.JobClassSpec{
|
||||
ControllerName: kubernetesadapter.ControllerName,
|
||||
ParametersRef: executionv1alpha1.ParametersReference{
|
||||
Group: executionv1alpha1.GroupVersion.Group,
|
||||
Kind: "KubernetesExecutionParameters",
|
||||
Name: integrationClass,
|
||||
},
|
||||
AllowedNamespaces: &metav1.LabelSelector{MatchLabels: map[string]string{testLabelKey: testLabelEnabled}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, object := range objects {
|
||||
if err := directClient.Create(ctx, object); err != nil {
|
||||
t.Fatalf("create %T: %v", object, err)
|
||||
}
|
||||
}
|
||||
|
||||
job := &executionv1alpha1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: testJobName, Namespace: integrationNamespace},
|
||||
Spec: executionv1alpha1.JobSpec{
|
||||
JobClassName: integrationClass,
|
||||
DesiredState: executionv1alpha1.JobDesiredStateRunning,
|
||||
Task: executionv1alpha1.TaskSpec{Image: "alpine:3.22", Command: []string{"true"}},
|
||||
},
|
||||
}
|
||||
if err := directClient.Create(ctx, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend := &batchv1.Job{}
|
||||
eventually(t, 10*time.Second, func() (bool, error) {
|
||||
err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, backend)
|
||||
return err == nil, client.IgnoreNotFound(err)
|
||||
})
|
||||
if backend.Labels[kubernetesadapter.JobUIDLabel] != string(job.UID) {
|
||||
t.Fatalf("backend identity label = %q, want %q", backend.Labels[kubernetesadapter.JobUIDLabel], job.UID)
|
||||
}
|
||||
|
||||
eventually(t, 10*time.Second, func() (bool, error) {
|
||||
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, job); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return conditionStatus(job, executionv1alpha1.JobConditionScheduled) == metav1.ConditionTrue, nil
|
||||
})
|
||||
|
||||
completed := metav1.Now()
|
||||
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, backend); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.Status.StartTime = &completed
|
||||
backend.Status.CompletionTime = &completed
|
||||
backend.Status.Conditions = []batchv1.JobCondition{
|
||||
{Type: batchv1.JobSuccessCriteriaMet, Status: corev1.ConditionTrue, Reason: "CompletionsReached"},
|
||||
{Type: batchv1.JobComplete, Status: corev1.ConditionTrue, Reason: "Completed"},
|
||||
}
|
||||
if err := directClient.Status().Update(ctx, backend); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
eventually(t, 10*time.Second, func() (bool, error) {
|
||||
if err := directClient.Get(ctx, types.NamespacedName{Namespace: job.Namespace, Name: job.Name}, job); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return conditionStatus(job, executionv1alpha1.JobConditionSucceeded) == metav1.ConditionTrue, nil
|
||||
})
|
||||
if job.Status.StartTime == nil || job.Status.CompletionTime == nil || job.Status.Execution == nil {
|
||||
t.Fatalf("controller did not persist execution status: %#v", job.Status)
|
||||
}
|
||||
|
||||
cancelManager()
|
||||
select {
|
||||
case err := <-managerErrors:
|
||||
if err != nil {
|
||||
t.Fatalf("manager stopped with error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("manager did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, timeout time.Duration, check func() (bool, error)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
ready, err := check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatal(fmt.Errorf("condition was not met within %s", timeout))
|
||||
}
|
||||
|
||||
func conditionStatus(job *executionv1alpha1.Job, conditionType string) metav1.ConditionStatus {
|
||||
condition := meta.FindStatusCondition(job.Status.Conditions, conditionType)
|
||||
if condition == nil {
|
||||
return metav1.ConditionUnknown
|
||||
}
|
||||
return condition.Status
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
|
||||
kubernetesadapter "git.ddupan.top/panxiao81/ayatori/internal/adapter/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClassName = "default"
|
||||
testJobName = "hello"
|
||||
testSAName = "runner"
|
||||
testLabelKey = "execution"
|
||||
testLabelEnabled = "enabled"
|
||||
)
|
||||
|
||||
//nolint:modernize // controller-runtime and Kubernetes API structs expose promoted embedded fields.
|
||||
func TestJobReconcilerKubernetesLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
reconciler, kubeClient := testReconciler(t, now, validObjects()...)
|
||||
request := ctrl.Request{}
|
||||
request.NamespacedName = types.NamespacedName{Namespace: "ci", Name: testJobName}
|
||||
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatalf("add finalizer: %v", err)
|
||||
}
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatalf("create backend: %v", err)
|
||||
}
|
||||
|
||||
backend := &batchv1.Job{}
|
||||
if err := kubeClient.Get(ctx, request.NamespacedName, backend); err != nil {
|
||||
t.Fatalf("backend Job was not created: %v", err)
|
||||
}
|
||||
if backend.Labels[kubernetesadapter.JobUIDLabel] != "ayatori-job-uid" {
|
||||
t.Fatalf("backend UID label = %q", backend.Labels[kubernetesadapter.JobUIDLabel])
|
||||
}
|
||||
|
||||
job := getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
if !conditionIs(job, executionv1alpha1.JobConditionAccepted, metav1.ConditionTrue) ||
|
||||
!conditionIs(job, executionv1alpha1.JobConditionScheduled, metav1.ConditionTrue) {
|
||||
t.Fatalf("Job was not accepted and scheduled: %#v", job.Status.Conditions)
|
||||
}
|
||||
|
||||
started := metav1.NewTime(now.Add(time.Minute))
|
||||
backend.Status.StartTime = &started
|
||||
backend.Status.Active = 1
|
||||
if err := kubeClient.Status().Update(ctx, backend); err != nil {
|
||||
t.Fatalf("set backend running: %v", err)
|
||||
}
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatalf("observe running backend: %v", err)
|
||||
}
|
||||
job = getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
if job.Status.StartTime == nil || !conditionIs(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown) {
|
||||
t.Fatalf("running state was not observed: %#v", job.Status)
|
||||
}
|
||||
|
||||
completed := metav1.NewTime(now.Add(2 * time.Minute))
|
||||
backend = &batchv1.Job{}
|
||||
if err := kubeClient.Get(ctx, request.NamespacedName, backend); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.Status.Active = 0
|
||||
backend.Status.CompletionTime = &completed
|
||||
backend.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue, Reason: "Completed"}}
|
||||
if err := kubeClient.Status().Update(ctx, backend); err != nil {
|
||||
t.Fatalf("set backend complete: %v", err)
|
||||
}
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatalf("observe completed backend: %v", err)
|
||||
}
|
||||
job = getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
if !conditionIs(job, executionv1alpha1.JobConditionSucceeded, metav1.ConditionTrue) || job.Status.CompletionTime == nil {
|
||||
t.Fatalf("terminal state was not observed: %#v", job.Status)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
|
||||
func TestJobReconcilerRejectsMissingClass(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
|
||||
job.Spec.JobClassName = "missing"
|
||||
reconciler, kubeClient := testReconciler(t, time.Now(), validObjects()[0], validObjects()[1], job)
|
||||
request := ctrl.Request{}
|
||||
request.NamespacedName = types.NamespacedName{Namespace: job.Namespace, Name: job.Name}
|
||||
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := reconciler.Reconcile(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RequeueAfter == 0 {
|
||||
t.Fatal("missing JobClass did not schedule a retry")
|
||||
}
|
||||
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
accepted := meta.FindStatusCondition(stored.Status.Conditions, executionv1alpha1.JobConditionAccepted)
|
||||
if accepted == nil || accepted.Status != metav1.ConditionFalse || accepted.Reason != "JobClassNotFound" {
|
||||
t.Fatalf("unexpected Accepted condition: %#v", accepted)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
|
||||
func TestJobReconcilerObservesExistingExecutionWithoutJobClass(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
|
||||
job.Finalizers = []string{jobFinalizer}
|
||||
job.Status.Execution = &executionv1alpha1.ExecutionStatus{Adapter: "kubernetes"}
|
||||
backend := kubernetesadapter.BuildJob(job, validObjects()[4].(*executionv1alpha1.KubernetesExecutionParameters), executionv1alpha1.ExecutionResourceRequirements{})
|
||||
backend.Status.StartTime = &metav1.Time{Time: now}
|
||||
backend.Status.Active = 1
|
||||
reconciler, kubeClient := testReconciler(t, now, job, backend)
|
||||
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
|
||||
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
if stored.Status.StartTime == nil || !conditionIs(stored, executionv1alpha1.JobConditionSucceeded, metav1.ConditionUnknown) {
|
||||
t.Fatalf("existing execution was not observed without its JobClass: %#v", stored.Status)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
|
||||
func TestJobReconcilerCancelsBeforeScheduling(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
|
||||
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateCancelled
|
||||
reconciler, kubeClient := testReconciler(t, time.Unix(1_700_000_000, 0), job)
|
||||
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
|
||||
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
condition := meta.FindStatusCondition(stored.Status.Conditions, executionv1alpha1.JobConditionSucceeded)
|
||||
if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "Cancelled" {
|
||||
t.Fatalf("unexpected cancellation condition: %#v", condition)
|
||||
}
|
||||
if stored.Status.CompletionTime == nil {
|
||||
t.Fatal("cancelled Job has no completionTime")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:modernize // controller-runtime Request exposes NamespacedName as a promoted embedded field.
|
||||
func TestJobReconcilerKeepsConfirmedSuccessDuringCancellation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
job := validObjects()[3].(*executionv1alpha1.Job).DeepCopy()
|
||||
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateCancelled
|
||||
job.Finalizers = []string{jobFinalizer}
|
||||
backend := kubernetesadapter.BuildJob(job, validObjects()[4].(*executionv1alpha1.KubernetesExecutionParameters), executionv1alpha1.ExecutionResourceRequirements{})
|
||||
backend.Status.CompletionTime = &metav1.Time{Time: now}
|
||||
backend.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
|
||||
reconciler, kubeClient := testReconciler(t, now, job, backend)
|
||||
request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: job.Namespace, Name: job.Name}}
|
||||
|
||||
if _, err := reconciler.Reconcile(ctx, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := getJob(t, ctx, kubeClient, request.NamespacedName)
|
||||
if !conditionIs(stored, executionv1alpha1.JobConditionSucceeded, metav1.ConditionTrue) {
|
||||
t.Fatalf("confirmed success was overwritten by cancellation: %#v", stored.Status.Conditions)
|
||||
}
|
||||
if err := kubeClient.Get(ctx, request.NamespacedName, &batchv1.Job{}); err != nil {
|
||||
t.Fatalf("successful backend was deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:modernize // Kubernetes API structs expose ObjectMeta through embedded TypeMeta fields.
|
||||
func validObjects() []client.Object {
|
||||
return []client.Object{
|
||||
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ci", Labels: map[string]string{testLabelKey: testLabelEnabled}}},
|
||||
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: testSAName, Namespace: "ci"}},
|
||||
&executionv1alpha1.JobClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: defaultClassName, UID: types.UID("class-uid")},
|
||||
Spec: executionv1alpha1.JobClassSpec{
|
||||
ControllerName: kubernetesadapter.ControllerName,
|
||||
ParametersRef: executionv1alpha1.ParametersReference{Group: executionv1alpha1.GroupVersion.Group, Kind: "KubernetesExecutionParameters", Name: defaultClassName},
|
||||
AllowedNamespaces: &metav1.LabelSelector{MatchLabels: map[string]string{testLabelKey: testLabelEnabled}},
|
||||
},
|
||||
},
|
||||
&executionv1alpha1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: testJobName, Namespace: "ci", UID: types.UID("ayatori-job-uid")},
|
||||
Spec: executionv1alpha1.JobSpec{
|
||||
JobClassName: defaultClassName, DesiredState: executionv1alpha1.JobDesiredStateRunning,
|
||||
Task: executionv1alpha1.TaskSpec{Image: "alpine:3.22", Command: []string{"true"}},
|
||||
},
|
||||
},
|
||||
&executionv1alpha1.KubernetesExecutionParameters{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: defaultClassName, UID: types.UID("parameters-uid")},
|
||||
Spec: executionv1alpha1.KubernetesExecutionParametersSpec{ServiceAccountName: testSAName, ImagePullPolicy: corev1.PullIfNotPresent},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testReconciler(t *testing.T, now time.Time, objects ...client.Object) (*JobReconciler, client.Client) {
|
||||
t.Helper()
|
||||
scheme := runtime.NewScheme()
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := batchv1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executionv1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kubeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithStatusSubresource(&executionv1alpha1.Job{}, &batchv1.Job{}).
|
||||
WithObjects(objects...).
|
||||
Build()
|
||||
return &JobReconciler{Client: kubeClient, Now: func() time.Time { return now }}, kubeClient
|
||||
}
|
||||
|
||||
func getJob(t *testing.T, ctx context.Context, kubeClient client.Client, key types.NamespacedName) *executionv1alpha1.Job {
|
||||
t.Helper()
|
||||
job := &executionv1alpha1.Job{}
|
||||
if err := kubeClient.Get(ctx, key, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
func conditionIs(job *executionv1alpha1.Job, conditionType string, status metav1.ConditionStatus) bool {
|
||||
condition := meta.FindStatusCondition(job.Status.Conditions, conditionType)
|
||||
return condition != nil && condition.Status == status
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
func bindingTenant(object *databasev1alpha1.PostgreSQLTenant) *application.BindingTenant {
|
||||
tenant := &application.BindingTenant{
|
||||
Revision: object.ResourceVersion, Generation: object.Generation,
|
||||
}
|
||||
tenant.Tenant = binding.Tenant{
|
||||
Identity: binding.TenantIdentity{Namespace: object.Namespace, Name: object.Name, UID: string(object.UID)},
|
||||
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
|
||||
}
|
||||
if request := object.Spec.Provision; request != nil {
|
||||
tenant.Request.Provision = &binding.ProvisionRequest{
|
||||
Instance: string(request.InstanceRef.Name), Database: string(request.Database), LoginRole: string(request.LoginRole),
|
||||
}
|
||||
}
|
||||
if object.Spec.DatabaseRef != nil {
|
||||
tenant.Request.ExistingDatabase = string(object.Spec.DatabaseRef.Name)
|
||||
}
|
||||
if ref := object.Status.DatabaseRef; ref != nil {
|
||||
tenant.Database = &binding.Identity{Name: string(ref.Name), UID: string(ref.UID)}
|
||||
}
|
||||
return tenant
|
||||
}
|
||||
|
||||
func bindingDatabase(object *databasev1alpha1.PostgreSQLDatabase) *application.BindingDatabase {
|
||||
database := &application.BindingDatabase{
|
||||
Revision: object.ResourceVersion,
|
||||
}
|
||||
database.Database = binding.Database{
|
||||
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
|
||||
Instance: string(object.Spec.InstanceRef.Name), InstanceUID: string(object.Status.InstanceUID),
|
||||
Name: string(object.Spec.Database), LoginRole: string(object.Spec.LoginRole), Source: object.Spec.Source,
|
||||
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
|
||||
Ready: currentReady(object.Generation, object.Status.Conditions),
|
||||
}
|
||||
if ref := object.Spec.TenantRef; ref != nil {
|
||||
database.Tenant = &binding.TenantIdentity{Namespace: ref.Namespace, Name: string(ref.Name), UID: string(ref.UID)}
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func tenantReference(tenant binding.TenantIdentity) *databasev1alpha1.TenantReference {
|
||||
return &databasev1alpha1.TenantReference{
|
||||
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: types.UID(tenant.UID),
|
||||
}
|
||||
}
|
||||
|
||||
// BindingTargetName 供 informer 索引使用;不把无效请求丢出事件映射。
|
||||
func BindingTargetName(tenant *databasev1alpha1.PostgreSQLTenant) string {
|
||||
if tenant.Spec.DatabaseRef != nil {
|
||||
return string(tenant.Spec.DatabaseRef.Name)
|
||||
}
|
||||
return binding.DynamicDatabaseName(string(tenant.UID))
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
"k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
)
|
||||
|
||||
const (
|
||||
TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection"
|
||||
DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection"
|
||||
)
|
||||
|
||||
// BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。
|
||||
// 重新读取后校验快照版本,保留不属于本用例的字段;不决定绑定资格或恢复顺序。
|
||||
type BindingResources struct {
|
||||
Client client.Client
|
||||
Reader client.Reader
|
||||
}
|
||||
|
||||
var _ application.BindingResources = (*BindingResources)(nil)
|
||||
|
||||
func (r *BindingResources) Tenant(ctx context.Context, namespace, name string) (*application.BindingTenant, error) {
|
||||
object := &databasev1alpha1.PostgreSQLTenant{}
|
||||
if err := r.Reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, object); err != nil {
|
||||
return nil, client.IgnoreNotFound(err)
|
||||
}
|
||||
return bindingTenant(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) Database(ctx context.Context, name string) (*application.BindingDatabase, error) {
|
||||
object := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
|
||||
return nil, client.IgnoreNotFound(err)
|
||||
}
|
||||
return bindingDatabase(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) Instance(ctx context.Context, name string) (*binding.Instance, error) {
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
|
||||
return nil, client.IgnoreNotFound(err)
|
||||
}
|
||||
return &binding.Instance{
|
||||
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
|
||||
Deleting: !object.DeletionTimestamp.IsZero(), Ready: currentReady(object.Generation, object.Status.Conditions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) BeginBinding(ctx context.Context, tenant *application.BindingTenant,
|
||||
checkpoint *application.BindingStatus) (*application.BindingTenant, error) {
|
||||
object, err := r.tenantAtVersion(ctx, tenant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if controllerutil.AddFinalizer(object, TenantFinalizer) {
|
||||
if err := r.Client.Update(ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if checkpoint != nil {
|
||||
if err := r.presentStatus(ctx, object, *checkpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return bindingTenant(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) CreateDatabase(ctx context.Context, target binding.Target,
|
||||
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
|
||||
object := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
object.Name = target.Name
|
||||
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
|
||||
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(target.Provision.Instance)},
|
||||
Database: databasev1alpha1.PostgreSQLIdentifier(target.Provision.Database),
|
||||
LoginRole: databasev1alpha1.PostgreSQLIdentifier(target.Provision.LoginRole),
|
||||
Source: "Provision", ReclaimPolicy: databasev1alpha1.ReclaimRetain, TenantRef: tenantReference(tenant),
|
||||
}
|
||||
controllerutil.AddFinalizer(object, DatabaseFinalizer)
|
||||
if err := r.Client.Create(ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindingDatabase(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) RecordInstance(ctx context.Context, database *application.BindingDatabase,
|
||||
instanceUID string) (*application.BindingDatabase, error) {
|
||||
object, err := r.databaseAtVersion(ctx, database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.Status.InstanceUID = types.UID(instanceUID)
|
||||
if err := r.Client.Status().Update(ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindingDatabase(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) BindDatabase(ctx context.Context, database *application.BindingDatabase,
|
||||
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
|
||||
object, err := r.databaseAtVersion(ctx, database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := tenantReference(tenant)
|
||||
changed := controllerutil.AddFinalizer(object, DatabaseFinalizer)
|
||||
if object.Spec.TenantRef == nil || *object.Spec.TenantRef != *wanted {
|
||||
object.Spec.TenantRef = wanted
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := r.Client.Update(ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return bindingDatabase(object), nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) Present(ctx context.Context, result application.BindingResult) error {
|
||||
if result.Tenant == nil {
|
||||
return nil
|
||||
}
|
||||
object, err := r.tenantAtVersion(ctx, result.Tenant)
|
||||
if err != nil {
|
||||
return client.IgnoreNotFound(err)
|
||||
}
|
||||
return r.presentStatus(ctx, object, result.Status)
|
||||
}
|
||||
|
||||
func (r *BindingResources) presentStatus(ctx context.Context, object *databasev1alpha1.PostgreSQLTenant,
|
||||
status application.BindingStatus) error {
|
||||
previous := object.Status.DeepCopy()
|
||||
object.Status.Phase = status.Phase
|
||||
object.Status.ObservedGeneration = object.Generation
|
||||
if status.Database != nil {
|
||||
object.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
|
||||
Name: databasev1alpha1.ObjectName(status.Database.Name), UID: types.UID(status.Database.UID),
|
||||
}
|
||||
}
|
||||
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready", Status: metav1.ConditionFalse, Reason: status.Reason, Message: status.Message,
|
||||
ObservedGeneration: object.Generation,
|
||||
})
|
||||
if equality.Semantic.DeepEqual(*previous, object.Status) {
|
||||
return nil
|
||||
}
|
||||
return r.Client.Status().Update(ctx, object)
|
||||
}
|
||||
|
||||
func (r *BindingResources) tenantAtVersion(ctx context.Context, tenant *application.BindingTenant) (*databasev1alpha1.PostgreSQLTenant, error) {
|
||||
object := &databasev1alpha1.PostgreSQLTenant{}
|
||||
key := types.NamespacedName{Namespace: tenant.Identity.Namespace, Name: tenant.Identity.Name}
|
||||
if err := r.Reader.Get(ctx, key, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(object.UID) != tenant.Identity.UID || object.ResourceVersion != tenant.Revision {
|
||||
return nil, bindingVersionConflict("postgresqltenants", object.Name)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func (r *BindingResources) databaseAtVersion(ctx context.Context, database *application.BindingDatabase) (*databasev1alpha1.PostgreSQLDatabase, error) {
|
||||
object := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
if err := r.Reader.Get(ctx, types.NamespacedName{Name: database.Identity.Name}, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(object.UID) != database.Identity.UID || object.ResourceVersion != database.Revision {
|
||||
return nil, bindingVersionConflict("postgresqldatabases", object.Name)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func bindingVersionConflict(resource, name string) error {
|
||||
return apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource(resource).GroupResource(), name,
|
||||
fmt.Errorf("绑定快照已过期,请重新读取后判断"))
|
||||
}
|
||||
|
||||
func currentReady(generation int64, conditions []metav1.Condition) bool {
|
||||
condition := meta.FindStatusCondition(conditions, "Ready")
|
||||
return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package kubernetes 提供 Database 所需的 Kubernetes API 薄适配。
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
typedcore "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// SecretCredentials 直接读取 API server,不将 Secret 数据纳入共享 informer cache。
|
||||
// namespace 在装配时固定,Instance 不能选择跨 namespace 读取。
|
||||
type SecretCredentials struct {
|
||||
secrets typedcore.SecretInterface
|
||||
}
|
||||
|
||||
func NewSecretCredentials(config *rest.Config, namespace string) (*SecretCredentials, error) {
|
||||
if config == nil || len(validation.IsDNS1123Label(namespace)) != 0 {
|
||||
return nil, errors.New("valid controller namespace and API configuration required")
|
||||
}
|
||||
client, err := typedcore.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, application.ErrCredentialsUnavailable
|
||||
}
|
||||
return &SecretCredentials{secrets: client.Secrets(namespace)}, nil
|
||||
}
|
||||
|
||||
func (r *SecretCredentials) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
|
||||
if err := ref.Validate(); err != nil {
|
||||
return application.Credentials{}, application.ErrCredentialsInvalid
|
||||
}
|
||||
keys := ref.Values()
|
||||
secret, err := r.secrets.Get(ctx, keys.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return application.Credentials{}, application.ErrCredentialsUnavailable
|
||||
}
|
||||
return decode(secret, keys)
|
||||
}
|
||||
|
||||
func decode(secret *corev1.Secret, keys instance.CredentialReferenceValues) (application.Credentials, error) {
|
||||
if secret.DeletionTimestamp != nil {
|
||||
return application.Credentials{}, application.ErrCredentialsUnavailable
|
||||
}
|
||||
return application.NewCredentials(string(secret.Data[keys.UsernameKey]), string(secret.Data[keys.PasswordKey]))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func instanceRecord(object *databasev1alpha1.PostgreSQLInstance) (*application.InstanceRecord, error) {
|
||||
identity, err := instance.NewIdentity(string(object.UID), object.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision, err := instance.NewRevision(object.Generation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec := object.Spec
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: spec.Endpoint.Host, HostAddr: spec.Endpoint.HostAddr,
|
||||
Port: int(spec.Endpoint.Port), ManagementDatabase: string(spec.Endpoint.Database),
|
||||
TLSMode: instance.TLSMode(spec.Endpoint.SSLMode),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credential, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
|
||||
Name: string(spec.AdminCredentialRef.Name),
|
||||
UsernameKey: spec.AdminCredentialRef.UsernameKey, PasswordKey: spec.AdminCredentialRef.PasswordKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition, err := instance.NewDefinition(endpoint, credential)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := instance.NewObservationTarget(identity, revision, definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &application.InstanceRecord{
|
||||
Target: target, Revision: object.ResourceVersion, Deleting: !object.DeletionTimestamp.IsZero(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
"k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
)
|
||||
|
||||
const InstanceFinalizer = "database.ayatori.ddupan.top/instance-protection"
|
||||
|
||||
type InstanceResources struct {
|
||||
Client client.Client
|
||||
Reader client.Reader
|
||||
}
|
||||
|
||||
func (r *InstanceResources) LoadInstance(ctx context.Context, name string) (*application.InstanceRecord, error) {
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
|
||||
return nil, client.IgnoreNotFound(err)
|
||||
}
|
||||
return instanceRecord(object)
|
||||
}
|
||||
|
||||
func (r *InstanceResources) ProtectInstance(ctx context.Context, record *application.InstanceRecord) (*application.InstanceRecord, error) {
|
||||
object, err := r.instanceAtVersion(ctx, record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if controllerutil.AddFinalizer(object, InstanceFinalizer) {
|
||||
if err := r.Client.Update(ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return instanceRecord(object)
|
||||
}
|
||||
|
||||
func (r *InstanceResources) InstanceReferences(ctx context.Context, name string) (string, error) {
|
||||
// 删除判断必须直读 API;包含 Released、删除中的 Database 和尚未绑定的申请。
|
||||
// 不按旧 Instance UID 忽略引用,也不依赖 informer 索引的及时性。
|
||||
databases := &databasev1alpha1.PostgreSQLDatabaseList{}
|
||||
if err := r.Reader.List(ctx, databases); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, database := range databases.Items {
|
||||
if string(database.Spec.InstanceRef.Name) == name {
|
||||
return "Database/" + database.Name, nil
|
||||
}
|
||||
}
|
||||
tenants := &databasev1alpha1.PostgreSQLTenantList{}
|
||||
if err := r.Reader.List(ctx, tenants); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, tenant := range tenants.Items {
|
||||
if tenant.Spec.Provision != nil && string(tenant.Spec.Provision.InstanceRef.Name) == name {
|
||||
return "Tenant/" + tenant.Namespace + "/" + tenant.Name, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *InstanceResources) PresentInstance(ctx context.Context, result application.InstanceResult) error {
|
||||
if result.Record == nil {
|
||||
return nil
|
||||
}
|
||||
object, err := r.instanceAtVersion(ctx, result.Record)
|
||||
if err != nil {
|
||||
return client.IgnoreNotFound(err)
|
||||
}
|
||||
previous := object.Status.DeepCopy()
|
||||
object.Status.Phase = string(result.Snapshot.Phase)
|
||||
object.Status.ObservedGeneration = object.Generation
|
||||
object.Status.PostgreSQLVersion = result.Snapshot.ReportedVersion
|
||||
ready := metav1.ConditionFalse
|
||||
if result.Snapshot.Readiness == instance.Ready {
|
||||
ready = metav1.ConditionTrue
|
||||
}
|
||||
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready", Status: ready, ObservedGeneration: object.Generation,
|
||||
Reason: result.Reason, Message: result.Message,
|
||||
})
|
||||
if !equality.Semantic.DeepEqual(*previous, object.Status) {
|
||||
if err := r.Client.Status().Update(ctx, object); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if result.RemoveProtection && controllerutil.RemoveFinalizer(object, InstanceFinalizer) {
|
||||
return r.Client.Update(ctx, object)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InstanceResources) instanceAtVersion(ctx context.Context, record *application.InstanceRecord) (*databasev1alpha1.PostgreSQLInstance, error) {
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
name := record.Target.Identity().Name()
|
||||
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(object.UID) != record.Target.Identity().UID() || object.ResourceVersion != record.Revision {
|
||||
return nil, apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource("postgresqlinstances").GroupResource(),
|
||||
name, errors.New("Instance 快照已过期,请重新观察"))
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package openbao 通过官方 SDK 适配应用凭据,不保存资源归属或重建供应状态。
|
||||
package openbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
bao "github.com/openbao/openbao/api/v2"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLocation = errors.New("credential location is outside the configured scope")
|
||||
ErrUnavailable = errors.New("credential backend unavailable")
|
||||
ErrNotFound = errors.New("application credential not found")
|
||||
ErrConflict = errors.New("credential creation requires manual conflict resolution")
|
||||
ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
|
||||
)
|
||||
|
||||
var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Credentials 使用独立的 SDK client;认证与短期 token 生命周期由部署装配负责。
|
||||
// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。
|
||||
type Credentials struct {
|
||||
kv *bao.KVv2
|
||||
basePath string
|
||||
}
|
||||
|
||||
// NewCredentials 不登录、不读取环境 token。调用方必须提供专用的已认证 client。
|
||||
// 禁用 SDK 写入重试,防止第一次结果丢失后被 CAS 错误掩盖。
|
||||
func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, error) {
|
||||
if client == nil || !validPath(mount) || !validPath(basePath) {
|
||||
return nil, ErrInvalidLocation
|
||||
}
|
||||
client.SetMaxRetries(0)
|
||||
return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil
|
||||
}
|
||||
|
||||
func validPath(value string) bool {
|
||||
for segment := range strings.SplitSeq(value, "/") {
|
||||
if !pathSegment.MatchString(segment) || segment == "data" || segment == "metadata" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ProvisionPath 只按 Database UID 定位;调用方须先持久化位置,再执行外部写入。
|
||||
func (c *Credentials) ProvisionPath(databaseUID string) (string, error) {
|
||||
if !pathSegment.MatchString(databaseUID) {
|
||||
return "", ErrInvalidLocation
|
||||
}
|
||||
return c.basePath + "/" + databaseUID, nil
|
||||
}
|
||||
|
||||
func (c *Credentials) accepts(path string) bool {
|
||||
return validPath(path) && strings.HasPrefix(path, c.basePath+"/")
|
||||
}
|
||||
|
||||
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
||||
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
|
||||
if !c.accepts(path) {
|
||||
return application.ApplicationCredential{}, ErrInvalidLocation
|
||||
}
|
||||
secret, err := c.kv.Get(ctx, path)
|
||||
if errors.Is(err, bao.ErrSecretNotFound) {
|
||||
return application.ApplicationCredential{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return application.ApplicationCredential{}, ErrUnavailable
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return application.ApplicationCredential{}, ErrNotFound
|
||||
}
|
||||
return application.ParseApplicationCredential(secret.Data)
|
||||
}
|
||||
|
||||
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
||||
// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。
|
||||
func (c *Credentials) Create(ctx context.Context, path string, credential application.ApplicationCredential) error {
|
||||
if !c.accepts(path) {
|
||||
return ErrInvalidLocation
|
||||
}
|
||||
if err := credential.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
data := credential.SecretData()
|
||||
created, err := c.kv.Put(ctx, path, data, bao.WithCheckAndSet(0))
|
||||
if err != nil {
|
||||
// 明确的认证/权限拒绝没有发生写入,可以等待依赖恢复。
|
||||
// SDK 的原始错误可能携带路径及响应体,不向外传播。
|
||||
if response, ok := errors.AsType[*bao.ResponseError](err); ok {
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return ErrUnavailable
|
||||
case http.StatusBadRequest:
|
||||
if slices.Contains(response.Errors, "check-and-set parameter did not match the current version") {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
return ErrUncertain
|
||||
}
|
||||
if created == nil || created.VersionMetadata == nil || created.VersionMetadata.Version != 1 {
|
||||
return ErrUncertain
|
||||
}
|
||||
observed, err := c.kv.Get(ctx, path)
|
||||
if err != nil || observed == nil || observed.VersionMetadata == nil || observed.VersionMetadata.Version != 1 {
|
||||
return ErrUncertain
|
||||
}
|
||||
if !maps.Equal(data, observed.Data) {
|
||||
return ErrUncertain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package openbao_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bao "github.com/openbao/openbao/api/v2"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||
)
|
||||
|
||||
// 只连接本测试创建的无持久卷 dev server,不接受生产地址或环境 token。
|
||||
func baoFixture(t *testing.T) *bao.Client {
|
||||
t.Helper()
|
||||
const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0"
|
||||
prepareBaoImage(t, image)
|
||||
// 冷缓存拉取不占用容器启动和健康检查的一分钟预算。
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, "docker", "run", "--pull=never", "--rm", "-d", "-p", "127.0.0.1::8200",
|
||||
image, "server", "-dev", "-dev-root-token-id="+fixtureToken, "-dev-listen-address=0.0.0.0:8200").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("cannot start isolated OpenBao fixture: %s", baoCommandError(ctx, err))
|
||||
}
|
||||
id := strings.TrimSpace(string(output))
|
||||
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
|
||||
t.Fatal("unexpected fixture container ID")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanup, stop := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer stop()
|
||||
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
|
||||
t.Error("OpenBao fixture cleanup failed")
|
||||
}
|
||||
})
|
||||
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format",
|
||||
`{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("cannot inspect fixture port: %s", baoCommandError(ctx, err))
|
||||
}
|
||||
client := fixtureClient(t, "http://127.0.0.1:"+strings.TrimSpace(string(output)))
|
||||
client.SetMaxRetries(0)
|
||||
for {
|
||||
if _, err := client.Sys().HealthWithContext(ctx); err == nil {
|
||||
return client
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("OpenBao fixture startup timed out")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareBaoImage(t *testing.T, image string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if exec.CommandContext(ctx, "docker", "image", "inspect", image).Run() == nil {
|
||||
return
|
||||
}
|
||||
t.Log("pulling isolated OpenBao fixture image (timeout: 5m)")
|
||||
if _, err := exec.CommandContext(ctx, "docker", "pull", image).Output(); err != nil {
|
||||
t.Fatalf("cannot pull OpenBao fixture image: %s", baoCommandError(ctx, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 保留 Docker stderr 与超时原因,但不泄露测试 token/password 或完整命令参数。
|
||||
func baoCommandError(ctx context.Context, err error) string {
|
||||
detail := err.Error()
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
detail += ": " + ctx.Err().Error()
|
||||
}
|
||||
return strings.NewReplacer(fixtureToken, "[REDACTED]", fixturePassword, "[REDACTED]").Replace(detail)
|
||||
}
|
||||
|
||||
func TestBaoCommandError(t *testing.T) {
|
||||
err := &exec.ExitError{Stderr: []byte("registry unavailable " + fixtureToken + " " + fixturePassword)}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
detail := baoCommandError(ctx, err)
|
||||
if !strings.Contains(detail, "registry unavailable") || !strings.Contains(detail, "context canceled") {
|
||||
t.Fatal("Docker diagnostic or context failure was lost")
|
||||
}
|
||||
if strings.Contains(detail, fixtureToken) || strings.Contains(detail, fixturePassword) {
|
||||
t.Fatal("Docker diagnostic exposed fixture credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialConcurrentCreateWithRealOpenBao(t *testing.T) {
|
||||
root := baoFixture(t)
|
||||
store := fixtureStore(t, root)
|
||||
credential := fixtureCredential(t)
|
||||
results := make(chan error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for range 2 {
|
||||
workers.Go(func() { results <- store.Create(t.Context(), credentialPath, credential) })
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
succeeded, conflicted := 0, 0
|
||||
for err := range results {
|
||||
switch err {
|
||||
case nil:
|
||||
succeeded++
|
||||
case openbao.ErrConflict:
|
||||
conflicted++
|
||||
default:
|
||||
t.Fatal("unexpected concurrent create result")
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || conflicted != 1 {
|
||||
t.Fatal("CAS must allow exactly one creator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialLostWriteResponseWithRealOpenBao(t *testing.T) {
|
||||
root := baoFixture(t)
|
||||
address, err := url.Parse(root.Address())
|
||||
if err != nil {
|
||||
t.Fatal("invalid fixture address")
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(address)
|
||||
proxy.ModifyResponse = func(response *http.Response) error {
|
||||
if response.Request.Method == http.MethodPut && response.StatusCode == http.StatusOK {
|
||||
return errors.New("fixture drops successful write response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
server := httptest.NewServer(proxy)
|
||||
defer server.Close()
|
||||
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||
credential := fixtureCredential(t)
|
||||
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain {
|
||||
t.Fatal("lost response must stop provisioning")
|
||||
}
|
||||
confirmed, err := root.KVv2("secret").Get(t.Context(), credentialPath)
|
||||
if err != nil || !maps.Equal(confirmed.Data, credential.SecretData()) || confirmed.VersionMetadata.Version != 1 {
|
||||
t.Fatal("fault injection did not preserve the original write")
|
||||
}
|
||||
if err := fixtureStore(t, root).Create(t.Context(), credentialPath, credential); err != openbao.ErrConflict {
|
||||
t.Fatal("restart must not adopt an unconfirmed write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsWithRealOpenBao(t *testing.T) {
|
||||
root := baoFixture(t)
|
||||
ctx := t.Context()
|
||||
// root 仅用于 fixture 装配;实际读写使用固定前缀的短期 token。
|
||||
policy := `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }`
|
||||
if err := root.Sys().PutPolicyWithContext(ctx, "application-fixture", policy); err != nil {
|
||||
t.Fatal("cannot configure fixture policy")
|
||||
}
|
||||
secret, err := root.Auth().Token().CreateWithContext(ctx, &bao.TokenCreateRequest{
|
||||
Policies: []string{"application-fixture"}, NoDefaultPolicy: true, TTL: "5m",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("cannot create scoped fixture token")
|
||||
}
|
||||
client := fixtureClient(t, root.Address())
|
||||
client.SetToken(secret.Auth.ClientToken)
|
||||
store := fixtureStore(t, client)
|
||||
credential := fixtureCredential(t)
|
||||
if err := store.Create(ctx, credentialPath, credential); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重建适配器读取已确认路径;重复 Create 仍报冲突,不把读取当作认领。
|
||||
restarted := fixtureStore(t, client)
|
||||
observed, err := restarted.Read(ctx, credentialPath)
|
||||
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
|
||||
t.Fatal("confirmed credential was not preserved across adapter restart")
|
||||
}
|
||||
if err := restarted.Create(ctx, credentialPath, credential); !errors.Is(err, openbao.ErrConflict) {
|
||||
t.Fatal("existing credential must conflict even if contents match")
|
||||
}
|
||||
metadata, err := root.KVv2("secret").GetMetadata(ctx, credentialPath)
|
||||
if err != nil || metadata.CurrentVersion != 1 {
|
||||
t.Fatal("duplicate create changed credential version")
|
||||
}
|
||||
if _, err := client.KVv2("secret").Get(ctx, "management/instance"); err == nil {
|
||||
t.Fatal("scoped token accessed management credentials")
|
||||
}
|
||||
if err := root.KVv2("secret").Delete(ctx, credentialPath); err != nil {
|
||||
t.Fatal("cannot soft-delete fixture credential")
|
||||
}
|
||||
if _, err := store.Read(ctx, credentialPath); err != openbao.ErrNotFound {
|
||||
t.Fatal("soft-deleted credential must not be usable")
|
||||
}
|
||||
if err := store.Create(ctx, credentialPath, credential); err != openbao.ErrConflict {
|
||||
t.Fatal("soft-deleted credential must not be recreated")
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package openbao_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
bao "github.com/openbao/openbao/api/v2"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
)
|
||||
|
||||
const (
|
||||
credentialPath = "applications/database-uid"
|
||||
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
||||
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
||||
kvDataKey = "data"
|
||||
)
|
||||
|
||||
func fixtureCredential(t *testing.T) application.ApplicationCredential {
|
||||
t.Helper()
|
||||
credential, err := application.ParseApplicationCredential(map[string]any{
|
||||
"username": "app_owner", "password": fixturePassword, "database": "app",
|
||||
"host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return credential
|
||||
}
|
||||
|
||||
func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
||||
for _, scenario := range []string{"read failure", "changed version", "changed password", "missing metadata"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
credential := fixtureCredential(t)
|
||||
var writes atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
writes.Add(1)
|
||||
var request struct {
|
||||
Options struct {
|
||||
CAS *int `json:"cas"`
|
||||
} `json:"options"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
|
||||
t.Error("create request must explicitly require CAS=0")
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil {
|
||||
t.Error("cannot encode fixture write response")
|
||||
}
|
||||
return
|
||||
}
|
||||
if scenario == "read failure" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
data := credential.SecretData()
|
||||
version := 1
|
||||
if scenario == "changed version" {
|
||||
version = 2
|
||||
}
|
||||
if scenario == "changed password" {
|
||||
data["password"] = "modified"
|
||||
}
|
||||
response := map[string]any{kvDataKey: data}
|
||||
if scenario != "missing metadata" {
|
||||
response["metadata"] = map[string]any{"version": version}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
|
||||
t.Error("cannot encode fixture read response")
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain || writes.Load() != 1 {
|
||||
t.Fatal("unconfirmed readback must stop after one write")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureClient(t *testing.T, address string) *bao.Client {
|
||||
t.Helper()
|
||||
config := bao.DefaultConfig()
|
||||
config.Address = address
|
||||
client, err := bao.NewClient(config)
|
||||
if err != nil {
|
||||
t.Fatal("cannot construct fixture client")
|
||||
}
|
||||
client.SetToken(fixtureToken)
|
||||
return client
|
||||
}
|
||||
|
||||
func fixtureStore(t *testing.T, client *bao.Client) *openbao.Credentials {
|
||||
t.Helper()
|
||||
store, err := openbao.NewCredentials(client, "secret", "applications")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestCredentialLocationScope(t *testing.T) {
|
||||
client := fixtureClient(t, "http://127.0.0.1:1")
|
||||
store := fixtureStore(t, client)
|
||||
path, err := store.ProvisionPath("database-uid")
|
||||
if err != nil || path != credentialPath {
|
||||
t.Fatal("unexpected stable location")
|
||||
}
|
||||
for _, path := range []string{"", "/absolute", "applications", "applications-other/key", "applications/../management", "applications/%2e%2e/key", "applications//key", "applications/data/key"} {
|
||||
if _, err := store.Read(t.Context(), path); !errors.Is(err, openbao.ErrInvalidLocation) {
|
||||
t.Fatal("accepted invalid location")
|
||||
}
|
||||
if err := store.Create(t.Context(), path, fixtureCredential(t)); !errors.Is(err, openbao.ErrInvalidLocation) {
|
||||
t.Fatal("accepted invalid create location")
|
||||
}
|
||||
}
|
||||
for _, uid := range []string{"", "../key", "a/b", "a?b"} {
|
||||
if _, err := store.ProvisionPath(uid); err == nil {
|
||||
t.Fatal("accepted invalid UID")
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"", "data", "metadata", "../secret", "secret/", "secret?query"} {
|
||||
if _, err := openbao.NewCredentials(client, invalid, "applications"); err == nil {
|
||||
t.Fatal("accepted invalid mount")
|
||||
}
|
||||
if _, err := openbao.NewCredentials(client, "secret", invalid); err == nil {
|
||||
t.Fatal("accepted invalid base path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialWriteFailureDoesNotRetryOrLeak(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
http.Error(w, fixturePassword+fixtureToken, http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||
if err := store.Create(t.Context(), credentialPath, fixtureCredential(t)); err != openbao.ErrUncertain {
|
||||
t.Fatal("write error must be a redacted uncertain outcome")
|
||||
}
|
||||
if requests.Load() != 1 {
|
||||
t.Fatal("SDK retried an uncertain write")
|
||||
}
|
||||
if _, err := store.Read(t.Context(), credentialPath); err != openbao.ErrUnavailable {
|
||||
t.Fatal("read error must be redacted")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err := store.Create(ctx, credentialPath, fixtureCredential(t)); err != openbao.ErrUnavailable || requests.Load() != 2 {
|
||||
t.Fatal("canceled operation must not write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialWriteDeniedBeforeExecution(t *testing.T) {
|
||||
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, fixtureToken, status)
|
||||
}))
|
||||
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||
err := store.Create(t.Context(), credentialPath, fixtureCredential(t))
|
||||
server.Close()
|
||||
if err != openbao.ErrUnavailable {
|
||||
t.Fatalf("status %d: definite rejection should wait for dependency recovery, got %v", status, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package postgresql 使用 pgxpool 提供 PostgreSQL 能力的薄适配。
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// Connector 不读取 Secret、不决定连接何时替换;池本身由 pgxpool 实现。
|
||||
type Connector struct {
|
||||
RootCert string
|
||||
}
|
||||
|
||||
type database struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (*database) String() string { return "[redacted PostgreSQL database]" }
|
||||
func (d *database) GoString() string { return d.String() }
|
||||
func (d *database) Close() {
|
||||
d.pool.Close()
|
||||
}
|
||||
|
||||
func (c Connector) Connect(ctx context.Context, endpoint instance.Endpoint, credentials application.Credentials) (application.Database, error) {
|
||||
if err := endpoint.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if credentials.Username() == "" || credentials.Password() == "" {
|
||||
return nil, application.ErrCredentialsInvalid
|
||||
}
|
||||
endpointValues := endpoint.Values()
|
||||
query := url.Values{
|
||||
"sslmode": {string(endpointValues.TLSMode)},
|
||||
"connect_timeout": {"5"},
|
||||
"application_name": {"ayatori-database-management"},
|
||||
}
|
||||
if c.RootCert != "" {
|
||||
query.Set("sslrootcert", c.RootCert)
|
||||
}
|
||||
connectionURL := url.URL{
|
||||
Scheme: "postgresql",
|
||||
Host: net.JoinHostPort(endpointValues.Host, strconv.Itoa(endpointValues.Port)),
|
||||
Path: "/" + endpointValues.ManagementDatabase,
|
||||
User: url.UserPassword(credentials.Username(), credentials.Password()),
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
config, err := pgxpool.ParseConfig(connectionURL.String())
|
||||
if err != nil {
|
||||
return nil, application.ErrConnection
|
||||
}
|
||||
// pgx 不实现 libpq hostaddr;复用其 LookupFunc 扩展点,TLS 验证身份仍采用 host。
|
||||
config.ConnConfig.LookupFunc = func(context.Context, string) ([]string, error) {
|
||||
return []string{endpointValues.HostAddr}, nil
|
||||
}
|
||||
config.ConnConfig.Fallbacks = nil
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, safeError(err, application.ErrConnection)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, safeError(err, application.ErrConnection)
|
||||
}
|
||||
return &database{pool: pool}, nil
|
||||
}
|
||||
|
||||
func safeError(err, fallback error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
var pgerr *pgconn.PgError
|
||||
if errors.As(err, &pgerr) && (pgerr.Code == "28P01" || pgerr.Code == "28000") {
|
||||
return application.ErrAuthentication
|
||||
}
|
||||
if _, ok := errors.AsType[*tls.CertificateVerificationError](err); ok {
|
||||
return application.ErrAuthentication
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func TestManagementSecretScopeAndMissingDependencyRecovery(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
reference := fixture.target.Definition().AdminCredential()
|
||||
|
||||
fixture.createSecret(t, "unrelated")
|
||||
if _, err := fixture.reader.Read(fixture.ctx, reference); !errors.Is(err, application.ErrCredentialsUnavailable) {
|
||||
t.Fatal("a Secret in another namespace satisfied the reference")
|
||||
}
|
||||
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsUnavailable) {
|
||||
t.Fatal("missing Secret did not fail closed")
|
||||
}
|
||||
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
if _, err := fixture.deniedReader.Read(fixture.ctx, reference); !errors.Is(err, application.ErrCredentialsUnavailable) {
|
||||
t.Fatal("API server did not enforce Secret RBAC")
|
||||
}
|
||||
fixture.observeVersion(t)
|
||||
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
delete(secret.Data, "credential")
|
||||
})
|
||||
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsInvalid) {
|
||||
t.Fatal("missing credential field reused a cached connection")
|
||||
}
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["credential"] = []byte(fixturePassword)
|
||||
})
|
||||
fixture.observeVersion(t)
|
||||
|
||||
err := fixture.client.CoreV1().Secrets(controllerNamespace).Delete(fixture.ctx, secretName, metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
t.Fatal("cannot delete fixture Secret")
|
||||
}
|
||||
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrCredentialsUnavailable) {
|
||||
t.Fatal("deleted Secret retained access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveCredentialChangesReplaceConnection(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
fixture.observeVersion(t)
|
||||
originalBackend := fixture.backendIDs(t)
|
||||
if originalBackend == "" {
|
||||
t.Fatal("management connection not visible in PostgreSQL")
|
||||
}
|
||||
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Labels = map[string]string{"changed": "true"}
|
||||
secret.Data["unrelated"] = []byte("ignored")
|
||||
})
|
||||
fixture.observeVersion(t)
|
||||
if fixture.backendIDs(t) != originalBackend {
|
||||
t.Fatal("metadata or unrelated fields rebuilt the connection")
|
||||
}
|
||||
|
||||
// 先改变 Secret、暂不改变服务器密码:旧连接必须失效,新认证必须失败。
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["credential"] = []byte(rotatedPassword)
|
||||
})
|
||||
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
|
||||
if !errors.Is(err, application.ErrAuthentication) || version != "" {
|
||||
t.Fatal("old connection bypassed changed credentials")
|
||||
}
|
||||
|
||||
fixture.queryPostgres(t, "ALTER ROLE postgres PASSWORD '"+rotatedPassword+"'")
|
||||
fixture.observeVersion(t)
|
||||
if fixture.backendIDs(t) == originalBackend {
|
||||
t.Fatal("password rotation reused the old backend")
|
||||
}
|
||||
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["login"] = []byte("nonexistent")
|
||||
})
|
||||
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrAuthentication) {
|
||||
t.Fatal("username change did not require a new authentication")
|
||||
}
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["login"] = []byte(fixtureUser)
|
||||
})
|
||||
fixture.observeVersion(t)
|
||||
}
|
||||
|
||||
func TestObservationDiscardsResultWhenCredentialsChange(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
|
||||
reads := 0
|
||||
fixture.gate.beforeRead = func() {
|
||||
reads++
|
||||
if reads == 2 {
|
||||
fixture.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["credential"] = []byte(rotatedPassword)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
observation, err := fixture.service.ObserveMetadata(fixture.ctx, fixture.target)
|
||||
if !errors.Is(err, application.ErrCredentialsChanged) {
|
||||
t.Fatal("in-flight rotation was not detected")
|
||||
}
|
||||
if observation.Version() != "" {
|
||||
t.Fatal("observation returned data obtained with stale credentials")
|
||||
}
|
||||
requested := instance.NewExtensionSet([]string{fixtureExtension})
|
||||
if observation.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatal("observation returned extension support obtained with stale credentials")
|
||||
}
|
||||
if fixture.backendIDs(t) != "" {
|
||||
t.Fatal("stale connection was retained after rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionReleaseAndServiceRestart(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
fixture.observeVersion(t)
|
||||
|
||||
fixture.service.Forget(fixture.target.Identity().Name())
|
||||
if fixture.backendIDs(t) != "" {
|
||||
t.Fatal("Forget retained a connection")
|
||||
}
|
||||
fixture.observeVersion(t)
|
||||
|
||||
fixture.service.Close()
|
||||
fixture.service.Close()
|
||||
if _, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target); !errors.Is(err, application.ErrClosed) {
|
||||
t.Fatal("closed service accepted work")
|
||||
}
|
||||
|
||||
restarted, err := application.NewInstanceService(fixture.reader, postgresql.Connector{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(restarted.Close)
|
||||
if _, err := restarted.ObserveVersion(fixture.ctx, fixture.target); err != nil {
|
||||
t.Fatal("new service could not recover from stored Secret", err)
|
||||
}
|
||||
|
||||
// 此 fixture 未启用 TLS;各加密模式均不得偷偷回退到明文连接。
|
||||
for _, mode := range []instance.TLSMode{instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull} {
|
||||
securedTarget := target(t, fixture.port, mode)
|
||||
if _, err := restarted.ObserveVersion(fixture.ctx, securedTarget); err == nil {
|
||||
t.Fatal("TLS policy silently downgraded to plaintext")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentVersionObservations(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
|
||||
var workers sync.WaitGroup
|
||||
for range 4 {
|
||||
workers.Go(func() {
|
||||
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
|
||||
if err != nil || version == "" {
|
||||
t.Error("concurrent observation failed", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
workers.Go(func() {
|
||||
fixture.service.Forget(fixture.target.Identity().Name())
|
||||
})
|
||||
workers.Wait()
|
||||
fixture.observeVersion(t)
|
||||
}
|
||||
|
||||
func TestManagementConnectionRecoversAfterTimeout(t *testing.T) {
|
||||
fixture := newCredentialFixture(t)
|
||||
fixture.createSecret(t, controllerNamespace)
|
||||
fixture.observeVersion(t)
|
||||
|
||||
if err := exec.CommandContext(fixture.ctx, "docker", "pause", fixture.containerID).Run(); err != nil {
|
||||
t.Fatal("cannot pause isolated PostgreSQL fixture")
|
||||
}
|
||||
// 即使断言失败,也先恢复容器,再由 fixture 按原 ID 清理。
|
||||
t.Cleanup(func() {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = exec.CommandContext(cleanupContext, "docker", "unpause", fixture.containerID).Run()
|
||||
})
|
||||
|
||||
queryContext, cancel := context.WithTimeout(fixture.ctx, 500*time.Millisecond)
|
||||
version, err := fixture.service.ObserveVersion(queryContext, fixture.target)
|
||||
cancel()
|
||||
if err == nil || version != "" {
|
||||
t.Fatal("timed out PostgreSQL observation returned a successful result")
|
||||
}
|
||||
|
||||
if err := exec.CommandContext(fixture.ctx, "docker", "unpause", fixture.containerID).Run(); err != nil {
|
||||
t.Fatal("cannot resume isolated PostgreSQL fixture")
|
||||
}
|
||||
fixture.observeVersion(t)
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
|
||||
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureAddress = "127.0.0.1"
|
||||
managementUsernameKey = "login"
|
||||
managementPasswordKey = "credential"
|
||||
unrelatedNamespace = "unrelated"
|
||||
fixtureHost = "fixture.invalid"
|
||||
fixtureUser = "postgres"
|
||||
fixtureExtension = "plpgsql"
|
||||
dockerExec = "exec"
|
||||
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
|
||||
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
|
||||
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
|
||||
controllerNamespace = "database-controller"
|
||||
secretName = "management"
|
||||
)
|
||||
|
||||
// fixture 不接受外部 DSN,只创建自己的临时容器并按确切 ID 清理。
|
||||
func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
|
||||
t.Helper()
|
||||
output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::5432",
|
||||
"-e", "POSTGRES_PASSWORD="+fixturePassword, fixtureImage).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("cannot start isolated PostgreSQL fixture: %s", fixtureCommandError(err))
|
||||
}
|
||||
id := strings.TrimSpace(string(output))
|
||||
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
|
||||
t.Fatal("unexpected container identifier")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
|
||||
t.Error("fixture cleanup failed")
|
||||
}
|
||||
})
|
||||
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", `{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}`, id).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("cannot inspect fixture port: %s", fixtureCommandError(err))
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
if err != nil {
|
||||
t.Fatal("invalid fixture port")
|
||||
}
|
||||
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
|
||||
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", fixtureAddress, "-U", fixtureUser).Run() != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("fixture startup timed out")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return id, port
|
||||
}
|
||||
|
||||
// Output 将 stderr 保存在 ExitError 中;保留诊断,但不打印命令参数和测试密码。
|
||||
func fixtureCommandError(err error) string {
|
||||
detail := err.Error()
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
redactor := strings.NewReplacer(
|
||||
fixturePassword, "[REDACTED]",
|
||||
rotatedPassword, "[REDACTED]",
|
||||
)
|
||||
return redactor.Replace(detail)
|
||||
}
|
||||
|
||||
func TestFixtureCommandErrorPreservesDiagnosticsAndRedactsPasswords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing docker executable",
|
||||
err: &exec.Error{Name: "docker", Err: exec.ErrNotFound},
|
||||
want: "executable file not found",
|
||||
},
|
||||
{
|
||||
name: "daemon failure from stderr",
|
||||
err: &exec.ExitError{Stderr: []byte("Cannot connect to the Docker daemon")},
|
||||
want: "Cannot connect to the Docker daemon",
|
||||
},
|
||||
{
|
||||
name: "passwords in stderr",
|
||||
err: &exec.ExitError{Stderr: []byte("failure: " + fixturePassword + " " + rotatedPassword)},
|
||||
want: "failure: [REDACTED] [REDACTED]",
|
||||
},
|
||||
{
|
||||
name: "password in error text",
|
||||
err: errors.New("failure: " + fixturePassword),
|
||||
want: "failure: [REDACTED]",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
detail := fixtureCommandError(tt.err)
|
||||
if !strings.Contains(detail, tt.want) {
|
||||
t.Fatalf("diagnostic lost expected information: %q", tt.want)
|
||||
}
|
||||
if strings.Contains(detail, fixturePassword) || strings.Contains(detail, rotatedPassword) {
|
||||
t.Fatal("diagnostic exposed a fixture password")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationTarget {
|
||||
t.Helper()
|
||||
id, err := instance.NewIdentity("fixture-uid", "fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revision, err := instance.NewRevision(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: fixtureHost,
|
||||
HostAddr: fixtureAddress,
|
||||
Port: port,
|
||||
ManagementDatabase: fixtureUser,
|
||||
TLSMode: mode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
|
||||
Name: secretName,
|
||||
UsernameKey: managementUsernameKey,
|
||||
PasswordKey: managementPasswordKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := instance.NewDefinition(endpoint, ref)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := instance.NewObservationTarget(id, revision, definition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// 在真实读取前设置屏障,确定性验证观测期间 Secret 变化;实际数据仍来自 API server。
|
||||
type gatedReader struct {
|
||||
application.CredentialReader
|
||||
beforeRead func()
|
||||
}
|
||||
|
||||
func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
|
||||
if r.beforeRead != nil {
|
||||
r.beforeRead()
|
||||
}
|
||||
return r.CredentialReader.Read(ctx, ref)
|
||||
}
|
||||
|
||||
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
|
||||
type credentialFixture struct {
|
||||
config *rest.Config
|
||||
ctx context.Context
|
||||
client *kubernetes.Clientset
|
||||
reader *secretadapter.SecretCredentials
|
||||
deniedReader *secretadapter.SecretCredentials
|
||||
gate *gatedReader
|
||||
service *application.InstanceService
|
||||
target instance.ObservationTarget
|
||||
containerID string
|
||||
port int
|
||||
}
|
||||
|
||||
func newCredentialFixture(t *testing.T) *credentialFixture {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
environment := &envtest.Environment{}
|
||||
config, err := environment.Start()
|
||||
if err != nil {
|
||||
t.Fatal("envtest startup failed", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := environment.Stop(); err != nil {
|
||||
t.Error("envtest cleanup failed", err)
|
||||
}
|
||||
})
|
||||
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
t.Fatal("cannot create test client")
|
||||
}
|
||||
for _, namespace := range []string{controllerNamespace, unrelatedNamespace} {
|
||||
_, err := client.CoreV1().Namespaces().Create(
|
||||
ctx,
|
||||
&corev1.Namespace{Name: namespace},
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal("cannot create fixture namespace")
|
||||
}
|
||||
}
|
||||
|
||||
reader, err := secretadapter.NewSecretCredentials(config, controllerNamespace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := environment.AddUser(envtest.User{Name: "without-secret-access"}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deniedReader, err := secretadapter.NewSecretCredentials(user.Config(), controllerNamespace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
containerID, port := postgresFixture(t, ctx)
|
||||
gate := &gatedReader{CredentialReader: reader}
|
||||
service, err := application.NewInstanceService(gate, postgresql.Connector{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(service.Close)
|
||||
|
||||
return &credentialFixture{
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
reader: reader,
|
||||
deniedReader: deniedReader,
|
||||
gate: gate,
|
||||
service: service,
|
||||
target: target(t, port, instance.TLSDisable),
|
||||
containerID: containerID,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
|
||||
t.Helper()
|
||||
secret := &corev1.Secret{
|
||||
Name: secretName,
|
||||
Data: map[string][]byte{
|
||||
managementUsernameKey: []byte(fixtureUser),
|
||||
managementPasswordKey: []byte(fixturePassword),
|
||||
},
|
||||
}
|
||||
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||
t.Fatal("cannot create fixture Secret")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *credentialFixture) updateSecret(t *testing.T, change func(*corev1.Secret)) {
|
||||
t.Helper()
|
||||
secrets := f.client.CoreV1().Secrets(controllerNamespace)
|
||||
secret, err := secrets.Get(f.ctx, secretName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatal("cannot read fixture Secret")
|
||||
}
|
||||
change(secret)
|
||||
if _, err := secrets.Update(f.ctx, secret, metav1.UpdateOptions{}); err != nil {
|
||||
t.Fatal("cannot update fixture Secret")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *credentialFixture) observeVersion(t *testing.T) {
|
||||
t.Helper()
|
||||
version, err := f.service.ObserveVersion(f.ctx, f.target)
|
||||
if err != nil {
|
||||
t.Fatal("version observation failed", err)
|
||||
}
|
||||
if version == "" {
|
||||
t.Fatal("successful observation returned an empty version")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *credentialFixture) queryPostgres(t *testing.T, sql string) string {
|
||||
t.Helper()
|
||||
output, err := exec.CommandContext(
|
||||
f.ctx, "docker", dockerExec, f.containerID,
|
||||
"psql", "-U", fixtureUser, "-tAc", sql,
|
||||
).Output()
|
||||
if err != nil {
|
||||
t.Fatal("fixture SQL failed")
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func (f *credentialFixture) backendIDs(t *testing.T) string {
|
||||
t.Helper()
|
||||
return f.queryPostgres(t, `
|
||||
SELECT pid
|
||||
FROM pg_stat_activity
|
||||
WHERE application_name = 'ayatori-database-management'
|
||||
ORDER BY pid
|
||||
`)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
|
||||
"k8s.io/client-go/rest"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
const watchRevisionAnnotation = "test.ayatori/observation"
|
||||
|
||||
func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
if _, err := envtest.InstallCRDs(f.config, envtest.CRDInstallOptions{
|
||||
Paths: []string{"../../../../config/crd/bases"}, ErrorIfPathMissing: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scheme := runtime.NewScheme()
|
||||
for _, install := range []func(*runtime.Scheme) error{databasev1alpha1.AddToScheme, corev1.AddToScheme, rbacv1.AddToScheme} {
|
||||
if err := install(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
apiClient, err := client.New(f.config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted := instanceControllerRBAC(t, f, apiClient)
|
||||
credentials, err := secretadapter.NewSecretCredentials(restricted, controllerNamespace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := application.NewInstanceService(credentials, postgresql.Connector{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同进程 -count 重复启动测试 manager;生产继续校验 controller 名称唯一。
|
||||
skipRepeatedName := true
|
||||
manager, err := ctrl.NewManager(restricted, ctrl.Options{
|
||||
Scheme: scheme, Cache: databasecontroller.InstanceCacheOptions(controllerNamespace),
|
||||
Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
|
||||
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedName},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
|
||||
if err := reconciler.SetupWithManager(manager); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerContext, stop := context.WithCancel(f.ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- manager.Start(managerContext) }()
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Error("Instance manager 未停止")
|
||||
}
|
||||
service.Close()
|
||||
})
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
object.Name = "native-instance"
|
||||
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{
|
||||
Host: fixtureHost, HostAddr: fixtureAddress, Port: int32(f.port), SSLMode: "disable",
|
||||
}
|
||||
object.Spec.AdminCredentialRef = databasev1alpha1.AdminCredentialReference{
|
||||
Name: secretName, UsernameKey: managementUsernameKey, PasswordKey: managementPasswordKey,
|
||||
}
|
||||
if err := apiClient.Create(f.ctx, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
|
||||
// 30 秒轮询前必须收到 Secret 创建事件;实际 controller 使用 namespace Role + metadata watch。
|
||||
useNativeManager(t, f)
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
before := f.backendIDs(t)
|
||||
f.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Annotations = map[string]string{watchRevisionAnnotation: "changed"}
|
||||
})
|
||||
// 用实际 API 事件触发重验,metadata 改动不应换池。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if f.backendIDs(t) != before {
|
||||
t.Fatal("无关 Secret metadata 修改重建了连接")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager PASSWORD '"+rotatedPassword+"'")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte("invalid-test-password") })
|
||||
awaitInstanceReason(t, f, apiClient, object, "AuthenticationFailed")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
if f.backendIDs(t) == before {
|
||||
t.Fatal("凭据轮换没有替换旧连接")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager NOCREATEROLE")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recheck" })
|
||||
awaitInstanceReason(t, f, apiClient, object, "InsufficientPrivileges")
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recovered" })
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
if err := f.client.CoreV1().Secrets(controllerNamespace).Delete(f.ctx, secretName, metav1.DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
|
||||
if f.backendIDs(t) != "" {
|
||||
t.Fatal("Secret 删除后旧连接未释放")
|
||||
}
|
||||
}
|
||||
|
||||
func awaitInstanceReason(t *testing.T, f *credentialFixture, apiClient client.Client,
|
||||
object *databasev1alpha1.PostgreSQLInstance, reason string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := apiClient.Get(f.ctx, client.ObjectKeyFromObject(object), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
|
||||
if condition != nil && condition.Reason == reason && condition.ObservedGeneration == object.Generation {
|
||||
if (condition.Status == metav1.ConditionTrue) != (reason == "ManagementReady") {
|
||||
t.Fatal("Ready 与检查结果不一致")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watch 未及时推进到 %s", reason)
|
||||
}
|
||||
|
||||
func instanceControllerRBAC(t *testing.T, f *credentialFixture, apiClient client.Client) *rest.Config {
|
||||
t.Helper()
|
||||
roleBytes, err := os.ReadFile("../../../../config/rbac/role.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := &rbacv1.ClusterRole{}
|
||||
if err := yaml.Unmarshal(roleBytes, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := apiClient.Create(f.ctx, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := "instance-controller-test"
|
||||
binding := &rbacv1.ClusterRoleBinding{}
|
||||
binding.Name = user
|
||||
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
|
||||
binding.Subjects = []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}}
|
||||
if err := apiClient.Create(f.ctx, binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentialBytes, err := os.ReadFile("../../../../config/rbac/database_credentials_role.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceRole := &rbacv1.Role{}
|
||||
decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(credentialBytes), 4096)
|
||||
if err := decoder.Decode(namespaceRole); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceRole.Namespace = controllerNamespace
|
||||
if err := apiClient.Create(f.ctx, namespaceRole); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceBinding := &rbacv1.RoleBinding{}
|
||||
namespaceBinding.Name, namespaceBinding.Namespace = user, controllerNamespace
|
||||
namespaceBinding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: namespaceRole.Name}
|
||||
namespaceBinding.Subjects = binding.Subjects
|
||||
if err := apiClient.Create(f.ctx, namespaceBinding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := rest.CopyConfig(f.config)
|
||||
config.Impersonate.UserName = user
|
||||
restrictedClient, err := client.New(config, client.Options{Scheme: apiClient.Scheme()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := &corev1.Secret{}
|
||||
err = restrictedClient.Get(f.ctx, client.ObjectKey{Namespace: unrelatedNamespace, Name: secretName}, secret)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatal("Instance controller 可以跨 namespace 读取 Secret")
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// 只读取当前执行角色的属性,不能从可继承的角色成员关系推导 CREATEDB/CREATEROLE。
|
||||
// 所有事实来自同一条语句;不创建探针数据库,不修改管理账号或持久 schema。
|
||||
const inspectManagementStatement = `
|
||||
SELECT
|
||||
pg_catalog.current_setting('server_version'),
|
||||
ARRAY(SELECT name::text FROM pg_catalog.pg_available_extensions ORDER BY name),
|
||||
role.rolsuper,
|
||||
role.rolcreaterole,
|
||||
role.rolcreatedb,
|
||||
pg_catalog.pg_is_in_recovery() OR
|
||||
pg_catalog.current_setting('transaction_read_only')::boolean
|
||||
FROM pg_catalog.pg_roles AS role
|
||||
WHERE role.rolname = current_user`
|
||||
|
||||
func (d *database) InspectManagement(ctx context.Context) (application.DatabaseMetadata, error) {
|
||||
var metadata application.DatabaseMetadata
|
||||
var superuser, createRole, createDatabase, readOnly bool
|
||||
err := d.pool.QueryRow(ctx, inspectManagementStatement).Scan(
|
||||
&metadata.Version, &metadata.AvailableExtensions,
|
||||
&superuser, &createRole, &createDatabase, &readOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
|
||||
}
|
||||
checks := instance.ManagementChecks{
|
||||
Connection: instance.CheckPassed,
|
||||
Metadata: instance.CheckPassed,
|
||||
Roles: nativePrivilege(createRole && !superuser),
|
||||
Databases: nativePrivilege(createDatabase && !superuser),
|
||||
// CREATEROLE 可管理自己新建角色的 membership;供应时必须显式取得 SET 权限,
|
||||
// 再以 owner 操作数据库 ACL。这里不授权操作任意导入角色或他人数据库。
|
||||
Grants: nativePrivilege(createRole && createDatabase && !superuser),
|
||||
// 新建数据库 owner 可安装 trusted 扩展。具体扩展仍需逐请求执行和回读,
|
||||
// 非 trusted 扩展不能因出现在 available 列表就视为可安装。
|
||||
Extensions: nativePrivilege(createRole && createDatabase && !superuser),
|
||||
}
|
||||
if readOnly {
|
||||
checks.Databases = instance.CheckUnavailable
|
||||
}
|
||||
metadata.Management = checks
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func nativePrivilege(allowed bool) instance.CheckResult {
|
||||
if allowed {
|
||||
return instance.CheckPassed
|
||||
}
|
||||
return instance.CheckInsufficientPrivileges
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const nativeManager = "native_manager"
|
||||
|
||||
func useNativeManager(t *testing.T, f *credentialFixture) {
|
||||
t.Helper()
|
||||
f.queryPostgres(t, "CREATE ROLE native_manager LOGIN CREATEDB CREATEROLE PASSWORD '"+fixturePassword+"'")
|
||||
f.createSecret(t, controllerNamespace)
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(nativeManager) })
|
||||
}
|
||||
|
||||
func assessManagement(t *testing.T, f *credentialFixture) instance.Snapshot {
|
||||
t.Helper()
|
||||
observation, err := f.service.ObserveManagement(f.ctx, f.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.BeginValidation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities, err := observation.Capabilities()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.AssessManagement(capabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return aggregate.Snapshot()
|
||||
}
|
||||
|
||||
func TestNativeManagementPrivileges(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
useNativeManager(t, f)
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("原生非 superuser 管理账号未通过检查")
|
||||
}
|
||||
before := f.backendIDs(t)
|
||||
for _, attribute := range []string{"NOCREATEROLE", "NOCREATEDB"} {
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager "+attribute)
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
|
||||
t.Fatal("已有连接忽略了管理权限撤回")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE CREATEDB")
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("管理权限恢复后无法重新就绪")
|
||||
}
|
||||
}
|
||||
if f.backendIDs(t) != before {
|
||||
t.Fatal("权限检查不应要求重建连接才生效")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager SET default_transaction_read_only = on")
|
||||
f.service.Forget(f.target.Identity().Name())
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.DependencyUnavailable {
|
||||
t.Fatal("只读会话不应标记可供应")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager RESET default_transaction_read_only")
|
||||
f.service.Forget(f.target.Identity().Name())
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("恢复可写会话后没有就绪")
|
||||
}
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(fixtureUser) })
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
|
||||
t.Fatal("不应以 superuser 绕过非特权账号合同")
|
||||
}
|
||||
reads := 0
|
||||
f.gate.beforeRead = func() {
|
||||
reads++
|
||||
if reads == 2 {
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
|
||||
}
|
||||
}
|
||||
observation, err := f.service.ObserveManagement(f.ctx, f.target)
|
||||
if !errors.Is(err, application.ErrCredentialsChanged) || observation.Target().Validate() == nil {
|
||||
t.Fatal("管理观察期间凭据轮换应丢弃全部能力结果")
|
||||
}
|
||||
if f.backendIDs(t) != "" {
|
||||
t.Fatal("中途轮换后不应保留旧管理连接")
|
||||
}
|
||||
}
|
||||
|
||||
// 以实际非 superuser 会话验证能力矩阵的依据,不用超级用户执行 SQL 模拟管理账号。
|
||||
// 这些固定名称只存在于本测试独占容器,生产观察本身不会创建探针对象。
|
||||
func TestNativeManagementSupplyContract(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
useNativeManager(t, f)
|
||||
config, err := pgx.ParseConfig("")
|
||||
if err != nil {
|
||||
t.Fatal("无法装配隔离测试连接")
|
||||
}
|
||||
config.Host, config.Port = fixtureAddress, uint16(f.port)
|
||||
config.Database, config.User, config.Password = fixtureUser, nativeManager, fixturePassword
|
||||
config.TLSConfig, config.Fallbacks = nil, nil
|
||||
connection, err := pgx.ConnectConfig(f.ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal("非 superuser 测试连接失败")
|
||||
}
|
||||
t.Cleanup(func() { _ = connection.Close(context.Background()) })
|
||||
execute := func(statement string) {
|
||||
t.Helper()
|
||||
if _, err := connection.Exec(f.ctx, statement); err != nil {
|
||||
t.Fatalf("原生管理能力合同未满足,步骤 %q", statement)
|
||||
}
|
||||
}
|
||||
execute("CREATE ROLE managed_owner LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION")
|
||||
execute("GRANT managed_owner TO native_manager WITH SET TRUE")
|
||||
execute("CREATE DATABASE managed_database OWNER managed_owner")
|
||||
execute("SET ROLE managed_owner")
|
||||
execute("REVOKE CONNECT ON DATABASE managed_database FROM PUBLIC")
|
||||
execute("GRANT CONNECT ON DATABASE managed_database TO managed_owner")
|
||||
execute("RESET ROLE")
|
||||
config.Database = "managed_database"
|
||||
tenantConnection, err := pgx.ConnectConfig(f.ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal("管理账号无法访问其受管数据库")
|
||||
}
|
||||
defer func() { _ = tenantConnection.Close(context.Background()) }()
|
||||
if _, err := tenantConnection.Exec(f.ctx, "SET ROLE managed_owner; CREATE EXTENSION hstore"); err != nil {
|
||||
t.Fatal("owner 无法安装 trusted 扩展")
|
||||
}
|
||||
var installed bool
|
||||
if err := tenantConnection.QueryRow(f.ctx, "SELECT EXISTS (SELECT FROM pg_catalog.pg_extension WHERE extname = 'hstore')").Scan(&installed); err != nil || !installed {
|
||||
t.Fatal("扩展安装后实际回读失败")
|
||||
}
|
||||
if _, err := tenantConnection.Exec(f.ctx, "CREATE EXTENSION file_fdw"); err == nil {
|
||||
t.Fatal("非 trusted 扩展不应被 Ready 隐式授权")
|
||||
}
|
||||
if err := tenantConnection.Close(f.ctx); err != nil {
|
||||
t.Fatal("关闭目标数据库连接失败")
|
||||
}
|
||||
execute("SET ROLE managed_owner")
|
||||
execute("DROP DATABASE managed_database")
|
||||
execute("RESET ROLE")
|
||||
execute("DROP ROLE managed_owner")
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
)
|
||||
|
||||
// 使用 pg_catalog 限定名称,避免管理账号的 search_path 改变查询来源。
|
||||
// 一个语句读取版本和可用列表;ARRAY 子查询在无行时返回空数组,而非 NULL。
|
||||
// 不查询 pg_extension:已安装集合不能代表服务器提供的全部扩展。
|
||||
const inspectMetadataStatement = `
|
||||
SELECT
|
||||
pg_catalog.current_setting('server_version'),
|
||||
ARRAY(
|
||||
SELECT name::text
|
||||
FROM pg_catalog.pg_available_extensions
|
||||
ORDER BY name
|
||||
)`
|
||||
|
||||
func (d *database) InspectMetadata(ctx context.Context) (application.DatabaseMetadata, error) {
|
||||
var metadata application.DatabaseMetadata
|
||||
err := d.pool.QueryRow(ctx, inspectMetadataStatement).Scan(
|
||||
&metadata.Version,
|
||||
&metadata.AvailableExtensions,
|
||||
)
|
||||
if err != nil {
|
||||
// 不返回部分结果,也不把查询失败转换为“成功观察到空列表”。
|
||||
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func TestMetadataObservesAvailableExtensionsWithoutInstalling(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
f.createSecret(t, controllerNamespace)
|
||||
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
|
||||
t.Fatal("fixture unexpectedly has hstore installed")
|
||||
}
|
||||
// 提供同名遮蔽对象,验证 adapter 不依赖管理账号可修改的 search_path。
|
||||
f.queryPostgres(t, "CREATE VIEW public.pg_available_extensions AS SELECT 'fake_extension'::name AS name")
|
||||
f.queryPostgres(t, "ALTER ROLE postgres SET search_path = public, pg_catalog")
|
||||
schemasBefore := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace")
|
||||
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !observed.Target().Matches(f.target) || observed.Version() == "" {
|
||||
t.Fatal("metadata was not bound to the current target")
|
||||
}
|
||||
requested := instance.NewExtensionSet([]string{"hstore", fixtureExtension})
|
||||
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("available but uninstalled extension was omitted")
|
||||
}
|
||||
unsupported := instance.NewExtensionSet([]string{"fake_extension", "HSTORE"})
|
||||
check := observed.Extensions().Check(unsupported)
|
||||
if check.Decision != instance.ExtensionsUnsupported || len(check.Unsupported) != 2 {
|
||||
t.Fatal("metadata accepted shadowed or case-normalized extension names")
|
||||
}
|
||||
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
|
||||
t.Fatal("metadata observation installed an extension")
|
||||
}
|
||||
if schemas := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace"); schemas != schemasBefore {
|
||||
t.Fatal("metadata observation changed database schemas")
|
||||
}
|
||||
|
||||
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.ObserveExtensions(observed.Target(), observed.Extensions()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if aggregate.CheckExtensions(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("domain rejected observed extension availability")
|
||||
}
|
||||
if err := aggregate.RequireProvisioningReady(); err == nil {
|
||||
t.Fatal("extension availability incorrectly authorized provisioning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataPermissionFailureAndRecovery(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
f.createSecret(t, controllerNamespace)
|
||||
// 低权限账号也能读取可用列表;这不能证明具备 role/database/extension 管理权限。
|
||||
f.queryPostgres(t, "CREATE ROLE metadata_reader LOGIN PASSWORD '"+fixturePassword+"'")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Data["login"] = []byte("metadata_reader")
|
||||
})
|
||||
if flags := f.queryPostgres(t, "SELECT rolsuper, rolcreaterole, rolcreatedb FROM pg_catalog.pg_roles WHERE rolname = 'metadata_reader'"); flags != "f|f|f" {
|
||||
t.Fatal("metadata reader unexpectedly has management privileges")
|
||||
}
|
||||
requested := instance.NewExtensionSet([]string{fixtureExtension})
|
||||
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
|
||||
if err != nil || observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatalf("read-only account could not observe metadata: %v", err)
|
||||
}
|
||||
|
||||
// 仅操作本测试独占容器的 catalog ACL;失败不能转换成“不支持任何扩展”。
|
||||
f.queryPostgres(t, "REVOKE SELECT ON pg_catalog.pg_available_extensions FROM PUBLIC")
|
||||
failed, err := f.service.ObserveMetadata(f.ctx, f.target)
|
||||
if !errors.Is(err, application.ErrObservation) {
|
||||
t.Fatalf("metadata permission failure was not reported safely: %v", err)
|
||||
}
|
||||
if failed.Version() != "" || failed.Target().Validate() == nil {
|
||||
t.Fatal("permission failure returned partial metadata")
|
||||
}
|
||||
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatal("permission failure returned an observed empty set")
|
||||
}
|
||||
if f.backendIDs(t) != "" {
|
||||
t.Fatal("failed metadata connection was retained")
|
||||
}
|
||||
f.queryPostgres(t, "GRANT SELECT ON pg_catalog.pg_available_extensions TO PUBLIC")
|
||||
recovered, err := f.service.ObserveMetadata(f.ctx, f.target)
|
||||
if err != nil || recovered.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatalf("metadata observation did not recover: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func fixtureCertificate(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: fixtureHost},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
DNSNames: []string{fixtureHost},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedKey, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "server.crt")
|
||||
keyPath := filepath.Join(dir, "server.key")
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: encodedKey}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
func TestPostgreSQLTLSHostIdentity(t *testing.T) {
|
||||
const psql = "psql"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
id, port := postgresFixture(t, ctx)
|
||||
certPath, keyPath := fixtureCertificate(t)
|
||||
commands := [][]string{
|
||||
{"cp", certPath, id + ":/tmp/server.crt"},
|
||||
{"cp", keyPath, id + ":/tmp/server.key"},
|
||||
{dockerExec, "-u", "0", id, "chown", "postgres:postgres", "/tmp/server.crt", "/tmp/server.key"},
|
||||
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl_cert_file='/tmp/server.crt'"},
|
||||
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl_key_file='/tmp/server.key'"},
|
||||
{dockerExec, id, psql, "-U", fixtureUser, "-c", "ALTER SYSTEM SET ssl=on"},
|
||||
{dockerExec, id, psql, "-U", fixtureUser, "-c", "SELECT pg_reload_conf()"},
|
||||
}
|
||||
for _, args := range commands {
|
||||
if exec.CommandContext(ctx, "docker", args...).Run() != nil {
|
||||
t.Fatal("TLS fixture setup failed")
|
||||
}
|
||||
}
|
||||
credentials, err := application.NewCredentials(fixtureUser, fixturePassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connector := postgresql.Connector{RootCert: certPath}
|
||||
endpoint := target(t, port, instance.TLSVerifyFull).Definition().Endpoint()
|
||||
db, err := connector.Connect(ctx, endpoint, credentials)
|
||||
if err != nil {
|
||||
t.Fatal("trusted DNS SAN connection failed", err)
|
||||
}
|
||||
if metadata, err := db.InspectMetadata(ctx); err != nil || metadata.Version == "" {
|
||||
db.Close()
|
||||
t.Fatal("TLS metadata read failed", err)
|
||||
}
|
||||
db.Close()
|
||||
values := endpoint.Values()
|
||||
values.Host = "127.0.0.1"
|
||||
ipEndpoint, err := instance.NewEndpoint(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err = connector.Connect(ctx, ipEndpoint, credentials)
|
||||
if err != nil {
|
||||
t.Fatal("trusted IP SAN connection failed", err)
|
||||
}
|
||||
db.Close()
|
||||
values.Host = "wrong.invalid"
|
||||
wrongEndpoint, err := instance.NewEndpoint(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db, err := connector.Connect(ctx, wrongEndpoint, credentials); err == nil {
|
||||
db.Close()
|
||||
t.Fatal("wrong TLS hostname accepted")
|
||||
}
|
||||
otherCA, _ := fixtureCertificate(t)
|
||||
if db, err := (postgresql.Connector{RootCert: otherCA}).Connect(ctx, endpoint, credentials); err == nil {
|
||||
db.Close()
|
||||
t.Fatal("wrong CA accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
var ErrApplicationCredentialInvalid = errors.New("application credential is invalid")
|
||||
|
||||
var applicationIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
// ApplicationCredential 是内存中的应用连接凭据,不得放入 CR 或普通日志。
|
||||
// 它与 Instance 管理凭据分开,固定输出交付合同中的七键,不生成带密码的 URI。
|
||||
type ApplicationCredential struct {
|
||||
username string
|
||||
password string
|
||||
database string
|
||||
endpoint instance.Endpoint
|
||||
}
|
||||
|
||||
func NewApplicationCredential(username, password, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
|
||||
if !applicationIdentifier.MatchString(username) || !applicationIdentifier.MatchString(database) || password == "" {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
if endpoint.Validate() != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
return ApplicationCredential{
|
||||
username: username,
|
||||
password: password,
|
||||
database: database,
|
||||
endpoint: endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateApplicationCredential 仅供已获准首次创建凭据的供应步骤调用。
|
||||
// 不能在读取失败、写入结果不确定或重启后无条件重新调用。
|
||||
func GenerateApplicationCredential(username, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
|
||||
password := make([]byte, 32)
|
||||
rand.Read(password)
|
||||
return NewApplicationCredential(username, base64.RawURLEncoding.EncodeToString(password), database, endpoint)
|
||||
}
|
||||
|
||||
func (c ApplicationCredential) String() string { return "[redacted application credential]" }
|
||||
func (c ApplicationCredential) GoString() string { return c.String() }
|
||||
func (c ApplicationCredential) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"[redacted application credential]"`), nil
|
||||
}
|
||||
|
||||
// SecretData 只在凭据后端或数据库连接边界使用;返回值包含明文密码,禁止记录日志。
|
||||
// 每次返回独立 map,调用方不能修改已经构造的凭据。
|
||||
func (c ApplicationCredential) SecretData() map[string]any {
|
||||
endpoint := c.endpoint.Values()
|
||||
return map[string]any{
|
||||
"username": c.username,
|
||||
"password": c.password,
|
||||
"database": c.database,
|
||||
"host": endpoint.Host,
|
||||
"hostaddr": endpoint.HostAddr,
|
||||
"port": strconv.Itoa(endpoint.Port),
|
||||
"sslmode": string(endpoint.TLSMode),
|
||||
}
|
||||
}
|
||||
|
||||
func (c ApplicationCredential) Validate() error {
|
||||
_, err := NewApplicationCredential(c.username, c.password, c.database, c.endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
|
||||
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
|
||||
values := make(map[string]string, 7)
|
||||
for _, key := range []string{"username", "password", "database", "host", "hostaddr", "port", "sslmode"} {
|
||||
value, ok := data[key].(string)
|
||||
if !ok || value == "" {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
port, err := strconv.Atoi(values["port"])
|
||||
if err != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: values["host"],
|
||||
HostAddr: values["hostaddr"],
|
||||
Port: port,
|
||||
ManagementDatabase: values["database"],
|
||||
TLSMode: instance.TLSMode(values["sslmode"]),
|
||||
})
|
||||
if err != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
return NewApplicationCredential(values["username"], values["password"], values["database"], endpoint)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func TestApplicationCredential(t *testing.T) {
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432,
|
||||
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := application.GenerateApplicationCredential("owner", "app", endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := application.GenerateApplicationCredential("owner", "app", endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := first.SecretData()
|
||||
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
|
||||
t.Fatal("expected seven keys and independent 256-bit passwords")
|
||||
}
|
||||
parsed, err := application.ParseApplicationCredential(data)
|
||||
if err != nil || !maps.Equal(parsed.SecretData(), data) {
|
||||
t.Fatal("credential did not round trip")
|
||||
}
|
||||
encoded, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{fmt.Sprint(first), fmt.Sprintf("%+v", first), fmt.Sprintf("%#v", first), string(encoded)} {
|
||||
if strings.Contains(output, data["password"].(string)) {
|
||||
t.Fatal("credential formatting leaked the password")
|
||||
}
|
||||
}
|
||||
data["password"] = "changed"
|
||||
if first.SecretData()["password"] == "changed" {
|
||||
t.Fatal("caller mutated credential")
|
||||
}
|
||||
for key := range data {
|
||||
invalid := maps.Clone(data)
|
||||
delete(invalid, key)
|
||||
if _, err := application.ParseApplicationCredential(invalid); err == nil {
|
||||
t.Fatalf("accepted missing %s", key)
|
||||
}
|
||||
invalid[key] = 42
|
||||
if _, err := application.ParseApplicationCredential(invalid); err == nil {
|
||||
t.Fatalf("accepted non-string %s", key)
|
||||
}
|
||||
}
|
||||
if (application.ApplicationCredential{}).Validate() == nil {
|
||||
t.Fatal("accepted zero credential")
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
)
|
||||
|
||||
// 快照版本只用于协调读写,不进入领域规则。Generation 用于确认整个申请未在回读期间变化。
|
||||
type BindingTenant struct {
|
||||
binding.Tenant
|
||||
Revision string
|
||||
Generation int64
|
||||
}
|
||||
|
||||
type BindingDatabase struct {
|
||||
binding.Database
|
||||
Revision string
|
||||
}
|
||||
|
||||
// BindingResources 是这个用例所需的操作,不是通用 Repository 或跨系统事务接口。
|
||||
// 查不到对象时返回 nil;写入必须检查传入快照的版本,不能覆盖并发修改。
|
||||
type BindingResources interface {
|
||||
Tenant(context.Context, string, string) (*BindingTenant, error)
|
||||
Database(context.Context, string) (*BindingDatabase, error)
|
||||
Instance(context.Context, string) (*binding.Instance, error)
|
||||
BeginBinding(context.Context, *BindingTenant, *BindingStatus) (*BindingTenant, error)
|
||||
CreateDatabase(context.Context, binding.Target, binding.TenantIdentity) (*BindingDatabase, error)
|
||||
RecordInstance(context.Context, *BindingDatabase, string) (*BindingDatabase, error)
|
||||
BindDatabase(context.Context, *BindingDatabase, binding.TenantIdentity) (*BindingDatabase, error)
|
||||
}
|
||||
|
||||
// BindingStatus 是用例结果,资源呈现层决定如何写成 Conditions/status。
|
||||
type BindingStatus struct {
|
||||
Phase string
|
||||
Reason string
|
||||
Message string
|
||||
Database *binding.Identity
|
||||
}
|
||||
|
||||
type BindingResult struct {
|
||||
Tenant *BindingTenant
|
||||
Status BindingStatus
|
||||
RetrySoon bool
|
||||
}
|
||||
|
||||
type BindingService struct {
|
||||
Resources BindingResources
|
||||
}
|
||||
|
||||
func (s BindingService) Reconcile(ctx context.Context, namespace, name string) (BindingResult, error) {
|
||||
tenant, err := s.Resources.Tenant(ctx, namespace, name)
|
||||
if err != nil || tenant == nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
if tenant.Deleting {
|
||||
return bindingResult(tenant, binding.Deleting, "DeletionPending",
|
||||
"删除清理尚未接入;保留 finalizer 和 Database 绑定,未执行后端删除"), nil
|
||||
}
|
||||
target, err := tenant.Request.Resolve(tenant.Identity)
|
||||
if err != nil {
|
||||
return bindingResult(tenant, tenant.Phase, "InvalidRequest", err.Error()), nil
|
||||
}
|
||||
// 持久固定申请,再创建/绑定资源;不是预先宣告双向绑定成功。
|
||||
var checkpoint *BindingStatus
|
||||
if tenant.Phase != binding.Binding && tenant.Phase != binding.Bound {
|
||||
checkpoint = &BindingStatus{Phase: binding.Binding, Reason: "BindingPending", Message: "申请目标已固定,等待资源侧绑定"}
|
||||
}
|
||||
tenant, err = s.Resources.BeginBinding(ctx, tenant, checkpoint)
|
||||
if err != nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
database, issue, err := s.resolveDatabase(ctx, tenant, target)
|
||||
if err != nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
if issue != nil {
|
||||
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
|
||||
}
|
||||
if issue := database.CanBind(tenant.Tenant); issue != nil {
|
||||
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
|
||||
}
|
||||
database, err = s.Resources.BindDatabase(ctx, database, tenant.Identity)
|
||||
if err != nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
return s.confirmBinding(ctx, tenant, database)
|
||||
}
|
||||
|
||||
func (s BindingService) resolveDatabase(ctx context.Context, tenant *BindingTenant, target binding.Target) (
|
||||
*BindingDatabase, *binding.Issue, error,
|
||||
) {
|
||||
database, err := s.Resources.Database(ctx, target.Name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if database == nil && target.Provision == nil {
|
||||
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "指定的 Database 尚不存在,等待资源出现"}, nil
|
||||
}
|
||||
instanceName := ""
|
||||
var observed *binding.Database
|
||||
if database != nil {
|
||||
instanceName, observed = database.Instance, &database.Database
|
||||
}
|
||||
if target.Provision != nil {
|
||||
instanceName = target.Provision.Instance
|
||||
if database != nil && !database.MatchesProvision(target, tenant.Identity) {
|
||||
return nil, &binding.Issue{Reason: binding.Conflict,
|
||||
Message: "动态 Database 名称已存在,但目标或 Tenant UID 不匹配;请核实记录,未自动认领"}, nil
|
||||
}
|
||||
}
|
||||
instance, err := s.Resources.Instance(ctx, instanceName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if instance == nil {
|
||||
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "引用的 Instance 尚不存在"}, nil
|
||||
}
|
||||
if issue := instance.Check(observed); issue != nil {
|
||||
return nil, issue, nil
|
||||
}
|
||||
if database == nil {
|
||||
database, err = s.Resources.CreateDatabase(ctx, target, tenant.Identity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
// 只有首次动态记录可以补入实例身份;导入必须先有资源观察。
|
||||
if database.InstanceUID == "" && target.Provision != nil {
|
||||
database, err = s.Resources.RecordInstance(ctx, database, instance.Identity.UID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if database.InstanceUID == "" {
|
||||
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "Database 尚未完成实例身份验证"}, nil
|
||||
}
|
||||
return database, nil, nil
|
||||
}
|
||||
|
||||
func (s BindingService) confirmBinding(ctx context.Context, tenant *BindingTenant, database *BindingDatabase) (BindingResult, error) {
|
||||
latest, err := s.Resources.Tenant(ctx, tenant.Identity.Namespace, tenant.Identity.Name)
|
||||
if err != nil || latest == nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
if latest.Identity != tenant.Identity || latest.Deleting {
|
||||
return BindingResult{}, nil
|
||||
}
|
||||
if latest.Generation != tenant.Generation {
|
||||
return BindingResult{RetrySoon: true}, nil
|
||||
}
|
||||
observed, err := s.Resources.Database(ctx, database.Identity.Name)
|
||||
if err != nil {
|
||||
return BindingResult{}, err
|
||||
}
|
||||
if observed == nil || observed.Identity != database.Identity || observed.Tenant == nil {
|
||||
return bindingResult(latest, latest.Phase, binding.Conflict, "资源侧身份或绑定已变化,未完成申请侧绑定"), nil
|
||||
}
|
||||
if issue := observed.CanBind(latest.Tenant); issue != nil {
|
||||
return bindingResult(latest, latest.Phase, issue.Reason, issue.Message), nil
|
||||
}
|
||||
result := bindingResult(latest, binding.Bound, "BindingComplete",
|
||||
"双向绑定已记录;尚未执行供应、应用登录验证或凭据交付,不能 Ready")
|
||||
result.Status.Database = &observed.Identity
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func bindingResult(tenant *BindingTenant, phase, reason, message string) BindingResult {
|
||||
return BindingResult{Tenant: tenant, Status: BindingStatus{Phase: phase, Reason: reason, Message: message}}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
)
|
||||
|
||||
const (
|
||||
bindingReadTenant = "tenant"
|
||||
bindingBegin = "begin"
|
||||
bindingReadInstance = "instance"
|
||||
bindingCreate = "create"
|
||||
bindingRecordInstance = "record-instance"
|
||||
bindingWriteResource = "bind"
|
||||
bindingDatabaseOperation = "database"
|
||||
bindingTestNamespace = "apps"
|
||||
bindingTestName = "app"
|
||||
bindingTestInstance = "shared"
|
||||
)
|
||||
|
||||
func TestBindingServiceOrder(t *testing.T) {
|
||||
resources := bindingFixture()
|
||||
service := BindingService{Resources: resources}
|
||||
result, err := service.Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource, bindingReadTenant, bindingDatabaseOperation}
|
||||
if !reflect.DeepEqual(resources.calls, want) {
|
||||
t.Fatalf("协调顺序 = %v, want %v", resources.calls, want)
|
||||
}
|
||||
if result.Status.Phase != binding.Bound || result.Status.Database == nil || result.Status.Database.UID != "database-uid" {
|
||||
t.Fatalf("绑定结果不符: %+v", result.Status)
|
||||
}
|
||||
if resources.tenant.Phase != binding.Binding || resources.tenant.Database != nil {
|
||||
t.Fatal("service 只能返回待呈现结果,不能提前写申请侧绑定")
|
||||
}
|
||||
if resources.database.Tenant == nil || *resources.database.Tenant != resources.tenant.Identity {
|
||||
t.Fatal("返回完成结果之前必须写入资源侧绑定")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingServiceStopsOnIOFailure(t *testing.T) {
|
||||
for _, operation := range []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
resources := bindingFixture()
|
||||
resources.failAt = operation
|
||||
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
|
||||
if !errors.Is(err, errBindingTest) || result.Status.Database != nil {
|
||||
t.Fatalf("IO 失败不应被转换为绑定成功: result=%+v, err=%v", result, err)
|
||||
}
|
||||
if resources.calls[len(resources.calls)-1] != operation {
|
||||
t.Fatalf("失败后继续执行了操作: %v", resources.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingServiceDoesNotWriteWhenDeletingOrInvalid(t *testing.T) {
|
||||
for _, deleting := range []bool{false, true} {
|
||||
resources := bindingFixture()
|
||||
resources.tenant.Deleting = deleting
|
||||
resources.tenant.Request = binding.Request{}
|
||||
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
|
||||
if err != nil || len(resources.calls) != 1 || result.Status.Database != nil {
|
||||
t.Fatalf("删除或无效申请不应触及资源: calls=%v, err=%v", resources.calls, err)
|
||||
}
|
||||
want := "InvalidRequest"
|
||||
if deleting {
|
||||
want = "DeletionPending"
|
||||
}
|
||||
if result.Status.Reason != want {
|
||||
t.Fatalf("Reason = %s, want %s", result.Status.Reason, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingServiceConfirmsIdentityAgain(t *testing.T) {
|
||||
resources := bindingFixture()
|
||||
resources.replaceOnReadback = true
|
||||
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status.Reason != binding.Conflict || result.Status.Database != nil {
|
||||
t.Fatal("资源侧写入后发生身份变化时不能返回绑定完成")
|
||||
}
|
||||
}
|
||||
|
||||
// 这里只记录用例操作,不模拟 Kubernetes 校验;真实 IO 契约由 controller envtest 覆盖。
|
||||
type bindingTestResources struct {
|
||||
tenant *BindingTenant
|
||||
database *BindingDatabase
|
||||
instance *binding.Instance
|
||||
calls []string
|
||||
failAt string
|
||||
replaceOnReadback bool
|
||||
bound bool
|
||||
}
|
||||
|
||||
var errBindingTest = errors.New("injected resource operation failure")
|
||||
|
||||
func bindingFixture() *bindingTestResources {
|
||||
tenant := &BindingTenant{Generation: 1}
|
||||
tenant.Tenant = binding.Tenant{
|
||||
Identity: binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "tenant-uid"},
|
||||
Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: bindingTestInstance}},
|
||||
}
|
||||
return &bindingTestResources{
|
||||
tenant: tenant,
|
||||
instance: &binding.Instance{Identity: binding.Identity{Name: bindingTestInstance, UID: "instance-uid"}, Ready: true},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) record(operation string) error {
|
||||
r.calls = append(r.calls, operation)
|
||||
if r.failAt == operation {
|
||||
return errBindingTest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) Tenant(context.Context, string, string) (*BindingTenant, error) {
|
||||
return r.tenant, r.record(bindingReadTenant)
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) Database(context.Context, string) (*BindingDatabase, error) {
|
||||
if r.bound && r.replaceOnReadback {
|
||||
replaced := *r.database
|
||||
replaced.Identity.UID = "replacement"
|
||||
return &replaced, r.record(bindingDatabaseOperation)
|
||||
}
|
||||
return r.database, r.record(bindingDatabaseOperation)
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) Instance(context.Context, string) (*binding.Instance, error) {
|
||||
return r.instance, r.record(bindingReadInstance)
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) BeginBinding(_ context.Context, tenant *BindingTenant, status *BindingStatus) (*BindingTenant, error) {
|
||||
if err := r.record(bindingBegin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != nil {
|
||||
r.tenant.Phase = status.Phase
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) CreateDatabase(_ context.Context, target binding.Target, tenant binding.TenantIdentity) (*BindingDatabase, error) {
|
||||
if err := r.record(bindingCreate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.database = &BindingDatabase{}
|
||||
r.database.Database = binding.Database{
|
||||
Identity: binding.Identity{Name: target.Name, UID: "database-uid"}, Tenant: &tenant,
|
||||
Instance: target.Provision.Instance, Name: target.Provision.Database, LoginRole: target.Provision.LoginRole, Source: "Provision",
|
||||
}
|
||||
return r.database, nil
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) RecordInstance(_ context.Context, database *BindingDatabase, uid string) (*BindingDatabase, error) {
|
||||
if err := r.record(bindingRecordInstance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
database.InstanceUID = uid
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (r *bindingTestResources) BindDatabase(_ context.Context, database *BindingDatabase, tenant binding.TenantIdentity) (*BindingDatabase, error) {
|
||||
if err := r.record(bindingWriteResource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
database.Tenant = &tenant
|
||||
r.bound = true
|
||||
return database, nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package application 定义 Database 用例与适配器之间的边界。
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCredentialsUnavailable = errors.New("management credentials unavailable")
|
||||
ErrCredentialsInvalid = errors.New("management credentials invalid")
|
||||
)
|
||||
|
||||
// Credentials 只存在于应用与连接适配器内存,不进入领域对象或持久化状态。
|
||||
type Credentials struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func NewCredentials(username, password string) (Credentials, error) {
|
||||
if username == "" || password == "" {
|
||||
return Credentials{}, ErrCredentialsInvalid
|
||||
}
|
||||
return Credentials{username: username, password: password}, nil
|
||||
}
|
||||
|
||||
func (c Credentials) Username() string { return c.username }
|
||||
func (c Credentials) Password() string { return c.password }
|
||||
func (c Credentials) String() string { return "[redacted management credentials]" }
|
||||
func (c Credentials) GoString() string { return c.String() }
|
||||
|
||||
// MarshalJSON 显式隐藏内容,避免未来字段调整意外改变日志或序列化行为。
|
||||
func (c Credentials) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"[redacted management credentials]"`), nil
|
||||
}
|
||||
|
||||
// CredentialReader 返回本次读取的有效值;metadata 不参与凭据相等比较。
|
||||
type CredentialReader interface {
|
||||
Read(context.Context, instance.CredentialReference) (Credentials, error)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testUsername = "test-user"
|
||||
|
||||
func TestCredentialsRejectEmptyValues(t *testing.T) {
|
||||
for _, values := range [][2]string{
|
||||
{"", "test-password"},
|
||||
{testUsername, ""},
|
||||
{"", ""},
|
||||
} {
|
||||
if _, err := NewCredentials(values[0], values[1]); err != ErrCredentialsInvalid {
|
||||
t.Fatal("empty credential was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAndServiceFormattingIsRedacted(t *testing.T) {
|
||||
const canary = "SECRET-CANARY-never-log-this"
|
||||
credentials, err := NewCredentials(canary, canary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credentials.Username() != canary || credentials.Password() != canary {
|
||||
t.Fatal("explicit credential access changed values")
|
||||
}
|
||||
|
||||
service, err := NewInstanceService(&sourceStub{credentials: credentials}, &connectorStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(service.Close)
|
||||
|
||||
encoded, err := json.Marshal(credentials)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs := []string{
|
||||
string(encoded),
|
||||
fmt.Sprintf("%v %+v %#v", credentials, credentials, credentials),
|
||||
fmt.Sprintf("%v %+v %#v", service, service, service),
|
||||
}
|
||||
for _, output := range outputs {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatal("formatting leaked credential data")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const (
|
||||
instanceDependencyUnavailable = "DependencyUnavailable"
|
||||
instanceAuthenticationFailed = "AuthenticationFailed"
|
||||
)
|
||||
|
||||
// InstanceRecord 是 API 快照;Revision 仅用于持久化并发保护,不是领域版本。
|
||||
type InstanceRecord struct {
|
||||
Target instance.ObservationTarget
|
||||
Revision string
|
||||
Deleting bool
|
||||
}
|
||||
|
||||
type InstanceResources interface {
|
||||
LoadInstance(context.Context, string) (*InstanceRecord, error)
|
||||
ProtectInstance(context.Context, *InstanceRecord) (*InstanceRecord, error)
|
||||
// InstanceReferences 返回一个可定位的阻塞引用;空字符串表示没有引用。
|
||||
InstanceReferences(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
type InstanceObserver interface {
|
||||
ObserveManagement(context.Context, instance.ObservationTarget) (InstanceObservation, error)
|
||||
Forget(string)
|
||||
}
|
||||
|
||||
type InstanceResult struct {
|
||||
Record *InstanceRecord
|
||||
Snapshot instance.Snapshot
|
||||
Reason string
|
||||
Message string
|
||||
RemoveProtection bool
|
||||
}
|
||||
|
||||
// InstanceReconciliation 协调 API 保护、实时观察和领域判断,不拼装 Kubernetes status。
|
||||
type InstanceReconciliation struct {
|
||||
Resources InstanceResources
|
||||
Observer InstanceObserver
|
||||
}
|
||||
|
||||
func (s *InstanceReconciliation) Reconcile(ctx context.Context, name string) (InstanceResult, error) {
|
||||
record, err := s.Resources.LoadInstance(ctx, name)
|
||||
if err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
if record == nil {
|
||||
s.Observer.Forget(name)
|
||||
return InstanceResult{}, nil
|
||||
}
|
||||
if record.Deleting {
|
||||
return s.deleting(ctx, record)
|
||||
}
|
||||
record, err = s.Resources.ProtectInstance(ctx, record)
|
||||
if err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
// 每轮从无证据的领域对象开始;持久化 Ready 和连接存活不能替代本轮检查。
|
||||
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, false)
|
||||
if err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
if err := aggregate.BeginValidation(); err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
observation, observationErr := s.Observer.ObserveManagement(ctx, record.Target)
|
||||
result := InstanceResult{Record: record}
|
||||
if observationErr != nil {
|
||||
result.Snapshot = aggregate.Snapshot()
|
||||
result.Snapshot.Readiness = instance.NotReady
|
||||
result.Snapshot.ObservedRevision = record.Target.Revision().Value()
|
||||
result.Reason, result.Message = observationFailure(observationErr)
|
||||
return result, nil
|
||||
}
|
||||
capabilities, err := observation.Capabilities()
|
||||
if err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
if err := aggregate.AssessManagement(capabilities); err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
result.Snapshot = aggregate.Snapshot()
|
||||
result.Snapshot.ReportedVersion = observation.Version()
|
||||
result.Reason, result.Message = managementResult(result.Snapshot.Failure)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *InstanceReconciliation) deleting(ctx context.Context, record *InstanceRecord) (InstanceResult, error) {
|
||||
name := record.Target.Identity().Name()
|
||||
s.Observer.Forget(name)
|
||||
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, true)
|
||||
if err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
if err := aggregate.BeginDeletion(); err != nil {
|
||||
return InstanceResult{}, err
|
||||
}
|
||||
result := InstanceResult{Record: record, Snapshot: aggregate.Snapshot(), Reason: "Deleting"}
|
||||
reference, err := s.Resources.InstanceReferences(ctx, name)
|
||||
if err != nil {
|
||||
result.Reason = instanceDependencyUnavailable
|
||||
result.Message = "无法确认 Database/Tenant 引用已解除;保留 Instance 删除保护并重试"
|
||||
return result, nil
|
||||
}
|
||||
if reference != "" {
|
||||
result.Reason = "InstanceInUse"
|
||||
result.Message = "仍被 " + reference + " 引用;先处理该资源,不会级联删除外部数据库"
|
||||
return result, nil
|
||||
}
|
||||
result.Message = "引用已解除,仅移除登记保护;不删除 PostgreSQL 或凭据"
|
||||
result.RemoveProtection = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func observationFailure(err error) (string, string) {
|
||||
switch {
|
||||
case errors.Is(err, ErrAuthentication):
|
||||
return instanceAuthenticationFailed, "管理连接认证或 TLS 校验失败;检查管理 Secret 和 CA/证书配置"
|
||||
case errors.Is(err, ErrCredentialsInvalid):
|
||||
return "InvalidCredentials", "管理 Secret 的用户名或密码字段缺失;检查引用字段映射"
|
||||
case errors.Is(err, ErrCredentialsChanged):
|
||||
return "CredentialsChanged", "观察期间管理凭据变化,已丢弃结果并关闭旧连接;等待重新验证"
|
||||
case errors.Is(err, ErrCredentialsUnavailable):
|
||||
return instanceDependencyUnavailable, "无法读取管理 Secret;检查其是否存在及 controller namespace 内的读取权限"
|
||||
default:
|
||||
return instanceDependencyUnavailable, "管理连接或能力查询失败;检查 PostgreSQL 可达性、catalog 读取权限和超时"
|
||||
}
|
||||
}
|
||||
|
||||
func managementResult(failure instance.Failure) (string, string) {
|
||||
switch failure {
|
||||
case instance.NoFailure:
|
||||
return "ManagementReady", "当前管理能力检查通过;具体资源授权和扩展安装仍需执行时验证"
|
||||
case instance.InsufficientPrivileges:
|
||||
return "InsufficientPrivileges", "原生管理要求非 superuser 且具备 CREATEDB/CREATEROLE;不会自动修改账号权限"
|
||||
case instance.DependencyUnavailable:
|
||||
return instanceDependencyUnavailable, "当前 PostgreSQL 不可写或所需管理能力暂不可用"
|
||||
case instance.AuthenticationFailed:
|
||||
return instanceAuthenticationFailed, "当前管理能力检查未通过认证"
|
||||
default:
|
||||
return "ObservationIncomplete", "管理能力检查尚有缺项,不能仅凭 metadata 查询成功标记 Ready"
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func TestInstanceFailurePresentation(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
reason string
|
||||
}{
|
||||
{ErrAuthentication, "AuthenticationFailed"},
|
||||
{ErrCredentialsInvalid, "InvalidCredentials"},
|
||||
{ErrCredentialsChanged, "CredentialsChanged"},
|
||||
{ErrCredentialsUnavailable, instanceDependencyUnavailable},
|
||||
{context.DeadlineExceeded, instanceDependencyUnavailable},
|
||||
{errors.New("private backend detail"), instanceDependencyUnavailable},
|
||||
}
|
||||
for _, test := range cases {
|
||||
reason, message := observationFailure(test.err)
|
||||
if reason != test.reason || message == "" || message == test.err.Error() {
|
||||
t.Fatal("观察失败没有安全且可诊断的状态")
|
||||
}
|
||||
}
|
||||
for _, failure := range []instance.Failure{
|
||||
instance.NoFailure, instance.ObservationIncomplete, instance.DependencyUnavailable,
|
||||
instance.AuthenticationFailed, instance.InsufficientPrivileges,
|
||||
} {
|
||||
reason, message := managementResult(failure)
|
||||
if reason == "" || message == "" || (reason == "ManagementReady") != (failure == instance.NoFailure) {
|
||||
t.Fatal("领域能力判定与状态不一致")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataCannotEstablishManagementReadiness(t *testing.T) {
|
||||
source := &sourceStub{}
|
||||
source.credentials, _ = NewCredentials("test", serviceTestPassword)
|
||||
connector := &connectorStub{}
|
||||
service, err := NewInstanceService(source, connector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
target := serviceTarget(t, "uid", "postgres.test", "management", 1)
|
||||
if _, err := service.ObserveMetadata(t.Context(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connector.databases[0].metadata.Management = instance.ManagementChecks{
|
||||
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
|
||||
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
|
||||
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
|
||||
}
|
||||
observation, err := service.ObserveMetadata(t.Context(), target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities, err := observation.Capabilities()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aggregate, err := instance.Reconstitute(target, instance.Snapshot{}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.BeginValidation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.AssessManagement(capabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if aggregate.Snapshot().Failure != instance.ObservationIncomplete {
|
||||
t.Fatal("metadata 入口不应携带完整管理检查")
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConnection = errors.New("management connection unavailable")
|
||||
ErrAuthentication = errors.New("management authentication failed")
|
||||
ErrObservation = errors.New("management observation failed")
|
||||
ErrCredentialsChanged = errors.New("management credentials changed during observation")
|
||||
ErrClosed = errors.New("instance service closed")
|
||||
)
|
||||
|
||||
// Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。
|
||||
// Metadata 只查询版本与可用扩展,不能产生领域 Ready。
|
||||
type Database interface {
|
||||
InspectMetadata(context.Context) (DatabaseMetadata, error)
|
||||
InspectManagement(context.Context) (DatabaseMetadata, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type Connector interface {
|
||||
Connect(context.Context, instance.Endpoint, Credentials) (Database, error)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
target instance.ObservationTarget
|
||||
credentials Credentials
|
||||
database Database
|
||||
}
|
||||
|
||||
// InstanceService 由原 Service 迁移:连接复用与释放属于应用装配,不属于 SQL adapter。
|
||||
// 保留原实现串行操作的约束,防止 Close 与查询并发;controller 停止 worker 后调用 Close。
|
||||
// 不缓存能力观察,不把连接存活等同于 Ready。凭据每轮重新读取,而非只在引用变化时读取。
|
||||
type InstanceService struct {
|
||||
mu sync.Mutex
|
||||
source CredentialReader
|
||||
connector Connector
|
||||
entries map[string]*entry
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewInstanceService(source CredentialReader, connector Connector) (*InstanceService, error) {
|
||||
if source == nil || connector == nil {
|
||||
return nil, errors.New("credential source and connector required")
|
||||
}
|
||||
return &InstanceService{
|
||||
source: source,
|
||||
connector: connector,
|
||||
entries: make(map[string]*entry),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *InstanceService) String() string { return "[redacted instance service]" }
|
||||
func (s *InstanceService) GoString() string { return s.String() }
|
||||
|
||||
// ObserveVersion 是完整 metadata 读取的便捷入口,不再维护另一条连接或查询路径。
|
||||
func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.ObservationTarget) (string, error) {
|
||||
observation, err := s.ObserveMetadata(ctx, target)
|
||||
return observation.Version(), err
|
||||
}
|
||||
|
||||
// ObserveMetadata 返回当前目标和凭据下的版本与扩展;任何失败均丢弃全部结果。
|
||||
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
|
||||
func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
|
||||
return s.observe(ctx, target, false)
|
||||
}
|
||||
|
||||
// ObserveManagement 复用同一凭据刷新与回读边界,但每轮重新检查原生管理能力。
|
||||
func (s *InstanceService) ObserveManagement(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
|
||||
return s.observe(ctx, target, true)
|
||||
}
|
||||
|
||||
func (s *InstanceService) observe(ctx context.Context, target instance.ObservationTarget, management bool) (InstanceObservation, error) {
|
||||
if err := target.Validate(); err != nil {
|
||||
return InstanceObservation{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return InstanceObservation{}, ErrClosed
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InstanceObservation{}, err
|
||||
}
|
||||
|
||||
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
|
||||
name := target.Identity().Name()
|
||||
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
|
||||
if err != nil {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, credentialError(err)
|
||||
}
|
||||
if credentials.username == "" || credentials.password == "" {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, ErrCredentialsInvalid
|
||||
}
|
||||
|
||||
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
|
||||
current := s.entries[name]
|
||||
if current != nil && (current.target.Identity() != target.Identity() ||
|
||||
current.target.Definition() != target.Definition() || current.credentials != credentials) {
|
||||
s.release(name)
|
||||
current = nil
|
||||
}
|
||||
if current == nil {
|
||||
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
|
||||
if err != nil {
|
||||
return InstanceObservation{}, err
|
||||
}
|
||||
current = &entry{
|
||||
target: target,
|
||||
credentials: credentials,
|
||||
database: database,
|
||||
}
|
||||
s.entries[name] = current
|
||||
}
|
||||
|
||||
var metadata DatabaseMetadata
|
||||
if management {
|
||||
metadata, err = current.database.InspectManagement(ctx)
|
||||
} else {
|
||||
metadata, err = current.database.InspectMetadata(ctx)
|
||||
// 即使 adapter 误填权限,也不能把只读 metadata 入口升级为 Ready。
|
||||
metadata.Management = instance.ManagementChecks{}
|
||||
}
|
||||
if err != nil {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, err
|
||||
}
|
||||
if metadata.Version == "" {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, ErrObservation
|
||||
}
|
||||
|
||||
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
|
||||
latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
|
||||
if err != nil {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, credentialError(err)
|
||||
}
|
||||
if latest != credentials {
|
||||
s.release(name)
|
||||
return InstanceObservation{}, ErrCredentialsChanged
|
||||
}
|
||||
return InstanceObservation{
|
||||
target: target,
|
||||
version: metadata.Version,
|
||||
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions),
|
||||
management: metadata.Management,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func credentialError(err error) error {
|
||||
if errors.Is(err, ErrCredentialsInvalid) {
|
||||
return ErrCredentialsInvalid
|
||||
}
|
||||
return ErrCredentialsUnavailable
|
||||
}
|
||||
|
||||
// Forget 只释放本地连接;不删除数据库,不替代 Instance finalizer。
|
||||
func (s *InstanceService) Forget(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.release(name)
|
||||
}
|
||||
|
||||
func (s *InstanceService) release(name string) {
|
||||
if current := s.entries[name]; current != nil {
|
||||
current.database.Close()
|
||||
}
|
||||
delete(s.entries, name)
|
||||
}
|
||||
|
||||
func (s *InstanceService) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
for name := range s.entries {
|
||||
s.release(name)
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const serviceTestPassword = "test-only"
|
||||
|
||||
// 延续源项目 Service 测试,用于穷举身份与装配失败;真实行为由 adapter 集成测试验证。
|
||||
type sourceStub struct {
|
||||
credentials Credentials
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *sourceStub) Read(context.Context, instance.CredentialReference) (Credentials, error) {
|
||||
return s.credentials, s.err
|
||||
}
|
||||
|
||||
type databaseStub struct {
|
||||
closes int
|
||||
err error
|
||||
metadata DatabaseMetadata
|
||||
}
|
||||
|
||||
func (d *databaseStub) InspectManagement(ctx context.Context) (DatabaseMetadata, error) {
|
||||
return d.InspectMetadata(ctx)
|
||||
}
|
||||
|
||||
func (d *databaseStub) InspectMetadata(context.Context) (DatabaseMetadata, error) {
|
||||
return d.metadata, d.err
|
||||
}
|
||||
func (d *databaseStub) Close() {
|
||||
d.closes++
|
||||
}
|
||||
|
||||
type connectorStub struct {
|
||||
databases []*databaseStub
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *connectorStub) Connect(context.Context, instance.Endpoint, Credentials) (Database, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
db := &databaseStub{
|
||||
metadata: DatabaseMetadata{
|
||||
Version: "17",
|
||||
AvailableExtensions: []string{"plpgsql"},
|
||||
},
|
||||
}
|
||||
c.databases = append(c.databases, db)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func serviceTarget(t *testing.T, uid, host, secret string, generation int64) instance.ObservationTarget {
|
||||
t.Helper()
|
||||
id, err := instance.NewIdentity(uid, "shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revision, err := instance.NewRevision(generation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: host,
|
||||
HostAddr: "127.0.0.1",
|
||||
Port: 5432,
|
||||
ManagementDatabase: "postgres",
|
||||
TLSMode: instance.TLSDisable,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
|
||||
Name: secret,
|
||||
UsernameKey: "user",
|
||||
PasswordKey: "pass",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := instance.NewDefinition(endpoint, ref)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := instance.NewObservationTarget(id, revision, definition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func TestInstanceConnectionIdentity(t *testing.T) {
|
||||
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
|
||||
connector := &connectorStub{}
|
||||
service, err := NewInstanceService(source, connector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
ctx := context.Background()
|
||||
cases := []struct {
|
||||
name string
|
||||
target instance.ObservationTarget
|
||||
wantConnections int
|
||||
}{
|
||||
{"initial connection", serviceTarget(t, "uid-1", "first", "admin", 1), 1},
|
||||
{"generation alone", serviceTarget(t, "uid-1", "first", "admin", 2), 1},
|
||||
{"endpoint changed", serviceTarget(t, "uid-1", "second", "admin", 3), 2},
|
||||
{"reference changed", serviceTarget(t, "uid-1", "second", "replacement", 4), 3},
|
||||
{"same name with new UID", serviceTarget(t, "uid-2", "second", "replacement", 1), 4},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
observation, err := service.ObserveMetadata(ctx, testCase.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !observation.Target().Matches(testCase.target) {
|
||||
t.Fatalf("%s: observation was bound to a previous target", testCase.name)
|
||||
}
|
||||
if len(connector.databases) != testCase.wantConnections {
|
||||
t.Fatalf("%s: got %d connections, want %d", testCase.name, len(connector.databases), testCase.wantConnections)
|
||||
}
|
||||
}
|
||||
for _, db := range connector.databases[:3] {
|
||||
if db.closes != 1 {
|
||||
t.Fatal("replaced connection not closed exactly once")
|
||||
}
|
||||
}
|
||||
service.Forget("shared")
|
||||
service.Forget("shared")
|
||||
if connector.databases[3].closes != 1 {
|
||||
t.Fatal("forget did not close exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataObservationFreshnessAndFailure(t *testing.T) {
|
||||
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
|
||||
connector := &connectorStub{}
|
||||
service, err := NewInstanceService(source, connector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
ctx := context.Background()
|
||||
target := serviceTarget(t, "metadata-uid", "first", "admin", 1)
|
||||
observed, err := service.ObserveMetadata(ctx, target)
|
||||
if err != nil || observed.Version() != "17" || !observed.Target().Matches(target) {
|
||||
t.Fatalf("metadata observation: %v", err)
|
||||
}
|
||||
requested := instance.NewExtensionSet([]string{"plpgsql"})
|
||||
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("extension list was not observed")
|
||||
}
|
||||
|
||||
// 连接可以复用,但每轮必须重新查询;旧观察还必须与 adapter 的可变 slice 脱离。
|
||||
database := connector.databases[0]
|
||||
database.metadata.AvailableExtensions[0] = "replacement"
|
||||
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("adapter mutation changed a completed observation")
|
||||
}
|
||||
refreshed, err := service.ObserveMetadata(ctx, target)
|
||||
if err != nil || refreshed.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
|
||||
t.Fatalf("extension list was cached across observations: %v", err)
|
||||
}
|
||||
database.metadata.AvailableExtensions = nil
|
||||
empty, err := service.ObserveMetadata(ctx, target)
|
||||
if err != nil || empty.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
|
||||
t.Fatalf("successful empty list was treated as unobserved: %v", err)
|
||||
}
|
||||
|
||||
// 即使 adapter 附带部分数据,错误仍使整个观察失效。
|
||||
database.err = ErrObservation
|
||||
failed, err := service.ObserveMetadata(ctx, target)
|
||||
if !errors.Is(err, ErrObservation) || failed.Version() != "" || failed.Target().Validate() == nil {
|
||||
t.Fatal("failed query returned a bound observation")
|
||||
}
|
||||
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatal("failed query was interpreted as an empty extension list")
|
||||
}
|
||||
if _, err := service.ObserveMetadata(ctx, target); err != nil {
|
||||
t.Fatalf("retry after query failure: %v", err)
|
||||
}
|
||||
connector.databases[1].metadata.Version = ""
|
||||
if _, err := service.ObserveMetadata(ctx, target); !errors.Is(err, ErrObservation) {
|
||||
t.Fatal("missing server version was accepted as complete metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAssemblyFailureRecovery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
target := serviceTarget(t, "uid-1", "first", "admin", 1)
|
||||
source := &sourceStub{
|
||||
credentials: Credentials{username: testUsername, password: serviceTestPassword},
|
||||
err: errors.New("unsafe source error"),
|
||||
}
|
||||
connector := &connectorStub{err: ErrConnection}
|
||||
service, err := NewInstanceService(source, connector)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrCredentialsUnavailable) {
|
||||
t.Fatal("unsafe source error escaped")
|
||||
}
|
||||
source.err = nil
|
||||
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrConnection) {
|
||||
t.Fatal("connection failure returned evidence")
|
||||
}
|
||||
connector.err = nil
|
||||
if _, err := service.ObserveVersion(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connector.databases[0].err = ErrObservation
|
||||
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrObservation) {
|
||||
t.Fatal("failed query returned evidence")
|
||||
}
|
||||
if connector.databases[0].closes != 1 {
|
||||
t.Fatal("failed connection retained")
|
||||
}
|
||||
if _, err := service.ObserveVersion(ctx, target); err != nil {
|
||||
t.Fatal("retry failed", err)
|
||||
}
|
||||
service.Close()
|
||||
service.Close()
|
||||
if connector.databases[1].closes != 1 {
|
||||
t.Fatal("shutdown did not close once")
|
||||
}
|
||||
if _, err := service.ObserveVersion(ctx, target); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal("closed service accepted work")
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package application
|
||||
|
||||
import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
|
||||
// DatabaseMetadata 是一次只读查询的事实,可附带原生管理检查,但不包含就绪结论。
|
||||
// AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。
|
||||
type DatabaseMetadata struct {
|
||||
Version string
|
||||
AvailableExtensions []string
|
||||
// Management 仅由 InspectManagement 填充;metadata 查询必须保持未观察。
|
||||
Management instance.ManagementChecks
|
||||
}
|
||||
|
||||
// InstanceObservation 只在查询成功且有效凭据再次核对一致后产生。
|
||||
// target 绑定本次调用,而非连接最初创建时的 generation;零值表示没有观察。
|
||||
type InstanceObservation struct {
|
||||
target instance.ObservationTarget
|
||||
version string
|
||||
extensions instance.ExtensionSupport
|
||||
management instance.ManagementChecks
|
||||
}
|
||||
|
||||
// Capabilities 保留缺项为未观察;不能从 metadata 的成功补齐管理检查。
|
||||
func (o InstanceObservation) Capabilities() (instance.CapabilityObservation, error) {
|
||||
return instance.NewCapabilityObservation(o.target, o.version, o.management)
|
||||
}
|
||||
|
||||
func (o InstanceObservation) Target() instance.ObservationTarget { return o.target }
|
||||
func (o InstanceObservation) Version() string { return o.version }
|
||||
func (o InstanceObservation) Extensions() instance.ExtensionSupport {
|
||||
return o.extensions
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Package controller 将 Database 用例接入 Kubernetes 事件和重试调度。
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
const dependencyRetry = 30 * time.Second
|
||||
|
||||
type BindingReconciler struct {
|
||||
Client client.Client
|
||||
Reader client.Reader
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases,verbs=get;list;watch;create;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch
|
||||
|
||||
func (r *BindingReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||
resources := &kubernetes.BindingResources{Client: r.Client, Reader: r.Reader}
|
||||
service := application.BindingService{Resources: resources}
|
||||
result, err := service.Reconcile(ctx, request.Namespace, request.Name)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if err := resources.Present(ctx, result); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if result.RetrySoon {
|
||||
return ctrl.Result{RequeueAfter: time.Millisecond}, nil
|
||||
}
|
||||
if result.Tenant == nil {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
// watch 是主入口,低频重查覆盖依赖事件映射失败,不做冲突忙循环。
|
||||
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/rest"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
const (
|
||||
bindingNamespace = "binding-tests"
|
||||
phaseBinding = "Binding"
|
||||
phaseBound = "Bound"
|
||||
reasonConflict = "Conflict"
|
||||
reasonDependency = "DependencyUnavailable"
|
||||
TenantFinalizer = kubernetes.TenantFinalizer
|
||||
DatabaseFinalizer = kubernetes.DatabaseFinalizer
|
||||
)
|
||||
|
||||
func targetDatabaseName(tenant *databasev1alpha1.PostgreSQLTenant) string {
|
||||
return "tenant-" + string(tenant.UID)
|
||||
}
|
||||
|
||||
func tenantReference(tenant *databasev1alpha1.PostgreSQLTenant) *databasev1alpha1.TenantReference {
|
||||
return &databasev1alpha1.TenantReference{
|
||||
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingController(t *testing.T) {
|
||||
apiClient, config, scheme := bindingEnvironment(t)
|
||||
t.Run("动态申请和幂等重试", func(t *testing.T) { testDynamicBinding(t, apiClient) })
|
||||
t.Run("双向写入之间重启", func(t *testing.T) { testBindingRestart(t, apiClient) })
|
||||
t.Run("并发申请只有一个绑定", func(t *testing.T) { testConcurrentBinding(t, apiClient) })
|
||||
t.Run("Released和同名重建", func(t *testing.T) { testBindingIdentity(t, apiClient) })
|
||||
t.Run("目标固定与删除保护", func(t *testing.T) { testBindingProtection(t, apiClient) })
|
||||
t.Run("拒绝陈旧观察和新实例身份", func(t *testing.T) { testStaleObservation(t, apiClient) })
|
||||
t.Run("呈现结果不覆盖并发修改", func(t *testing.T) { testPresentationVersion(t, apiClient) })
|
||||
t.Run("依赖稍后出现的watch", func(t *testing.T) { testBindingWatch(t, apiClient, config, scheme) })
|
||||
}
|
||||
|
||||
func bindingEnvironment(t *testing.T) (client.Client, *rest.Config, *runtime.Scheme) {
|
||||
t.Helper()
|
||||
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
|
||||
t.Skip("运行 make test 启动真实 API server")
|
||||
}
|
||||
scheme := runtime.NewScheme()
|
||||
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rbacv1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
crdPath, err := filepath.Abs("../../../config/crd/bases")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
|
||||
config, err := environment.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := environment.Stop(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
apiClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespace := &corev1.Namespace{}
|
||||
namespace.Name = bindingNamespace
|
||||
requireCreate(t, apiClient, namespace)
|
||||
return apiClient, config, scheme
|
||||
}
|
||||
|
||||
func testDynamicBinding(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "dynamic-instance")
|
||||
tenant := provisionTenant("dynamic", instance.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.Status.DatabaseRef == nil || tenant.Status.Phase != phaseBound {
|
||||
t.Fatal("动态申请未建立双向绑定")
|
||||
}
|
||||
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
database.Name = targetDatabaseName(tenant)
|
||||
reload(t, apiClient, database)
|
||||
if database.Spec.Database != "dynamic" || database.Spec.LoginRole != "dynamic" ||
|
||||
database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain ||
|
||||
*database.Spec.TenantRef != *tenantReference(tenant) || database.Status.InstanceUID != instance.UID {
|
||||
t.Fatal("动态资源目标、默认值或身份不符")
|
||||
}
|
||||
if len(database.OwnerReferences) != 0 || !controllerutil.ContainsFinalizer(database, DatabaseFinalizer) {
|
||||
t.Fatal("Database 不应随 Tenant GC,且必须先有删除保护")
|
||||
}
|
||||
beforeTenant, beforeDatabase := tenant.ResourceVersion, database.ResourceVersion
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
reload(t, apiClient, database)
|
||||
if tenant.ResourceVersion != beforeTenant || database.ResourceVersion != beforeDatabase {
|
||||
t.Fatal("幂等重试产生了无意义写入")
|
||||
}
|
||||
assertNotReady(t, tenant, "BindingComplete")
|
||||
}
|
||||
|
||||
// 只在真实 API 调用边界注入错误,底层仍使用 API server 的并发、status 与 CEL 语义。
|
||||
type failedTenantStatusClient struct {
|
||||
client.Client
|
||||
}
|
||||
|
||||
func (c *failedTenantStatusClient) Status() client.SubResourceWriter {
|
||||
return &failedTenantStatusWriter{SubResourceWriter: c.Client.Status()}
|
||||
}
|
||||
|
||||
type failedTenantStatusWriter struct {
|
||||
client.SubResourceWriter
|
||||
}
|
||||
|
||||
func (w *failedTenantStatusWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
|
||||
if tenant, ok := object.(*databasev1alpha1.PostgreSQLTenant); ok && tenant.Status.DatabaseRef != nil {
|
||||
return errors.New("injected tenant status write failure")
|
||||
}
|
||||
return w.SubResourceWriter.Update(ctx, object, options...)
|
||||
}
|
||||
|
||||
func testBindingRestart(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "restart-instance")
|
||||
tenant := provisionTenant("restart", instance.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
first := &BindingReconciler{Client: &failedTenantStatusClient{Client: apiClient}, Reader: apiClient}
|
||||
if _, err := first.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err == nil {
|
||||
t.Fatal("预期第二次绑定写入失败")
|
||||
}
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.Status.DatabaseRef != nil || tenant.Status.Phase != phaseBinding {
|
||||
t.Fatal("失败后不应伪造申请侧完成")
|
||||
}
|
||||
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
database.Name = targetDatabaseName(tenant)
|
||||
reload(t, apiClient, database)
|
||||
if database.Spec.TenantRef == nil || database.Spec.TenantRef.UID != tenant.UID {
|
||||
t.Fatal("失败后资源侧绑定不应回滚")
|
||||
}
|
||||
// 新建 reconciler,无旧内存,只从 API 中读取进度。
|
||||
restarted := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
reconcileOK(t, restarted, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.Status.DatabaseRef == nil || tenant.Status.DatabaseRef.UID != database.UID {
|
||||
t.Fatal("重启后未补齐同一资源绑定")
|
||||
}
|
||||
}
|
||||
|
||||
func testConcurrentBinding(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "concurrent-instance")
|
||||
database := availableDatabase(t, apiClient, "concurrent-db", instance)
|
||||
tenants := []*databasev1alpha1.PostgreSQLTenant{
|
||||
existingTenant("contender-one", database.Name), existingTenant("contender-two", database.Name),
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
requireCreate(t, apiClient, tenant)
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
results := make(chan error, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
workers.Go(func() {
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)})
|
||||
results <- err
|
||||
})
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
if err != nil && !apierrors.IsConflict(err) {
|
||||
t.Fatalf("并发协调出现非版本冲突错误: %v", err)
|
||||
}
|
||||
}
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
bound := 0
|
||||
for _, tenant := range tenants {
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.Status.DatabaseRef != nil {
|
||||
bound++
|
||||
} else {
|
||||
assertNotReady(t, tenant, reasonConflict)
|
||||
}
|
||||
}
|
||||
if bound != 1 {
|
||||
t.Fatalf("绑定申请数 = %d, want 1", bound)
|
||||
}
|
||||
}
|
||||
|
||||
func testBindingIdentity(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "identity-instance")
|
||||
database := availableDatabase(t, apiClient, "released-db", instance)
|
||||
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
|
||||
Namespace: bindingNamespace, Name: "identity", UID: "previous-tenant-uid",
|
||||
}
|
||||
if err := apiClient.Update(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.Status.Phase = "Released"
|
||||
if err := apiClient.Status().Update(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := existingTenant("identity", database.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
assertNotReady(t, tenant, reasonConflict)
|
||||
if tenant.Status.DatabaseRef != nil {
|
||||
t.Fatal("同名新 Tenant 不应继承旧 UID 的绑定")
|
||||
}
|
||||
// 同名动态记录没有匹配 UID,不能通过名称猜测这是先前创建的资源。
|
||||
dynamic := provisionTenant("collision", instance.Name)
|
||||
requireCreate(t, apiClient, dynamic)
|
||||
collision := availableDatabase(t, apiClient, targetDatabaseName(dynamic), instance)
|
||||
reconcileOK(t, reconciler, dynamic)
|
||||
reload(t, apiClient, dynamic)
|
||||
assertNotReady(t, dynamic, reasonConflict)
|
||||
reload(t, apiClient, collision)
|
||||
if collision.Spec.TenantRef != nil {
|
||||
t.Fatal("同名未知记录被认领")
|
||||
}
|
||||
}
|
||||
|
||||
func testBindingProtection(t *testing.T, apiClient client.Client) {
|
||||
tenant := provisionTenant("protection", "missing-instance")
|
||||
requireCreate(t, apiClient, tenant)
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
assertNotReady(t, tenant, reasonDependency)
|
||||
original := tenant.DeepCopy()
|
||||
tenant.Spec.Provision.InstanceRef.Name = "other-instance"
|
||||
if err := apiClient.Update(t.Context(), tenant); !apierrors.IsInvalid(err) {
|
||||
t.Fatalf("Binding 后目标修改 = %v, want Invalid", err)
|
||||
}
|
||||
tenant = original
|
||||
if err := apiClient.Delete(t.Context(), tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.DeletionTimestamp.IsZero() || !controllerutil.ContainsFinalizer(tenant, TenantFinalizer) {
|
||||
t.Fatal("未实现清理时不应提前移除删除保护")
|
||||
}
|
||||
assertNotReady(t, tenant, "DeletionPending")
|
||||
}
|
||||
|
||||
func testStaleObservation(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "stale-instance")
|
||||
database := availableDatabase(t, apiClient, "stale-database", instance)
|
||||
// 被观察后不能更换实际数据库目标,修改回收策略仍允许。
|
||||
changed := database.DeepCopy()
|
||||
changed.Spec.Database = "different"
|
||||
if err := apiClient.Update(t.Context(), changed); !apierrors.IsInvalid(err) {
|
||||
t.Fatalf("已观察目标修改 = %v, want Invalid", err)
|
||||
}
|
||||
database.Spec.ReclaimPolicy = databasev1alpha1.ReclaimDelete
|
||||
if err := apiClient.Update(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := existingTenant("stale", database.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
assertNotReady(t, tenant, reasonDependency)
|
||||
// 即使同名新 Instance 已 Ready,也不能覆盖 Database 记录的旧 Instance UID。
|
||||
if err := apiClient.Delete(t.Context(), instance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readyInstance(t, apiClient, instance.Name)
|
||||
reconcileOK(t, reconciler, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
assertNotReady(t, tenant, reasonConflict)
|
||||
}
|
||||
|
||||
func testBindingWatch(t *testing.T, apiClient client.Client, config *rest.Config, scheme *runtime.Scheme) {
|
||||
controllerConfig := bindingControllerConfig(t, apiClient, config)
|
||||
// controller-runtime 的名称登记跨 manager 生命周期保留;允许 go test -count 重复顺序启动。
|
||||
// 每轮 cleanup 等待旧 manager 退出,生产 manager 不关闭名称校验。
|
||||
skipRepeatedTestName := true
|
||||
manager, err := ctrl.NewManager(controllerConfig, ctrl.Options{
|
||||
Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
|
||||
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedTestName},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconciler := &BindingReconciler{}
|
||||
if err := reconciler.SetupWithManager(t.Context(), manager); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- manager.Start(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Error("manager 未及时停止")
|
||||
}
|
||||
})
|
||||
if !manager.GetCache().WaitForCacheSync(ctx) {
|
||||
t.Fatal("cache 未同步")
|
||||
}
|
||||
tenant := existingTenant("watch", "late-database")
|
||||
requireCreate(t, apiClient, tenant)
|
||||
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
|
||||
condition := meta.FindStatusCondition(current.Status.Conditions, "Ready")
|
||||
return condition != nil && condition.Reason == reasonDependency
|
||||
})
|
||||
instance := readyInstance(t, apiClient, "late-instance")
|
||||
availableDatabase(t, apiClient, "late-database", instance)
|
||||
// 小于低频重试周期,只能靠 informer/watch 事件收敛,而不是手工调用 Reconcile。
|
||||
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
|
||||
return current.Status.Phase == phaseBound && current.Status.DatabaseRef != nil
|
||||
})
|
||||
}
|
||||
|
||||
func testPresentationVersion(t *testing.T, apiClient client.Client) {
|
||||
instance := readyInstance(t, apiClient, "presentation-instance")
|
||||
tenant := provisionTenant("presentation", instance.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
resources := &kubernetes.BindingResources{Client: apiClient, Reader: apiClient}
|
||||
service := application.BindingService{Resources: resources}
|
||||
result, err := service.Reconcile(t.Context(), tenant.Namespace, tenant.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 用例完成资源侧写入后,模拟另一个客户端修改不属于绑定目标的字段。
|
||||
reload(t, apiClient, tenant)
|
||||
tenant.Spec.SecretName = "updated-delivery"
|
||||
tenant.Annotations = map[string]string{"example.test/keep": "preserved"}
|
||||
if err := apiClient.Update(t.Context(), tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := resources.Present(t.Context(), result); !apierrors.IsConflict(err) {
|
||||
t.Fatalf("过期结果呈现 = %v, want Conflict", err)
|
||||
}
|
||||
reconcileOK(t, &BindingReconciler{Client: apiClient, Reader: apiClient}, tenant)
|
||||
reload(t, apiClient, tenant)
|
||||
if tenant.Status.Phase != phaseBound || tenant.Spec.SecretName != "updated-delivery" ||
|
||||
tenant.Annotations["example.test/keep"] != "preserved" {
|
||||
t.Fatal("重新协调未完成绑定或覆盖了其他字段")
|
||||
}
|
||||
}
|
||||
|
||||
func bindingControllerConfig(t *testing.T, apiClient client.Client, config *rest.Config) *rest.Config {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile("../../../config/rbac/role.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := &rbacv1.ClusterRole{}
|
||||
if err := yaml.Unmarshal(content, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireCreate(t, apiClient, role)
|
||||
binding := &rbacv1.ClusterRoleBinding{}
|
||||
binding.Name = "binding-controller-test"
|
||||
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
|
||||
binding.Subjects = []rbacv1.Subject{{APIGroup: rbacv1.GroupName, Kind: "User", Name: binding.Name}}
|
||||
requireCreate(t, apiClient, binding)
|
||||
controllerConfig := rest.CopyConfig(config)
|
||||
controllerConfig.Impersonate = rest.ImpersonationConfig{UserName: binding.Name}
|
||||
restricted, err := client.New(controllerConfig, client.Options{Scheme: apiClient.Scheme()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 绑定角色没有凭据读取权限,也不需要测试中的管理用户权限。
|
||||
secret := &corev1.Secret{}
|
||||
if err := restricted.Get(t.Context(), client.ObjectKey{Namespace: bindingNamespace, Name: "not-readable"}, secret); !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("绑定 controller 读取 Secret = %v, want Forbidden", err)
|
||||
}
|
||||
return controllerConfig
|
||||
}
|
||||
|
||||
func waitForTenant(t *testing.T, apiClient client.Client, tenant *databasev1alpha1.PostgreSQLTenant,
|
||||
predicate func(*databasev1alpha1.PostgreSQLTenant) bool) {
|
||||
t.Helper()
|
||||
deadline := time.NewTimer(10 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(25 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
current := &databasev1alpha1.PostgreSQLTenant{}
|
||||
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(tenant), current); err == nil && predicate(current) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-deadline.C:
|
||||
t.Fatalf("Tenant %s 未在 watch 期限内收敛", tenant.Name)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readyInstance(t *testing.T, apiClient client.Client, name string) *databasev1alpha1.PostgreSQLInstance {
|
||||
t.Helper()
|
||||
instance := &databasev1alpha1.PostgreSQLInstance{}
|
||||
instance.Name = name
|
||||
instance.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
|
||||
instance.Spec.AdminCredentialRef.Name = "admin"
|
||||
requireCreate(t, apiClient, instance)
|
||||
instance.Status.Conditions = readyConditions(instance.Generation)
|
||||
if err := apiClient.Status().Update(t.Context(), instance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
func availableDatabase(t *testing.T, apiClient client.Client, name string,
|
||||
instance *databasev1alpha1.PostgreSQLInstance) *databasev1alpha1.PostgreSQLDatabase {
|
||||
t.Helper()
|
||||
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
database.Name = name
|
||||
database.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
|
||||
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance.Name)},
|
||||
Database: "existing", LoginRole: "existing", Source: "Import",
|
||||
CredentialRef: &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"},
|
||||
}
|
||||
requireCreate(t, apiClient, database)
|
||||
database.Status.InstanceUID = instance.UID
|
||||
database.Status.Phase = "Available"
|
||||
database.Status.Conditions = readyConditions(database.Generation)
|
||||
if err := apiClient.Status().Update(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func readyConditions(generation int64) []metav1.Condition {
|
||||
return []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Verified",
|
||||
Message: "测试提供的后端观察", ObservedGeneration: generation, LastTransitionTime: metav1.Now()}}
|
||||
}
|
||||
|
||||
func provisionTenant(name, instance string) *databasev1alpha1.PostgreSQLTenant {
|
||||
tenant := &databasev1alpha1.PostgreSQLTenant{}
|
||||
tenant.Name, tenant.Namespace = name, bindingNamespace
|
||||
tenant.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
|
||||
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance)},
|
||||
}
|
||||
return tenant
|
||||
}
|
||||
|
||||
func existingTenant(name, database string) *databasev1alpha1.PostgreSQLTenant {
|
||||
tenant := &databasev1alpha1.PostgreSQLTenant{}
|
||||
tenant.Name, tenant.Namespace = name, bindingNamespace
|
||||
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: databasev1alpha1.ObjectName(database)}
|
||||
return tenant
|
||||
}
|
||||
|
||||
func requireCreate(t *testing.T, apiClient client.Client, object client.Object) {
|
||||
t.Helper()
|
||||
if err := apiClient.Create(t.Context(), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func reload(t *testing.T, apiClient client.Client, object client.Object) {
|
||||
t.Helper()
|
||||
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileOK(t *testing.T, reconciler *BindingReconciler, tenant *databasev1alpha1.PostgreSQLTenant) {
|
||||
t.Helper()
|
||||
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{
|
||||
NamespacedName: client.ObjectKeyFromObject(tenant),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotReady(t *testing.T, tenant *databasev1alpha1.PostgreSQLTenant, reason string) {
|
||||
t.Helper()
|
||||
condition := meta.FindStatusCondition(tenant.Status.Conditions, "Ready")
|
||||
if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != reason {
|
||||
t.Fatalf("Ready condition 不符: %+v", condition)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
)
|
||||
|
||||
const targetDatabaseIndex = "database.bindingTarget"
|
||||
|
||||
func (r *BindingReconciler) SetupWithManager(ctx context.Context, manager ctrl.Manager) error {
|
||||
if r.Client == nil {
|
||||
r.Client = manager.GetClient()
|
||||
}
|
||||
if r.Reader == nil {
|
||||
r.Reader = manager.GetAPIReader()
|
||||
}
|
||||
if err := manager.GetFieldIndexer().IndexField(ctx, &databasev1alpha1.PostgreSQLTenant{},
|
||||
targetDatabaseIndex, func(object client.Object) []string {
|
||||
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
|
||||
return []string{kubernetes.BindingTargetName(tenant)}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(manager).
|
||||
Named("database-binding").
|
||||
For(&databasev1alpha1.PostgreSQLTenant{}).
|
||||
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.requestsForDatabase)).
|
||||
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(r.requestsForInstance)).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func (r *BindingReconciler) requestsForDatabase(ctx context.Context, object client.Object) []ctrl.Request {
|
||||
tenants := &databasev1alpha1.PostgreSQLTenantList{}
|
||||
if err := r.Client.List(ctx, tenants, client.MatchingFields{targetDatabaseIndex: object.GetName()}); err != nil {
|
||||
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Database 事件;等待低频重试")
|
||||
return nil
|
||||
}
|
||||
requests := make([]ctrl.Request, 0, len(tenants.Items))
|
||||
for _, tenant := range tenants.Items {
|
||||
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (r *BindingReconciler) requestsForInstance(ctx context.Context, object client.Object) []ctrl.Request {
|
||||
// 当前只有 homelab 内部申请,使用 cache 列表过滤,不维护另一份实例/租户集合。
|
||||
tenants := &databasev1alpha1.PostgreSQLTenantList{}
|
||||
if err := r.Client.List(ctx, tenants); err != nil {
|
||||
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 事件;等待低频重试")
|
||||
return nil
|
||||
}
|
||||
requests := make([]ctrl.Request, 0, len(tenants.Items))
|
||||
for _, tenant := range tenants.Items {
|
||||
instanceName := ""
|
||||
if tenant.Spec.Provision != nil {
|
||||
instanceName = string(tenant.Spec.Provision.InstanceRef.Name)
|
||||
} else if tenant.Spec.DatabaseRef != nil {
|
||||
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||
if err := r.Client.Get(ctx, types.NamespacedName{Name: string(tenant.Spec.DatabaseRef.Name)}, database); err != nil {
|
||||
continue
|
||||
}
|
||||
instanceName = string(database.Spec.InstanceRef.Name)
|
||||
}
|
||||
if instanceName == object.GetName() {
|
||||
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
type InstanceReconciler struct {
|
||||
Client client.Client
|
||||
Reader client.Reader
|
||||
Observer application.InstanceObserver
|
||||
SecretNamespace string
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/finalizers,verbs=update
|
||||
// Secret 权限单独声明为 namespace Role,不放入生成的 ClusterRole。
|
||||
|
||||
func (r *InstanceReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||
resources := &kubernetes.InstanceResources{Client: r.Client, Reader: r.Reader}
|
||||
service := application.InstanceReconciliation{Resources: resources, Observer: r.Observer}
|
||||
observationContext, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.Reconcile(observationContext, request.Name)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
// 查询超时后仍用 worker context 保存安全失败结果;manager 停止时不强行写入。
|
||||
if err := resources.PresentInstance(ctx, result); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if result.Record == nil || result.RemoveProtection {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
)
|
||||
|
||||
type instanceBackend struct {
|
||||
checks instance.ManagementChecks
|
||||
err error
|
||||
inspect func()
|
||||
closed int
|
||||
}
|
||||
|
||||
func (b *instanceBackend) Read(context.Context, instance.CredentialReference) (application.Credentials, error) {
|
||||
return application.NewCredentials("fixture", "test-only-instance-password")
|
||||
}
|
||||
|
||||
func (b *instanceBackend) Connect(context.Context, instance.Endpoint, application.Credentials) (application.Database, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *instanceBackend) InspectMetadata(context.Context) (application.DatabaseMetadata, error) {
|
||||
return application.DatabaseMetadata{Version: "18"}, nil
|
||||
}
|
||||
|
||||
func (b *instanceBackend) InspectManagement(context.Context) (application.DatabaseMetadata, error) {
|
||||
if b.inspect != nil {
|
||||
b.inspect()
|
||||
}
|
||||
return application.DatabaseMetadata{Version: "18", Management: b.checks}, b.err
|
||||
}
|
||||
|
||||
func (b *instanceBackend) Close() { b.closed++ }
|
||||
|
||||
func newInstanceReconciler(t *testing.T, apiClient client.Client, backend *instanceBackend) *InstanceReconciler {
|
||||
t.Helper()
|
||||
service, err := application.NewInstanceService(backend, backend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(service.Close)
|
||||
return &InstanceReconciler{Client: apiClient, Reader: apiClient, Observer: service}
|
||||
}
|
||||
|
||||
func reconcileInstance(t *testing.T, reconciler *InstanceReconciler, object *databasev1alpha1.PostgreSQLInstance) {
|
||||
t.Helper()
|
||||
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInstanceReason(t *testing.T, object *databasev1alpha1.PostgreSQLInstance, reason string) {
|
||||
t.Helper()
|
||||
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
|
||||
if condition == nil || condition.Reason != reason || condition.ObservedGeneration != object.Generation {
|
||||
t.Fatalf("Instance 状态不是当前 generation 的 %s", reason)
|
||||
}
|
||||
if reason != "ManagementReady" && condition.Status != metav1.ConditionFalse {
|
||||
t.Fatal("失败状态仍为 Ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceObservationAPI(t *testing.T) {
|
||||
apiClient, _, _ := bindingEnvironment(t)
|
||||
backend := &instanceBackend{checks: instance.ManagementChecks{
|
||||
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
|
||||
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
|
||||
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
|
||||
}}
|
||||
reconciler := newInstanceReconciler(t, apiClient, backend)
|
||||
object := readyInstance(t, apiClient, "observed-instance")
|
||||
backend.inspect = func() {
|
||||
current := &databasev1alpha1.PostgreSQLInstance{}
|
||||
current.Name = object.Name
|
||||
reload(t, apiClient, current)
|
||||
if !controllerutil.ContainsFinalizer(current, kubernetes.InstanceFinalizer) {
|
||||
t.Fatal("观察早于 finalizer 持久化")
|
||||
}
|
||||
}
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "ManagementReady")
|
||||
if object.Status.Phase != string(instance.PhaseReady) || object.Status.PostgreSQLVersion != "18" {
|
||||
t.Fatal("当前成功观察未呈现")
|
||||
}
|
||||
before := object.ResourceVersion
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
if object.ResourceVersion != before {
|
||||
t.Fatal("相同观察不应反复写入 status")
|
||||
}
|
||||
backend.err = application.ErrAuthentication
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "AuthenticationFailed")
|
||||
if backend.closed != 1 || object.Status.PostgreSQLVersion != "" {
|
||||
t.Fatal("观察失败应释放连接并清除旧版本结果")
|
||||
}
|
||||
backend.err = nil
|
||||
backend.checks.Grants = instance.CheckUnobserved
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "ObservationIncomplete")
|
||||
backend.checks.Grants = instance.CheckPassed
|
||||
// 用新 service/reconciler 恢复;不依赖上轮领域对象或 Ready。
|
||||
reconciler = newInstanceReconciler(t, apiClient, backend)
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "ManagementReady")
|
||||
|
||||
backend.inspect = func() {
|
||||
reload(t, apiClient, object)
|
||||
object.Annotations = map[string]string{"concurrent": "kept-by-instance-test"}
|
||||
if err := apiClient.Update(t.Context(), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)})
|
||||
if !apierrors.IsConflict(err) {
|
||||
t.Fatal("旧观察不应覆盖在途 API 修改")
|
||||
}
|
||||
backend.inspect = nil
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
if object.Annotations["concurrent"] != "kept-by-instance-test" {
|
||||
t.Fatal("重试覆盖了其他字段")
|
||||
}
|
||||
}
|
||||
|
||||
type failedReferenceReader struct{ client.Reader }
|
||||
|
||||
func (*failedReferenceReader) List(context.Context, client.ObjectList, ...client.ListOption) error {
|
||||
return errors.New("injected reference list failure")
|
||||
}
|
||||
|
||||
func TestInstanceDeletionProtection(t *testing.T) {
|
||||
apiClient, _, _ := bindingEnvironment(t)
|
||||
backend := &instanceBackend{}
|
||||
reconciler := newInstanceReconciler(t, apiClient, backend)
|
||||
object := readyInstance(t, apiClient, "protected-instance")
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
database := availableDatabase(t, apiClient, "retained-database", object)
|
||||
database.Status.Phase = "Released"
|
||||
if err := apiClient.Status().Update(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := provisionTenant("pending-request", object.Name)
|
||||
requireCreate(t, apiClient, tenant)
|
||||
if err := apiClient.Delete(t.Context(), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.inspect = func() { t.Fatal("删除中不应连接 PostgreSQL") }
|
||||
reconciler.Reader = &failedReferenceReader{Reader: apiClient}
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, reasonDependency)
|
||||
reconciler.Reader = apiClient
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "InstanceInUse")
|
||||
if object.Status.Phase != string(instance.PhaseDeleting) || backend.closed != 1 {
|
||||
t.Fatal("删除没有停止本地观察")
|
||||
}
|
||||
if err := apiClient.Delete(t.Context(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconcileInstance(t, reconciler, object)
|
||||
reload(t, apiClient, object)
|
||||
assertInstanceReason(t, object, "InstanceInUse")
|
||||
if err := apiClient.Delete(t.Context(), tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconcileInstance(t, reconciler, object)
|
||||
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); !apierrors.IsNotFound(err) {
|
||||
t.Fatal("最后一个引用解除后 Instance 应可删除")
|
||||
}
|
||||
reconcileInstance(t, reconciler, object)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/cache"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
)
|
||||
|
||||
// InstanceCacheOptions 必须在创建 manager 时使用;只 watch 固定 namespace 的 Secret metadata。
|
||||
// SecretCredentials 始终直读 API,不会令共享 cache 保存密码。
|
||||
func InstanceCacheOptions(namespace string) cache.Options {
|
||||
return cache.Options{ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: {Namespaces: map[string]cache.Config{namespace: {}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func (r *InstanceReconciler) SetupWithManager(manager ctrl.Manager) error {
|
||||
if r.Observer == nil || len(validation.IsDNS1123Label(r.SecretNamespace)) != 0 {
|
||||
return errors.New("instance observer and valid management Secret namespace required")
|
||||
}
|
||||
if r.Client == nil {
|
||||
r.Client = manager.GetClient()
|
||||
}
|
||||
if r.Reader == nil {
|
||||
r.Reader = manager.GetAPIReader()
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(manager).
|
||||
Named("database-instance").
|
||||
For(&databasev1alpha1.PostgreSQLInstance{}).
|
||||
WatchesMetadata(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.instancesForSecret)).
|
||||
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
|
||||
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func (r *InstanceReconciler) instancesForSecret(ctx context.Context, object client.Object) []ctrl.Request {
|
||||
if object.GetNamespace() != r.SecretNamespace {
|
||||
return nil
|
||||
}
|
||||
instances := &databasev1alpha1.PostgreSQLInstanceList{}
|
||||
if err := r.Client.List(ctx, instances); err != nil {
|
||||
ctrl.LoggerFrom(ctx).Error(err, "无法映射管理 Secret 事件;等待低频重试")
|
||||
return nil
|
||||
}
|
||||
var requests []ctrl.Request
|
||||
for _, item := range instances.Items {
|
||||
if string(item.Spec.AdminCredentialRef.Name) == object.GetName() {
|
||||
request := ctrl.Request{Name: item.Name}
|
||||
requests = append(requests, request)
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (r *InstanceReconciler) instanceForReference(_ context.Context, object client.Object) []ctrl.Request {
|
||||
var name string
|
||||
switch item := object.(type) {
|
||||
case *databasev1alpha1.PostgreSQLDatabase:
|
||||
name = string(item.Spec.InstanceRef.Name)
|
||||
case *databasev1alpha1.PostgreSQLTenant:
|
||||
if item.Spec.Provision != nil {
|
||||
name = string(item.Spec.Provision.InstanceRef.Name)
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
request := ctrl.Request{Name: name}
|
||||
return []ctrl.Request{request}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Package binding 定义资源与申请的纯绑定规则,不访问 Kubernetes 或数据库。
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
Binding = "Binding"
|
||||
Bound = "Bound"
|
||||
Deleting = "Deleting"
|
||||
Conflict = "Conflict"
|
||||
DependencyUnavailable = "DependencyUnavailable"
|
||||
)
|
||||
|
||||
type Identity struct {
|
||||
Name string
|
||||
UID string
|
||||
}
|
||||
|
||||
type TenantIdentity struct {
|
||||
Namespace string
|
||||
Name string
|
||||
UID string
|
||||
}
|
||||
|
||||
// Request 保留用户输入;Resolve 产生默认值已确定的目标,不修改原请求。
|
||||
type Request struct {
|
||||
Provision *ProvisionRequest
|
||||
ExistingDatabase string
|
||||
}
|
||||
|
||||
type ProvisionRequest struct {
|
||||
Instance string
|
||||
Database string
|
||||
LoginRole string
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
Name string
|
||||
Provision *ProvisionRequest
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
func (r Request) Resolve(tenant TenantIdentity) (Target, error) {
|
||||
if (r.Provision == nil) == (r.ExistingDatabase == "") {
|
||||
return Target{}, fmt.Errorf("必须且只能选择动态申请或已有 Database")
|
||||
}
|
||||
if r.Provision == nil {
|
||||
return Target{Name: r.ExistingDatabase}, nil
|
||||
}
|
||||
provision := *r.Provision
|
||||
if provision.Database == "" {
|
||||
provision.Database = tenant.Name
|
||||
}
|
||||
if provision.LoginRole == "" {
|
||||
provision.LoginRole = tenant.Name
|
||||
}
|
||||
if !identifier.MatchString(provision.Database) || !identifier.MatchString(provision.LoginRole) {
|
||||
return Target{}, fmt.Errorf("动态 database/loginRole 必须符合 PostgreSQL identifier 规则;省略时使用 Tenant 名称")
|
||||
}
|
||||
return Target{Name: DynamicDatabaseName(tenant.UID), Provision: &provision}, nil
|
||||
}
|
||||
|
||||
func DynamicDatabaseName(tenantUID string) string { return "tenant-" + tenantUID }
|
||||
|
||||
type Tenant struct {
|
||||
Identity TenantIdentity
|
||||
Request Request
|
||||
Phase string
|
||||
Deleting bool
|
||||
Database *Identity
|
||||
}
|
||||
|
||||
// Database 是绑定所需的资源事实,不包含存储版本、Conditions 或客户端对象。
|
||||
type Database struct {
|
||||
Identity Identity
|
||||
Instance string
|
||||
InstanceUID string
|
||||
Name string
|
||||
LoginRole string
|
||||
Source string
|
||||
Tenant *TenantIdentity
|
||||
Phase string
|
||||
Deleting bool
|
||||
Ready bool
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
Identity Identity
|
||||
Deleting bool
|
||||
Ready bool
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (d Database) MatchesProvision(target Target, tenant TenantIdentity) bool {
|
||||
return target.Provision != nil && d.Source == "Provision" && d.Instance == target.Provision.Instance &&
|
||||
d.Name == target.Provision.Database && d.LoginRole == target.Provision.LoginRole &&
|
||||
d.Tenant != nil && *d.Tenant == tenant
|
||||
}
|
||||
|
||||
func (i Instance) Check(database *Database) *Issue {
|
||||
if i.Deleting || !i.Ready {
|
||||
return &Issue{DependencyUnavailable, "Instance 正在删除或尚无当前版本的 Ready 观察"}
|
||||
}
|
||||
if database != nil && database.InstanceUID != "" && database.InstanceUID != i.Identity.UID {
|
||||
return &Issue{Conflict, "Instance UID 已变化;请核实实例身份,未迁移或接管资源"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Database) CanBind(tenant Tenant) *Issue {
|
||||
if tenant.Database != nil && tenant.Database.UID != d.Identity.UID {
|
||||
return &Issue{Conflict, fmt.Sprintf("Database %s 的 UID 与已记录绑定不同;请核实同名重建,未接管新对象", d.Identity.Name)}
|
||||
}
|
||||
if d.Deleting || d.Phase == "Released" || d.Phase == Deleting {
|
||||
return &Issue{Conflict, "Database 正在删除或处于 Released;请由管理员核实并处理,未重新分配"}
|
||||
}
|
||||
if d.Tenant != nil {
|
||||
if *d.Tenant != tenant.Identity {
|
||||
return &Issue{Conflict, fmt.Sprintf("Database %s 已绑定 Tenant %s/%s(UID %s);未抢占",
|
||||
d.Identity.Name, d.Tenant.Namespace, d.Tenant.Name, d.Tenant.UID)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Phase != "Available" || !d.Ready {
|
||||
return &Issue{DependencyUnavailable, "Database 尚未完成验证并进入 Available,等待资源观察"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package binding
|
||||
|
||||
import "testing"
|
||||
|
||||
const (
|
||||
testNamespace = "apps"
|
||||
testApp = "app"
|
||||
testInstance = "shared"
|
||||
testOwner = "owner"
|
||||
testExisting = "existing"
|
||||
testOther = "other"
|
||||
)
|
||||
|
||||
func TestRequestResolve(t *testing.T) {
|
||||
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant-uid"}
|
||||
tests := []struct {
|
||||
name string
|
||||
request Request
|
||||
valid bool
|
||||
}{
|
||||
{"动态默认值", Request{Provision: &ProvisionRequest{Instance: testInstance}}, true},
|
||||
{"显式名称", Request{Provision: &ProvisionRequest{Instance: testInstance, Database: "custom", LoginRole: testOwner}}, true},
|
||||
{"已有资源", Request{ExistingDatabase: testExisting}, true},
|
||||
{"没有入口", Request{}, false},
|
||||
{"同时指定入口", Request{Provision: &ProvisionRequest{}, ExistingDatabase: testExisting}, false},
|
||||
{"非法库名", Request{Provision: &ProvisionRequest{Database: "bad-name"}}, false},
|
||||
{"非法角色名", Request{Provision: &ProvisionRequest{LoginRole: "bad-name"}}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
target, err := tc.request.Resolve(tenant)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("Resolve() = %v, valid = %v", err, tc.valid)
|
||||
}
|
||||
if !tc.valid {
|
||||
return
|
||||
}
|
||||
if tc.request.Provision == nil {
|
||||
if target.Name != testExisting || target.Provision != nil {
|
||||
t.Fatal("已有资源不应推导 Instance 或供应请求")
|
||||
}
|
||||
return
|
||||
}
|
||||
if target.Name != "tenant-tenant-uid" || target.Provision == tc.request.Provision {
|
||||
t.Fatal("目标名称不稳定,或 Resolve 未复制输入")
|
||||
}
|
||||
if tc.request.Provision.Database == "" && target.Provision.Database != tenant.Name {
|
||||
t.Fatal("数据库默认名称不符")
|
||||
}
|
||||
if tc.request.Provision.LoginRole == "" && target.Provision.LoginRole != tenant.Name {
|
||||
t.Fatal("角色默认名称不符")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseCanBind(t *testing.T) {
|
||||
tenant := Tenant{Identity: TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "current"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
change func(*Database, *Tenant)
|
||||
reason string
|
||||
}{
|
||||
{"空闲且就绪", func(*Database, *Tenant) {}, ""},
|
||||
{"同一绑定重试", func(d *Database, t *Tenant) { d.Tenant = &t.Identity; d.Ready = false }, ""},
|
||||
{"尚未观察", func(d *Database, _ *Tenant) { d.Ready = false }, DependencyUnavailable},
|
||||
{"尚未Available", func(d *Database, _ *Tenant) { d.Phase = "Pending" }, DependencyUnavailable},
|
||||
{"Released", func(d *Database, _ *Tenant) { d.Phase = "Released" }, Conflict},
|
||||
{"删除标记", func(d *Database, _ *Tenant) { d.Deleting = true }, Conflict},
|
||||
{"删除阶段", func(d *Database, _ *Tenant) { d.Phase = Deleting }, Conflict},
|
||||
{"已被占用", func(d *Database, _ *Tenant) { d.Tenant = &TenantIdentity{UID: testOther} }, Conflict},
|
||||
{"同名新申请", func(d *Database, t *Tenant) { old := t.Identity; old.UID = "old"; d.Tenant = &old }, Conflict},
|
||||
{"同名新资源", func(_ *Database, t *Tenant) { t.Database = &Identity{Name: "resource", UID: "old"} }, Conflict},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
database := Database{Identity: Identity{Name: "resource", UID: "database-uid"}, Phase: "Available", Ready: true}
|
||||
currentTenant := tenant
|
||||
tc.change(&database, ¤tTenant)
|
||||
checkIssue(t, database.CanBind(currentTenant), tc.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceCheck(t *testing.T) {
|
||||
instance := Instance{Identity: Identity{UID: "instance"}, Ready: true}
|
||||
checkIssue(t, instance.Check(nil), "")
|
||||
checkIssue(t, instance.Check(&Database{InstanceUID: "instance"}), "")
|
||||
checkIssue(t, instance.Check(&Database{InstanceUID: "replaced"}), Conflict)
|
||||
instance.Ready = false
|
||||
checkIssue(t, instance.Check(nil), DependencyUnavailable)
|
||||
instance.Ready, instance.Deleting = true, true
|
||||
checkIssue(t, instance.Check(nil), DependencyUnavailable)
|
||||
}
|
||||
|
||||
func TestMatchesProvision(t *testing.T) {
|
||||
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant"}
|
||||
target := Target{Provision: &ProvisionRequest{Instance: testInstance, Database: testApp, LoginRole: testOwner}}
|
||||
database := Database{Source: "Provision", Instance: testInstance, Name: testApp, LoginRole: testOwner, Tenant: &tenant}
|
||||
if !database.MatchesProvision(target, tenant) {
|
||||
t.Fatal("相同目标与身份应允许重试")
|
||||
}
|
||||
mutations := []func(*Database){
|
||||
func(d *Database) { d.Source = "Import" },
|
||||
func(d *Database) { d.Instance = testOther },
|
||||
func(d *Database) { d.Name = testOther },
|
||||
func(d *Database) { d.LoginRole = testOther },
|
||||
func(d *Database) { d.Tenant = nil },
|
||||
func(d *Database) { d.Tenant = &TenantIdentity{UID: testOther} },
|
||||
}
|
||||
for _, mutate := range mutations {
|
||||
changed := database
|
||||
mutate(&changed)
|
||||
if changed.MatchesProvision(target, tenant) {
|
||||
t.Fatal("不匹配的记录不能仅靠名称被认领")
|
||||
}
|
||||
}
|
||||
if database.MatchesProvision(Target{}, tenant) {
|
||||
t.Fatal("已有资源申请不是动态供应重试")
|
||||
}
|
||||
}
|
||||
|
||||
func checkIssue(t *testing.T, issue *Issue, reason string) {
|
||||
t.Helper()
|
||||
if reason == "" {
|
||||
if issue != nil {
|
||||
t.Fatalf("不应拒绝: %+v", issue)
|
||||
}
|
||||
return
|
||||
}
|
||||
if issue == nil || issue.Reason != reason || issue.Message == "" {
|
||||
t.Fatalf("issue = %+v, want %s 与可读诊断", issue, reason)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// CredentialReferenceValues contains effective field mappings, not secret data.
|
||||
// The application supplies defaults and fixes the namespace to the controller's.
|
||||
// Namespace and provider-specific paths are deliberately not selectable here.
|
||||
type CredentialReferenceValues struct {
|
||||
Name string
|
||||
UsernameKey string
|
||||
PasswordKey string
|
||||
}
|
||||
|
||||
// CredentialReference is an immutable reference to a management Secret.
|
||||
// Its zero value is invalid; aggregate construction must Validate incoming values.
|
||||
type CredentialReference struct {
|
||||
values CredentialReferenceValues
|
||||
}
|
||||
|
||||
// Instance and Secret names share the DNS subdomain syntax and 253-character limit.
|
||||
var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||
|
||||
func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) {
|
||||
reference := CredentialReference{values: values}
|
||||
if err := reference.Validate(); err != nil {
|
||||
return CredentialReference{}, err
|
||||
}
|
||||
return reference, nil
|
||||
}
|
||||
|
||||
// Values returns a copy of the reference, never secret contents.
|
||||
func (r CredentialReference) Values() CredentialReferenceValues { return r.values }
|
||||
|
||||
// Validate enforces reference invariants without accessing Kubernetes or OpenBao.
|
||||
// Checking that the referenced Secret contains nonempty credentials is an application
|
||||
// responsibility. Errors omit input values and no implicit defaults are applied.
|
||||
func (r CredentialReference) Validate() error {
|
||||
if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) {
|
||||
return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters")
|
||||
}
|
||||
if r.values.UsernameKey == "" {
|
||||
return errors.New("management Secret username field is required")
|
||||
}
|
||||
if r.values.PasswordKey == "" {
|
||||
return errors.New("management Secret password field is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func validCredentialReference() instance.CredentialReferenceValues {
|
||||
return instance.CredentialReferenceValues{
|
||||
Name: "shared-postgresql-admin", UsernameKey: "username", PasswordKey: "password",
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance: docs/database/domain-instance.md §2. References carry names, never credentials or IO.
|
||||
func TestCredentialReferenceRejectsInvalidValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*instance.CredentialReferenceValues)
|
||||
}{
|
||||
{"empty name", func(v *instance.CredentialReferenceValues) { v.Name = "" }},
|
||||
{"uppercase", func(v *instance.CredentialReferenceValues) { v.Name = "Admin" }},
|
||||
{"underscore", func(v *instance.CredentialReferenceValues) { v.Name = "pg_admin" }},
|
||||
{"leading hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "-admin" }},
|
||||
{"trailing hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "admin-" }},
|
||||
{"empty label", func(v *instance.CredentialReferenceValues) { v.Name = "pg..admin" }},
|
||||
{"trailing dot", func(v *instance.CredentialReferenceValues) { v.Name = "pg." }},
|
||||
{"namespace or path", func(v *instance.CredentialReferenceValues) { v.Name = "system/admin" }},
|
||||
{"whitespace", func(v *instance.CredentialReferenceValues) { v.Name = " admin" }},
|
||||
{"too long", func(v *instance.CredentialReferenceValues) { v.Name = strings.Repeat("a", 254) }},
|
||||
{"empty username key", func(v *instance.CredentialReferenceValues) { v.UsernameKey = "" }},
|
||||
{"empty password key", func(v *instance.CredentialReferenceValues) { v.PasswordKey = "" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := validCredentialReference()
|
||||
tc.change(&values)
|
||||
reference, err := instance.NewCredentialReference(values)
|
||||
if err == nil {
|
||||
t.Fatal("invalid credential reference accepted")
|
||||
}
|
||||
if reference != (instance.CredentialReference{}) {
|
||||
t.Fatal("constructor returned a partial reference on failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialReferencePreservesExplicitValues(t *testing.T) {
|
||||
for _, name := range []string{"a", "1", "pg.admin-1", strings.Repeat("a", 253)} {
|
||||
values := validCredentialReference()
|
||||
values.Name = name
|
||||
values.UsernameKey = "PG_USER"
|
||||
values.PasswordKey = "pg.password"
|
||||
reference, err := instance.NewCredentialReference(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reference.Values() != values {
|
||||
t.Fatal("constructor changed the explicit field mapping")
|
||||
}
|
||||
if err := reference.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialReferenceIsAnImmutableComparableValue(t *testing.T) {
|
||||
values := validCredentialReference()
|
||||
reference, err := instance.NewCredentialReference(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := instance.NewCredentialReference(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reference != same {
|
||||
t.Fatal("identical references must compare equal")
|
||||
}
|
||||
values.Name = "different"
|
||||
snapshot := reference.Values()
|
||||
snapshot.PasswordKey = "different-key"
|
||||
if reference.Values() != validCredentialReference() {
|
||||
t.Fatal("caller mutated reference through a copy")
|
||||
}
|
||||
if err := (instance.CredentialReference{}).Validate(); err == nil {
|
||||
t.Fatal("zero reference must be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialReferenceErrorOmitsInput(t *testing.T) {
|
||||
values := validCredentialReference()
|
||||
values.Name = "canary-sensitive/input"
|
||||
_, err := instance.NewCredentialReference(values)
|
||||
if err == nil {
|
||||
t.Fatal("invalid reference accepted")
|
||||
}
|
||||
if strings.Contains(err.Error(), "canary") {
|
||||
t.Fatal("error included input")
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package instance contains the pure domain model of a registered PostgreSQL instance.
|
||||
// It does not depend on Kubernetes types, database drivers or credential providers.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// TLSMode is an explicit transport policy, not a driver-specific default.
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
TLSDisable TLSMode = "disable"
|
||||
TLSRequire TLSMode = "require"
|
||||
TLSVerifyCA TLSMode = "verify-ca"
|
||||
TLSVerifyFull TLSMode = "verify-full"
|
||||
)
|
||||
|
||||
// EndpointValues carries explicit, effective values across the application boundary.
|
||||
// Defaults are supplied by the API/application mapping, never silently by the domain.
|
||||
type EndpointValues struct {
|
||||
Host string
|
||||
HostAddr string
|
||||
Port int
|
||||
ManagementDatabase string
|
||||
TLSMode TLSMode
|
||||
}
|
||||
|
||||
// Endpoint is an immutable connection target. Equality compares its declared values,
|
||||
// not physical server identity. Its zero value is invalid; aggregate construction
|
||||
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
|
||||
type Endpoint struct {
|
||||
values EndpointValues
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
func NewEndpoint(values EndpointValues) (Endpoint, error) {
|
||||
endpoint := Endpoint{values: values}
|
||||
if err := endpoint.Validate(); err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// Values returns a copy, without exposing mutable state.
|
||||
func (e Endpoint) Values() EndpointValues { return e.values }
|
||||
|
||||
// Validate checks local invariants only; it does not resolve DNS or perform IO.
|
||||
// Errors intentionally omit input values.
|
||||
func (e Endpoint) Validate() error {
|
||||
if e.values.Host == "" {
|
||||
return errors.New("endpoint host is required")
|
||||
}
|
||||
address, err := netip.ParseAddr(e.values.HostAddr)
|
||||
if err != nil || address.Zone() != "" {
|
||||
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
|
||||
}
|
||||
if e.values.Port < 1 || e.values.Port > 65535 {
|
||||
return errors.New("endpoint port must be between 1 and 65535")
|
||||
}
|
||||
if !identifier.MatchString(e.values.ManagementDatabase) {
|
||||
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
|
||||
}
|
||||
switch e.values.TLSMode {
|
||||
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
|
||||
return nil
|
||||
default:
|
||||
return errors.New("endpoint TLS mode must be explicitly supported")
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func validEndpoint() instance.EndpointValues {
|
||||
return instance.EndpointValues{
|
||||
Host: "postgres.home.arpa", HostAddr: "192.0.2.10", Port: 5432,
|
||||
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance: docs/database/domain-instance.md §2, explicit values and no implicit TLS downgrade.
|
||||
func TestEndpointRejectsInvalidValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*instance.EndpointValues)
|
||||
}{
|
||||
{"empty host", func(v *instance.EndpointValues) { v.Host = "" }},
|
||||
{"missing address", func(v *instance.EndpointValues) { v.HostAddr = "" }},
|
||||
{"DNS instead of IP", func(v *instance.EndpointValues) { v.HostAddr = "postgres.home.arpa" }},
|
||||
{"invalid IP", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.999" }},
|
||||
{"address with port", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.10:5432" }},
|
||||
{"scoped address", func(v *instance.EndpointValues) { v.HostAddr = "fe80::1%eth0" }},
|
||||
{"zero port", func(v *instance.EndpointValues) { v.Port = 0 }},
|
||||
{"negative port", func(v *instance.EndpointValues) { v.Port = -1 }},
|
||||
{"large port", func(v *instance.EndpointValues) { v.Port = 65536 }},
|
||||
{"empty database", func(v *instance.EndpointValues) { v.ManagementDatabase = "" }},
|
||||
{"uppercase database", func(v *instance.EndpointValues) { v.ManagementDatabase = "Postgres" }},
|
||||
{"leading digit", func(v *instance.EndpointValues) { v.ManagementDatabase = "1postgres" }},
|
||||
{"punctuation", func(v *instance.EndpointValues) { v.ManagementDatabase = "post-gres" }},
|
||||
{"NUL", func(v *instance.EndpointValues) { v.ManagementDatabase = "post\x00gres" }},
|
||||
{"long identifier", func(v *instance.EndpointValues) { v.ManagementDatabase = strings.Repeat("a", 64) }},
|
||||
{"missing TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "" }},
|
||||
{"unsupported TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "prefer" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := validEndpoint()
|
||||
tc.change(&values)
|
||||
endpoint, err := instance.NewEndpoint(values)
|
||||
if err == nil {
|
||||
t.Fatal("invalid endpoint accepted")
|
||||
}
|
||||
if endpoint != (instance.Endpoint{}) {
|
||||
t.Fatal("constructor returned a partial endpoint on failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointPreservesValidValues(t *testing.T) {
|
||||
for _, mode := range []instance.TLSMode{
|
||||
instance.TLSDisable, instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull,
|
||||
} {
|
||||
for _, address := range []string{"192.0.2.10", "2001:db8::10"} {
|
||||
for _, port := range []int{1, 65535} {
|
||||
values := validEndpoint()
|
||||
values.TLSMode, values.HostAddr, values.Port = mode, address, port
|
||||
values.ManagementDatabase = "a" + strings.Repeat("_", 62)
|
||||
endpoint, err := instance.NewEndpoint(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if endpoint.Values() != values {
|
||||
t.Fatal("constructor changed explicit values")
|
||||
}
|
||||
if err := endpoint.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointIsAnImmutableComparableValue(t *testing.T) {
|
||||
values := validEndpoint()
|
||||
endpoint, err := instance.NewEndpoint(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := instance.NewEndpoint(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if endpoint != same {
|
||||
t.Fatal("identical endpoint values must compare equal")
|
||||
}
|
||||
values.Host = "changed.example"
|
||||
snapshot := endpoint.Values()
|
||||
snapshot.Host = values.Host
|
||||
if endpoint.Values().Host == snapshot.Host {
|
||||
t.Fatal("caller mutated endpoint through a copy")
|
||||
}
|
||||
if err := (instance.Endpoint{}).Validate(); err == nil {
|
||||
t.Fatal("zero endpoint must not be valid")
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "slices"
|
||||
|
||||
// ExtensionSet is an immutable set of exact names. Zero represents the empty set.
|
||||
// It does not impose identifier syntax or claim that a server supports any name.
|
||||
type ExtensionSet struct {
|
||||
names []string
|
||||
}
|
||||
|
||||
func NewExtensionSet(names []string) ExtensionSet {
|
||||
copied := slices.Clone(names)
|
||||
slices.Sort(copied)
|
||||
return ExtensionSet{names: slices.Compact(copied)}
|
||||
}
|
||||
|
||||
// Names returns a sorted, deduplicated copy.
|
||||
func (s ExtensionSet) Names() []string { return slices.Clone(s.names) }
|
||||
|
||||
type ExtensionDecision string
|
||||
|
||||
const (
|
||||
ExtensionsAccepted ExtensionDecision = "Accepted"
|
||||
ExtensionsUnsupported ExtensionDecision = "ExtensionsUnsupported"
|
||||
ExtensionSupportUnobserved ExtensionDecision = "ExtensionSupportUnobserved"
|
||||
)
|
||||
|
||||
// ExtensionCheck reports support only, not readiness or permission to install.
|
||||
// Unsupported is a detached, sorted list and is populated only for known support.
|
||||
type ExtensionCheck struct {
|
||||
Decision ExtensionDecision
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
// ExtensionSupport is the extension-list component of an Instance observation.
|
||||
// Zero means unobserved, not an observed empty list. Target/revision binding and
|
||||
// invalidation belong to the containing Instance observation, not this set value.
|
||||
type ExtensionSupport struct {
|
||||
observed bool
|
||||
available ExtensionSet
|
||||
}
|
||||
|
||||
// ObserveExtensionSupport records a successfully read list, including an empty one.
|
||||
// A failed query must not call this constructor with an empty list: the application
|
||||
// must propagate the dependency failure and leave support unobserved.
|
||||
func ObserveExtensionSupport(available []string) ExtensionSupport {
|
||||
return ExtensionSupport{observed: true, available: NewExtensionSet(available)}
|
||||
}
|
||||
|
||||
// Check performs no IO and cannot install or remove extensions.
|
||||
func (s ExtensionSupport) Check(requested ExtensionSet) ExtensionCheck {
|
||||
if len(requested.names) == 0 {
|
||||
return ExtensionCheck{Decision: ExtensionsAccepted}
|
||||
}
|
||||
if !s.observed {
|
||||
return ExtensionCheck{Decision: ExtensionSupportUnobserved}
|
||||
}
|
||||
var unsupported []string
|
||||
for _, name := range requested.names {
|
||||
if _, found := slices.BinarySearch(s.available.names, name); !found {
|
||||
unsupported = append(unsupported, name)
|
||||
}
|
||||
}
|
||||
if len(unsupported) != 0 {
|
||||
return ExtensionCheck{Decision: ExtensionsUnsupported, Unsupported: unsupported}
|
||||
}
|
||||
return ExtensionCheck{Decision: ExtensionsAccepted}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const (
|
||||
testUUID = "uuid-ossp"
|
||||
testTrigram = "pg_trgm"
|
||||
testVector = "vector"
|
||||
testChanged = "changed"
|
||||
)
|
||||
|
||||
// Acceptance: docs/database/domain-instance.md, extension support is based on observations,
|
||||
// not a name regexp or an administrator allowlist.
|
||||
func TestExtensionSupportDecisions(t *testing.T) {
|
||||
available := instance.ObserveExtensionSupport([]string{testTrigram, testUUID})
|
||||
cases := []struct {
|
||||
name string
|
||||
support instance.ExtensionSupport
|
||||
requested []string
|
||||
want instance.ExtensionDecision
|
||||
unsupported []string
|
||||
}{
|
||||
{"unobserved", instance.ExtensionSupport{}, []string{testTrigram}, instance.ExtensionSupportUnobserved, nil},
|
||||
{"observed empty", instance.ObserveExtensionSupport(nil), []string{testTrigram}, instance.ExtensionsUnsupported, []string{testTrigram}},
|
||||
{"empty request", instance.ExtensionSupport{}, nil, instance.ExtensionsAccepted, nil},
|
||||
{"supported", available, []string{testUUID, testTrigram, testTrigram}, instance.ExtensionsAccepted, nil},
|
||||
{"unsupported", available, []string{testVector, "hstore", testVector, testTrigram},
|
||||
instance.ExtensionsUnsupported, []string{"hstore", testVector}},
|
||||
{"exact names", available, []string{"PG_TRGM"}, instance.ExtensionsUnsupported, []string{"PG_TRGM"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.support.Check(instance.NewExtensionSet(tc.requested))
|
||||
if result.Decision != tc.want || !slices.Equal(result.Unsupported, tc.unsupported) {
|
||||
t.Fatalf("Check() = %v, want %v / %v", result, tc.want, tc.unsupported)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionSetCopiesAndCanonicalizesNames(t *testing.T) {
|
||||
input := []string{testUUID, testTrigram, testUUID}
|
||||
set := instance.NewExtensionSet(input)
|
||||
input[0] = testChanged
|
||||
names := set.Names()
|
||||
want := []string{testTrigram, testUUID}
|
||||
if !slices.Equal(names, want) {
|
||||
t.Fatalf("Names() = %v, want %v", names, want)
|
||||
}
|
||||
names[0] = testChanged
|
||||
if !slices.Equal(set.Names(), want) {
|
||||
t.Fatal("returned slice mutated set")
|
||||
}
|
||||
if len((instance.ExtensionSet{}).Names()) != 0 {
|
||||
t.Fatal("zero set must be empty")
|
||||
}
|
||||
// Names are preserved exactly; actual server support, not a local regexp, is decisive.
|
||||
unusual := []string{"Vendor.Extension", testUUID}
|
||||
if result := instance.ObserveExtensionSupport(unusual).Check(instance.NewExtensionSet(unusual)); result.Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("imposed a local name restriction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionSupportCopiesObservationAndResults(t *testing.T) {
|
||||
input := []string{testTrigram}
|
||||
support := instance.ObserveExtensionSupport(input)
|
||||
input[0] = testVector
|
||||
requested := instance.NewExtensionSet([]string{testTrigram, testVector})
|
||||
result := support.Check(requested)
|
||||
if !slices.Equal(result.Unsupported, []string{testVector}) {
|
||||
t.Fatal("input mutation changed observation")
|
||||
}
|
||||
result.Unsupported[0] = testChanged
|
||||
again := support.Check(requested)
|
||||
if !slices.Equal(again.Unsupported, []string{testVector}) {
|
||||
t.Fatal("result mutation changed subsequent decision")
|
||||
}
|
||||
// Replacing an observation does not mutate the old value or produce uninstall actions.
|
||||
empty := instance.ObserveExtensionSupport(nil)
|
||||
if empty.Check(requested).Decision != instance.ExtensionsUnsupported {
|
||||
t.Fatal("empty observation ignored")
|
||||
}
|
||||
if support.Check(instance.NewExtensionSet([]string{testTrigram})).Decision != instance.ExtensionsAccepted {
|
||||
t.Fatal("new observation mutated old value")
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "errors"
|
||||
|
||||
// Identity identifies one registration, not a physical PostgreSQL server.
|
||||
// UID is opaque; a recreated resource with the same name has a different identity.
|
||||
// Its zero value is invalid and must be rejected when constructing an aggregate.
|
||||
type Identity struct {
|
||||
uid string
|
||||
name string
|
||||
}
|
||||
|
||||
func NewIdentity(uid, name string) (Identity, error) {
|
||||
identity := Identity{uid: uid, name: name}
|
||||
if err := identity.Validate(); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (i Identity) UID() string { return i.uid }
|
||||
func (i Identity) Name() string { return i.name }
|
||||
|
||||
// Validate checks registration values without looking up any external identity.
|
||||
func (i Identity) Validate() error {
|
||||
if i.uid == "" {
|
||||
return errors.New("instance UID is required")
|
||||
}
|
||||
if len(i.name) > 253 || !dnsSubdomainName.MatchString(i.name) {
|
||||
return errors.New("instance name must be a valid DNS subdomain of at most 253 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revision is a positive configuration generation, separate from Identity.
|
||||
// Zero is invalid for desired configuration; an unobserved status generation of
|
||||
// zero must be represented separately when the aggregate is implemented.
|
||||
type Revision struct {
|
||||
value int64
|
||||
}
|
||||
|
||||
func NewRevision(value int64) (Revision, error) {
|
||||
revision := Revision{value: value}
|
||||
if err := revision.Validate(); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (r Revision) Value() int64 { return r.value }
|
||||
|
||||
func (r Revision) Validate() error {
|
||||
if r.value <= 0 {
|
||||
return errors.New("instance revision must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// Acceptance: docs/database/domain-instance.md §2: registration identity is UID + name,
|
||||
// independent of the configuration revision or physical PostgreSQL server.
|
||||
func TestIdentityDistinguishesRecreatedInstances(t *testing.T) {
|
||||
first, err := instance.NewIdentity("opaque-uid-1", "shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := instance.NewIdentity("opaque-uid-1", "shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recreated, err := instance.NewIdentity("opaque-uid-2", "shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != same || first == recreated {
|
||||
t.Fatal("identity must distinguish same-name registrations by UID")
|
||||
}
|
||||
if first.UID() != "opaque-uid-1" || first.Name() != "shared" {
|
||||
t.Fatal("identity changed declared values")
|
||||
}
|
||||
if err := first.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityValidation(t *testing.T) {
|
||||
for _, name := range []string{"", "Shared", "shared_name", "ns/shared", "-shared", "pg..shared", strings.Repeat("a", 254)} {
|
||||
identity, err := instance.NewIdentity("uid", name)
|
||||
if err == nil || identity != (instance.Identity{}) {
|
||||
t.Fatal("invalid name accepted or partial identity returned")
|
||||
}
|
||||
}
|
||||
if _, err := instance.NewIdentity("", "shared"); err == nil {
|
||||
t.Fatal("empty UID accepted")
|
||||
}
|
||||
if err := (instance.Identity{}).Validate(); err == nil {
|
||||
t.Fatal("zero identity accepted")
|
||||
}
|
||||
for _, name := range []string{"a", "1", "pg.shared-1", strings.Repeat("a", 253)} {
|
||||
if _, err := instance.NewIdentity("opaque-not-a-uuid", name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionRequiresPositiveValue(t *testing.T) {
|
||||
for _, value := range []int64{math.MinInt64, -1, 0} {
|
||||
revision, err := instance.NewRevision(value)
|
||||
if err == nil || revision != (instance.Revision{}) {
|
||||
t.Fatal("invalid revision accepted or partial value returned")
|
||||
}
|
||||
}
|
||||
for _, value := range []int64{1, 2, math.MaxInt64} {
|
||||
revision, err := instance.NewRevision(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revision.Value() != value {
|
||||
t.Fatal("revision changed declared value")
|
||||
}
|
||||
if err := revision.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := instance.NewRevision(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revision != same {
|
||||
t.Fatal("identical revisions must compare equal")
|
||||
}
|
||||
}
|
||||
if err := (instance.Revision{}).Validate(); err == nil {
|
||||
t.Fatal("zero revision accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance
|
||||
|
||||
import "errors"
|
||||
|
||||
// Phase is a workflow checkpoint, never evidence of external resource state.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePending Phase = "Pending"
|
||||
PhaseValidating Phase = "Validating"
|
||||
PhaseReady Phase = "Ready"
|
||||
PhaseDeleting Phase = "Deleting"
|
||||
)
|
||||
|
||||
type Readiness string
|
||||
|
||||
const (
|
||||
Unknown Readiness = "Unknown"
|
||||
Ready Readiness = "Ready"
|
||||
NotReady Readiness = "NotReady"
|
||||
)
|
||||
|
||||
// Snapshot contains persisted observations only, without credentials or live evidence.
|
||||
type Snapshot struct {
|
||||
Phase Phase
|
||||
ObservedRevision int64
|
||||
Readiness Readiness
|
||||
ReportedVersion string
|
||||
Failure Failure
|
||||
}
|
||||
|
||||
// Instance protects registration state and pure lifecycle transitions.
|
||||
// Reconstitution does not establish live capability evidence, even for a Ready snapshot.
|
||||
type Instance struct {
|
||||
target ObservationTarget
|
||||
snapshot Snapshot
|
||||
deleting bool
|
||||
extensions ExtensionSupport
|
||||
evidence *CapabilityObservation
|
||||
}
|
||||
|
||||
func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) {
|
||||
if err := target.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch snapshot.Phase {
|
||||
case PhasePending, PhaseValidating, PhaseReady, PhaseDeleting:
|
||||
default:
|
||||
snapshot.Phase = PhasePending
|
||||
snapshot.Readiness = Unknown
|
||||
}
|
||||
return &Instance{target: target, snapshot: snapshot, deleting: deleting}, nil
|
||||
}
|
||||
|
||||
func (i *Instance) Target() ObservationTarget { return i.target }
|
||||
|
||||
// Snapshot returns a detached value. Persisting it remains the application's job.
|
||||
func (i *Instance) Snapshot() Snapshot { return i.snapshot }
|
||||
|
||||
// BeginValidation records intent only; it does not claim a concluded observation.
|
||||
func (i *Instance) BeginValidation() error {
|
||||
if err := i.target.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i.deleting {
|
||||
return errors.New("cannot begin validation after deletion was requested")
|
||||
}
|
||||
i.snapshot.Phase = PhaseValidating
|
||||
i.snapshot.Readiness = Unknown
|
||||
i.extensions = ExtensionSupport{}
|
||||
i.evidence = nil
|
||||
i.snapshot.Failure = NoFailure
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginDeletion stops the lifecycle from accepting validation. It does not delete
|
||||
// resources, inspect Tenant references, close connections or modify finalizers.
|
||||
func (i *Instance) BeginDeletion() error {
|
||||
if err := i.target.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !i.deleting {
|
||||
return errors.New("cannot begin deletion without a deletion request")
|
||||
}
|
||||
i.snapshot.Phase = PhaseDeleting
|
||||
i.snapshot.Readiness = Unknown
|
||||
i.extensions = ExtensionSupport{}
|
||||
i.evidence = nil
|
||||
i.snapshot.Failure = NoFailure
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObserveExtensions accepts facts only for this registration and configuration.
|
||||
// Unobserved support clears a previous list after a failed read; the application
|
||||
// reports the dependency failure separately. This does not establish readiness.
|
||||
// Same-target freshness and Secret refresh are enforced by the application.
|
||||
func (i *Instance) ObserveExtensions(target ObservationTarget, support ExtensionSupport) error {
|
||||
if !i.target.Matches(target) {
|
||||
return errors.New("extension observation target does not match instance")
|
||||
}
|
||||
if i.deleting {
|
||||
return errors.New("cannot accept extension observations after deletion was requested")
|
||||
}
|
||||
i.extensions = support
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckExtensions checks support only; Accepted is not authorization to provision.
|
||||
// The aggregate does not perform IO, mutate its snapshot or uninstall extensions.
|
||||
func (i *Instance) CheckExtensions(requested ExtensionSet) ExtensionCheck {
|
||||
return i.extensions.Check(requested)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// Acceptance: docs/database/domain-instance.md §2/§3, extension observations are
|
||||
// bound to the current registration and definition and never authorize provisioning.
|
||||
func TestInstanceAcceptsExtensionObservationForCurrentTarget(t *testing.T) {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
|
||||
target := value.Target()
|
||||
snapshot := value.Snapshot()
|
||||
|
||||
if err := value.ObserveExtensions(target, instance.ObserveExtensionSupport([]string{testTrigram})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := value.CheckExtensions(instance.NewExtensionSet([]string{testTrigram})); got.Decision != instance.ExtensionsAccepted {
|
||||
t.Fatalf("CheckExtensions() = %v, want accepted", got)
|
||||
}
|
||||
if value.Snapshot() != snapshot {
|
||||
t.Fatal("extension observation changed the persisted lifecycle snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceRejectsExtensionObservationForDifferentTarget(t *testing.T) {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
|
||||
identity, _, definition := targetParts(t)
|
||||
revision, err := instance.NewRevision(value.Target().Revision().Value() + 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different, err := instance.NewObservationTarget(identity, revision, definition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := value.ObserveExtensions(different, instance.ObserveExtensionSupport([]string{testTrigram})); err == nil {
|
||||
t.Fatal("observation for a different target was accepted")
|
||||
}
|
||||
if got := value.CheckExtensions(instance.NewExtensionSet([]string{testTrigram})); got.Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatalf("rejected observation changed support: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing.T) {
|
||||
requested := instance.NewExtensionSet([]string{testTrigram})
|
||||
|
||||
t.Run("validation", func(t *testing.T) {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, false)
|
||||
if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := value.BeginValidation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatalf("validation retained stale support: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deletion", func(t *testing.T) {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, true)
|
||||
if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err == nil {
|
||||
t.Fatal("deleting instance accepted a new observation")
|
||||
}
|
||||
if err := value.BeginDeletion(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatalf("deletion retained extension support: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInstanceCanExplicitlyInvalidateExtensionObservation(t *testing.T) {
|
||||
value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
|
||||
requested := instance.NewExtensionSet([]string{testTrigram})
|
||||
if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{testTrigram})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := value.ObserveExtensions(value.Target(), instance.ExtensionSupport{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved {
|
||||
t.Fatalf("explicit invalidation retained stale support: %v", got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user