Compare commits

...
Author SHA1 Message Date
panxiao81 3d91abe380 fix: 按 runner 默认提供 Docker 的约定仅执行预检
Verify / test (pull_request) Successful in 5m45s
Verify / lint (pull_request) Successful in 7m44s
Verify / database-integration (pull_request) Successful in 11m42s
2026-09-21 15:59:12 +00:00
panxiao81 fb2c34ce46 fix: 不将 Docker 数据目录独立挂载作为启动前置条件
Verify / database-integration (pull_request) Failing after 11m16s
Verify / lint (pull_request) Successful in 15m38s
Verify / test (pull_request) Successful in 16m2s
2026-09-21 15:51:10 +00:00
panxiao81 505aeb8e50 fix: 在 Pod CI 初始化 Docker 并保留 fixture 诊断
Verify / database-integration (pull_request) Failing after 58s
Verify / test (pull_request) Successful in 11m30s
Verify / lint (pull_request) Successful in 13m59s
2026-09-21 15:48:06 +00:00
panxiao81 c110aceb0f feat: 接入管理 Secret 凭据与连接刷新
Verify / test (pull_request) Successful in 4m45s
Verify / lint (pull_request) Successful in 7m5s
Verify / database-integration (pull_request) Failing after 11m1s
2026-09-21 08:35:24 +00:00
panxiao81 341c095a97 Merge pull request 'feat: 实现 Instance Ready 领域判定与恢复规则' (#5) from feat/database-instance-readiness into main
Verify / test (push) Successful in 11m36s
Verify / lint (push) Successful in 12m3s
Reviewed-on: #5
2026-09-21 07:58:31 +00:00
panxiao81 cd0d3a70ae feat: 实现 Instance Ready 领域判定与恢复规则
Verify / test (pull_request) Successful in 7m20s
Verify / lint (pull_request) Successful in 7m51s
2026-09-21 07:49:41 +00:00
panxiao81 984c0aee73 Merge pull request 'feat: 迁移 Database Instance 领域基线与扩展观测' (#4) from feat/database-instance-domain into main
Verify / test (push) Successful in 6m0s
Verify / lint (push) Successful in 6m23s
Reviewed-on: #4
2026-09-21 07:42:08 +00:00
panxiao81 813b4341d1 test: 复用扩展观测测试常量
Verify / test (pull_request) Successful in 5m43s
Verify / lint (pull_request) Successful in 6m3s
2026-09-21 07:33:10 +00:00
panxiao81 5a7b38ad26 feat: 接入 Instance 扩展能力观测
Verify / test (pull_request) Successful in 4m16s
Verify / lint (pull_request) Failing after 4m10s
2026-09-21 06:32:40 +00:00
panxiao81 8ac6283573 feat: 迁移 Database Instance 领域基线 2026-09-21 06:32:40 +00:00
panxiao81 33fb3ec972 Merge pull request 'docs: 明确 Ayatori 控制面与产品范围' (#3) from docs/ayatori-architecture into main
Verify / test (push) Successful in 4m39s
Verify / lint (push) Successful in 3m47s
2026-09-21 06:32:19 +00:00
panxiao81 86953cd91a docs: 对齐产品里程碑与集成验证要求
Verify / test (pull_request) Successful in 3m14s
Verify / lint (pull_request) Successful in 3m45s
2026-09-21 05:21:22 +00:00
panxiao81 ddd717209e chore: 添加 homelab 知识维护 skill
Verify / test (pull_request) Failing after 36s
Verify / lint (pull_request) Failing after 39s
2026-09-21 05:14:04 +00:00
panxiao81 2756803ba4 docs: 保留 DBaaS 已批准设计合同
Verify / test (pull_request) Successful in 10m35s
Verify / lint (pull_request) Successful in 12m9s
2026-09-20 20:38:53 +00:00
panxiao81 76752c8443 docs: 统一 Database API 到 Ayatori 域 2026-09-20 20:38:53 +00:00
panxiao81 36138f835a docs: 允许 Database 模块无兼容负担重构 2026-09-20 20:38:52 +00:00
panxiao81 de8b7f9b04 docs: 记录 Compute 方向与 Database 模块合并 2026-09-20 20:38:51 +00:00
panxiao81 e6b9980b3e docs: 解耦内置 API 与上游实现组件 2026-09-20 20:38:50 +00:00
panxiao81 641db531a8 docs: 按实际管理缺口限定产品范围 2026-09-20 20:38:49 +00:00
panxiao81 7b841d7dba docs: 明确 API machinery 与领域控制循环边界 2026-09-20 20:38:48 +00:00
panxiao81 af001b6188 Merge pull request 'feat: scaffold job execution API' (#1) from feat/job-api-scaffold into main
Verify / test (push) Successful in 6m6s
Verify / lint (push) Successful in 6m40s
Reviewed-on: #1
2026-09-18 18:00:43 +00:00
panxiao81 7a972afaa6 test: ignore invalid embedlit suggestions
Verify / test (pull_request) Successful in 5m44s
Verify / lint (pull_request) Successful in 6m15s
2026-09-18 16:28:53 +00:00
panxiao81 5bd0455940 test: satisfy Go 1.27 modernization lint
Verify / test (pull_request) Failing after 5m11s
Verify / lint (pull_request) Failing after 6m5s
2026-09-17 19:06:39 +00:00
panxiao81 4846ff2aba feat: scaffold job execution API
Verify / lint (pull_request) Failing after 5m37s
Verify / test (pull_request) Successful in 5m38s
2026-09-17 18:58:11 +00:00
panxiao81 7a47221280 docs: design job execution APIs 2026-09-17 18:29:17 +00:00
panxiao81 f559ffebf9 docs: define ephemeral job retention 2026-09-17 17:42:48 +00:00
panxiao81 fd643da0ca docs: define modular controller boundaries 2026-09-17 17:16:54 +00:00
113 changed files with 11489 additions and 42 deletions
+76
View File
@@ -0,0 +1,76 @@
---
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.
+11
View File
@@ -0,0 +1,11 @@
# This file configures golangci-lint with module plugins.
# When you run 'make lint', it will automatically build a custom golangci-lint binary
# with all the plugins listed below.
#
# See: https://golangci-lint.run/plugins/module-plugins/
version: v2.13.1
plugins:
# logcheck validates structured logging calls and parameters (e.g., balanced key-value pairs)
- module: "sigs.k8s.io/logtools"
import: "sigs.k8s.io/logtools/logcheck/gclplugin"
version: latest
+14
View File
@@ -0,0 +1,14 @@
# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file
# Ignore everything by default and re-include only needed files
**
# Re-include Go source files (but not *_test.go)
# If you use Podman, re-include your source directories by name,
# such as !cmd, !api, and !internal.
# See https://github.com/containers/buildah/issues/6417
!**/*.go
**/*_test.go
# Re-include Go module files
!go.mod
!go.sum
+71
View File
@@ -0,0 +1,71 @@
name: Verify
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: [self-hosted, pod]
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
- name: Verify generated files and tests
run: |
make test
git diff --exit-code
lint:
runs-on: [self-hosted, pod]
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
- 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
+1
View File
@@ -2,6 +2,7 @@
/bin/ /bin/
/dist/ /dist/
/coverage/ /coverage/
/cover.out
# Local configuration and credentials # Local configuration and credentials
.env .env
+68
View File
@@ -0,0 +1,68 @@
version: "2"
run:
allow-parallel-runners: true
linters:
default: none
enable:
- copyloopvar
- depguard
- dupl
- errcheck
- ginkgolinter
- goconst
- gocyclo
- govet
- ineffassign
- lll
- modernize
- misspell
- nakedret
- prealloc
- revive
- staticcheck
- unconvert
- unparam
- unused
- logcheck
settings:
custom:
logcheck:
type: "module"
description: Checks Go logging calls for Kubernetes logging conventions.
depguard:
rules:
forbid-sort-pkg:
deny:
- pkg: sort
desc: Should be replaced with slices package
revive:
rules:
- name: comment-spacings
- name: import-shadowing
modernize:
disable:
- omitzero
exclusions:
generated: lax
rules:
- linters:
- lll
path: api/*
- linters:
- dupl
- lll
path: internal/*
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+39
View File
@@ -3,8 +3,47 @@
- 本仓库是 ddupan.top homelab 的内部基础设施控制平面,不以通用发行版为初期目标。 - 本仓库是 ddupan.top homelab 的内部基础设施控制平面,不以通用发行版为初期目标。
- 提交、文档和代码注释优先使用中文;公共 API 标识符和代码遵循对应语言惯例。 - 提交、文档和代码注释优先使用中文;公共 API 标识符和代码遵循对应语言惯例。
- 不要重新实现已有成熟后端的核心能力;新增实现前先确认能否通过稳定 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;应用应直接组合正交的平台资源。 - 不要引入统一包装所有能力的 Application CRD;应用应直接组合正交的平台资源。
- Proxmox VM 的北向管理不能假定单一 API 覆盖完整生命周期。允许按能力组合 Proxmox API、节点
上的受限强类型 Agent/CLI 操作和 ManualTask;节点 Agent 不得退化为无版本契约的任意远程 shell。
- 所有 controller 必须考虑幂等、observe、finalizer、conditions、删除策略和恢复行为。 - 所有 controller 必须考虑幂等、observe、finalizer、conditions、删除策略和恢复行为。
- Ayatori 会联动 Kubernetes API、虚拟化、存储、网络及其他外部控制面;集成测试是功能完成
标准的一部分,不得仅凭 fake client 或 mock 测试宣告 controller、adapter 或生命周期变更完成。
- 测试应按风险分层:纯领域规则使用快速单元测试;API schema、CEL、status subresource、
watch/cache、owner reference 和 reconcile 事件链使用 envtest;需要 scheduler、kubelet、网络、
存储或真实后端行为的路径在 Dev 集群或对应后端环境执行端到端测试。
- fake client 适合穷举状态机和错误分支,但它不会完整执行 API server defaulting、validation、
resourceVersion、garbage collection 或新版 Kubernetes 约束;涉及这些语义时必须增加真实 API
server 测试。跨 adapter 的共同契约应使用同一套 contract tests,避免各实现产生语义漂移。
- 集成测试必须覆盖正常路径以及幂等重试、controller 重启、依赖稍后出现、删除/finalizer、
后端结果不确定和并发竞态等恢复路径;无法在当前层测试的部分要明确记录由哪一层验证。
- Secret、token、kubeconfig 及具体生产凭据不得提交到仓库。 - Secret、token、kubeconfig 及具体生产凭据不得提交到仓库。
- `deploy/dev/` 与 `deploy/prod/` 使用相同制品;生产版本只通过 promotion 更新。 - `deploy/dev/` 与 `deploy/prod/` 使用相同制品;生产版本只通过 promotion 更新。
- 内部专用不构成降低测试、版本、恢复、安全和可审计要求的理由。 - 内部专用不构成降低测试、版本、恢复、安全和可审计要求的理由。
+33
View File
@@ -0,0 +1,33 @@
# Build the manager binary
# Override BASE_IMAGE to build from another registry, e.g. docker.io/library/golang:1.27.1
ARG BASE_IMAGE=golang:1.27.1
FROM ${BASE_IMAGE} AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
RUN go mod download
# Copy the Go source (relies on .dockerignore to filter)
COPY . .
# Build
# the GOARCH has no default value to allow the binary to be built according to the host where the command
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532
ENTRYPOINT ["/manager"]
+237
View File
@@ -0,0 +1,237 @@
# Image URL to use all building/pushing image targets
IMG ?= controller:latest
# YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header.
YEAR ?= $(shell date +%Y)
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
GOBIN=$(shell go env GOPATH)/bin
else
GOBIN=$(shell go env GOBIN)
endif
# CONTAINER_TOOL defines the container tool to be used for building images.
# Be aware that the target commands are only tested with Docker which is
# scaffolded by default. However, you might want to replace it to use other
# tools. (i.e. podman)
CONTAINER_TOOL ?= docker
# Setting SHELL to bash allows bash commands to be executed by recipes.
# Options are set to exit when a recipe line exits non-zero or a piped command fails.
SHELL = /usr/bin/env bash -o pipefail
.SHELLFLAGS = -ec
.PHONY: all
all: build
##@ General
# The help target prints out all targets with their descriptions organized
# beneath their categories. The categories are represented by '##@' and the
# target descriptions by '##'. The awk command is responsible for reading the
# entire set of makefiles included in this invocation, looking for lines of the
# file as xyz: ## something, and then pretty-format the target and help. Then,
# if there's a line with ##@ something, that gets pretty-printed as a category.
# More info on the usage of ANSI control characters for terminal formatting:
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
# More info on the awk command:
# http://linuxcommand.org/lc3_adv_awk.php
.PHONY: help
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
##@ Development
.PHONY: manifests
manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
"$(CONTROLLER_GEN)" rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
.PHONY: generate
generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
"$(CONTROLLER_GEN)" object paths="./..."
.PHONY: fmt
fmt: ## Run go fmt against code.
go fmt ./...
.PHONY: vet
vet: ## Run go vet against code.
go vet ./...
.PHONY: test
test: manifests generate fmt vet setup-envtest ## Run tests.
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test ./... -coverprofile cover.out
.PHONY: lint
lint: golangci-lint ## Run golangci-lint linter
"$(GOLANGCI_LINT)" run
.PHONY: test-database-integration
test-database-integration: setup-envtest ## 使用临时 API server 与独立 PostgreSQL 容器验证凭据读取和连接更新。
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -tags=integration -race -count=1 ./internal/database/...
.PHONY: lint-database-integration
lint-database-integration: golangci-lint ## 检查集成测试构建标签下的 Database 代码。
"$(GOLANGCI_LINT)" run --build-tags=integration ./internal/database/...
.PHONY: lint-fix
lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
"$(GOLANGCI_LINT)" run --fix
.PHONY: lint-config
lint-config: golangci-lint ## Verify golangci-lint linter configuration
"$(GOLANGCI_LINT)" config verify
##@ Build
.PHONY: build
build: manifests generate fmt vet ## Build manager binary.
go build -o bin/manager cmd/main.go
.PHONY: run
run: manifests generate fmt vet ## Run a controller from your host.
go run ./cmd/main.go
# If you wish to build the manager image targeting other platforms you can use the --platform flag.
# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
# More info: https://docs.docker.com/develop/develop-images/build_enhancements/
# Override BASE_IMAGE to build from another registry, e.g.
# make docker-build IMG=<img> BASE_IMAGE=docker.io/library/golang:1.27.1
.PHONY: docker-build
docker-build: ## Build docker image with the manager.
$(CONTAINER_TOOL) build $(if $(BASE_IMAGE),--build-arg BASE_IMAGE=$(BASE_IMAGE)) -t ${IMG} .
.PHONY: docker-push
docker-push: ## Push docker image with the manager.
$(CONTAINER_TOOL) push ${IMG}
# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple
# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to:
# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/
# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/
# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=<myregistry/image:<tag>> then the export will fail)
# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option.
PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le
.PHONY: docker-buildx
docker-buildx: ## Build and push docker image for the manager for cross-platform support
# copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile
sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross
- $(CONTAINER_TOOL) buildx create --name ayatori-builder
$(CONTAINER_TOOL) buildx use ayatori-builder
- $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) $(if $(BASE_IMAGE),--build-arg BASE_IMAGE=$(BASE_IMAGE)) --tag ${IMG} -f Dockerfile.cross .
- $(CONTAINER_TOOL) buildx rm ayatori-builder
rm Dockerfile.cross
.PHONY: build-installer
build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment.
mkdir -p dist
cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG}
"$(KUSTOMIZE)" build config/default > dist/install.yaml
##@ Deployment
ifndef ignore-not-found
ignore-not-found = false
endif
.PHONY: install
install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
@out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \
if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi
.PHONY: uninstall
uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
@out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \
if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi
.PHONY: deploy
deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG}
"$(KUSTOMIZE)" build config/default | "$(KUBECTL)" apply -f -
.PHONY: undeploy
undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
"$(KUSTOMIZE)" build config/default | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -
##@ Dependencies
## Location to install dependencies to
LOCALBIN ?= $(shell pwd)/bin
$(LOCALBIN):
mkdir -p "$(LOCALBIN)"
## Tool Binaries
KUBECTL ?= kubectl
KUSTOMIZE ?= $(LOCALBIN)/kustomize
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
ENVTEST ?= $(LOCALBIN)/setup-envtest
GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
## Tool Versions
KUSTOMIZE_VERSION ?= v5.8.1
CONTROLLER_TOOLS_VERSION ?= v0.22.0
#ENVTEST_VERSION is the controller-runtime version to use for setup-envtest, derived from go.mod
ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \
[ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \
printf '%s\n' "$$v")
#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \
[ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \
printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/')
GOLANGCI_LINT_VERSION ?= v2.13.1
.PHONY: kustomize
kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
$(KUSTOMIZE): $(LOCALBIN)
$(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION))
.PHONY: controller-gen
controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
$(CONTROLLER_GEN): $(LOCALBIN)
$(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))
.PHONY: setup-envtest
setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory.
@echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..."
@"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \
echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \
exit 1; \
}
.PHONY: envtest
envtest: $(ENVTEST) ## Download setup-envtest locally if necessary.
$(ENVTEST): $(LOCALBIN)
$(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION))
.PHONY: golangci-lint
golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
$(GOLANGCI_LINT): $(LOCALBIN)
$(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))
@test -f .custom-gcl.yml && { \
echo "Building custom golangci-lint with plugins..." && \
$(GOLANGCI_LINT) custom --destination $(LOCALBIN) --name golangci-lint-custom && \
mv -f $(LOCALBIN)/golangci-lint-custom $(GOLANGCI_LINT); \
} || true
# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist
# $1 - target path with name of binary
# $2 - package url which can be installed
# $3 - specific version of package
define go-install-tool
@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \
set -e; \
package=$(2)@$(3) ;\
echo "Downloading $${package}" ;\
rm -f "$(1)" ;\
GOBIN="$(LOCALBIN)" go install $${package} ;\
mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\
} ;\
ln -sf "$$(realpath "$(1)-$(3)")" "$(1)"
endef
define gomodver
$(shell go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null)
endef
+42
View File
@@ -0,0 +1,42 @@
# Code generated by tool. DO NOT EDIT.
# This file is used to track the info used to scaffold your project
# and allow the plugins properly work.
# More info: https://book.kubebuilder.io/reference/project-config.html
cliVersion: 4.16.0
domain: ayatori.ddupan.top
layout:
- go.kubebuilder.io/v4
multigroup: true
projectName: ayatori
repo: git.ddupan.top/panxiao81/ayatori
resources:
- api:
crdVersion: v1
namespaced: true
domain: ayatori.ddupan.top
group: execution
kind: Job
path: git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
domain: ayatori.ddupan.top
group: execution
kind: JobClass
path: git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
domain: ayatori.ddupan.top
group: execution
kind: KubernetesExecutionParameters
path: git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
domain: ayatori.ddupan.top
group: execution
kind: OpenSandboxExecutionParameters
path: git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1
version: v1alpha1
version: "3"
+7 -3
View File
@@ -28,11 +28,15 @@ Ayatori 是 `ddupan.top` homelab 的内部基础设施控制平面。它以 Kube
- [执行模型](docs/concepts/execution-model.md) - [执行模型](docs/concepts/execution-model.md)
- [环境与发布](docs/concepts/environments.md) - [环境与发布](docs/concepts/environments.md)
- [路线图](docs/roadmap.md) - [路线图](docs/roadmap.md)
- [ADR-0001:采用 Kubernetes API 作为资源模型](docs/decisions/0001-kubernetes-api-machinery.md) - [ADR-0001:采用 Kubernetes API machinery 作为状态协调平面](docs/decisions/0001-kubernetes-api-machinery.md)
- [ADR-0002:采用 k0s 与可选工作负载运行时](docs/decisions/0002-k0s-optional-workload-runtime.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-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)
## 当前状态 ## 当前状态
Ayatori 处于设计与早期实现阶段。第一个纵向切片计划是统一 Job API 与 Kubernetes Ayatori 处于设计与早期实现阶段。当前使用 Job controller 验证第一个完整控制循环与 adapter
Pod executor,随后接入 OpenSandbox executor。 边界;它不是通用 Job Service 或 FaaS 产品承诺。首批实际产品方向是 Database、LoadBalancer
和 Bucket/Object Storage,具体顺序按纵向价值决定。
@@ -0,0 +1,28 @@
// Package v1alpha1 contains API Schema definitions for the execution v1alpha1 API group.
// +kubebuilder:object:generate=true
// +groupName=execution.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 is group version used to register these objects.
// This name is used by applyconfiguration generators (e.g. controller-gen).
SchemeGroupVersion = schema.GroupVersion{Group: "execution.ayatori.ddupan.top", Version: "v1alpha1"}
// GroupVersion is an alias for SchemeGroupVersion, for backward compatibility.
GroupVersion = SchemeGroupVersion
// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
})
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
+180
View File
@@ -0,0 +1,180 @@
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
)
const (
JobConditionAccepted = "Accepted"
JobConditionScheduled = "Scheduled"
JobConditionSucceeded = "Succeeded"
)
type JobDesiredState string
const (
JobDesiredStateRunning JobDesiredState = "Running"
JobDesiredStateCancelled JobDesiredState = "Cancelled"
)
type TaskSpec struct {
// +kubebuilder:validation:MinLength=1
Image string `json:"image"`
// +optional
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
// +optional
Command []string `json:"command,omitempty"`
// +optional
Args []string `json:"args,omitempty"`
// +optional
WorkingDir string `json:"workingDir,omitempty"`
// +listType=map
// +listMapKey=name
// +optional
Env []EnvVar `json:"env,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="has(self.value) != has(self.valueFrom)",message="exactly one of value or valueFrom must be set"
type EnvVar struct {
// +kubebuilder:validation:Pattern=`^[A-Za-z_][A-Za-z0-9_]*$`
Name string `json:"name"`
// +optional
Value *string `json:"value,omitempty"`
// +optional
ValueFrom *EnvVarSource `json:"valueFrom,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="has(self.secretKeyRef) != has(self.configMapKeyRef)",message="exactly one key reference must be set"
type EnvVarSource struct {
// +optional
SecretKeyRef *corev1.SecretKeySelector `json:"secretKeyRef,omitempty"`
// +optional
ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"`
}
type ResourceValues struct {
// +optional
CPU *resource.Quantity `json:"cpu,omitempty"`
// +optional
Memory *resource.Quantity `json:"memory,omitempty"`
}
type ExecutionResourceRequirements struct {
// +optional
Requests ResourceValues `json:"requests,omitempty"`
// +optional
Limits ResourceValues `json:"limits,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="self.task == oldSelf.task",message="task is immutable"
// +kubebuilder:validation:XValidation:rule="self.resources == oldSelf.resources",message="resources are immutable"
// +kubebuilder:validation:XValidation:rule="has(self.activeDeadlineSeconds) == has(oldSelf.activeDeadlineSeconds) && (!has(self.activeDeadlineSeconds) || self.activeDeadlineSeconds == oldSelf.activeDeadlineSeconds)",message="activeDeadlineSeconds is immutable"
// +kubebuilder:validation:XValidation:rule="has(self.jobClassName) == has(oldSelf.jobClassName) && (!has(self.jobClassName) || self.jobClassName == oldSelf.jobClassName)",message="jobClassName is immutable"
// +kubebuilder:validation:XValidation:rule="oldSelf.desiredState == self.desiredState || (oldSelf.desiredState == 'Running' && self.desiredState == 'Cancelled')",message="desiredState may only transition from Running to Cancelled"
type JobSpec struct {
// +optional
JobClassName string `json:"jobClassName,omitempty"`
Task TaskSpec `json:"task"`
// +optional
Resources ExecutionResourceRequirements `json:"resources,omitempty"`
// +kubebuilder:validation:Minimum=1
// +optional
ActiveDeadlineSeconds *int64 `json:"activeDeadlineSeconds,omitempty"`
// +kubebuilder:validation:Minimum=0
// +optional
TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"`
// +kubebuilder:validation:Enum=Running;Cancelled
// +kubebuilder:default=Running
// +optional
DesiredState JobDesiredState `json:"desiredState,omitempty"`
}
type ParametersReference struct {
Group string `json:"group"`
Kind string `json:"kind"`
Name string `json:"name"`
// +optional
UID types.UID `json:"uid,omitempty"`
}
type ResolvedJobClassReference struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
ControllerName string `json:"controllerName"`
ParametersRef ParametersReference `json:"parametersRef"`
}
type ExecutionReference struct {
// +kubebuilder:validation:MinLength=1
Type string `json:"type"`
// +kubebuilder:validation:MinLength=1
ID string `json:"id"`
}
type ExecutionStatus struct {
Adapter string `json:"adapter"`
// +listType=map
// +listMapKey=type
// +optional
References []ExecutionReference `json:"references,omitempty"`
}
type JobResult struct {
// +optional
ExitCode *int32 `json:"exitCode,omitempty"`
// +optional
Reason string `json:"reason,omitempty"`
}
type JobStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// +optional
ResolvedJobClass *ResolvedJobClassReference `json:"resolvedJobClass,omitempty"`
// +optional
EffectiveResources ExecutionResourceRequirements `json:"effectiveResources,omitempty"`
// +optional
Execution *ExecutionStatus `json:"execution,omitempty"`
// +optional
StartTime *metav1.Time `json:"startTime,omitempty"`
// +optional
CompletionTime *metav1.Time `json:"completionTime,omitempty"`
// +optional
Result *JobResult `json:"result,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=='Accepted')].status`
// +kubebuilder:printcolumn:name="Scheduled",type=string,JSONPath=`.status.conditions[?(@.type=='Scheduled')].status`
// +kubebuilder:printcolumn:name="Succeeded",type=string,JSONPath=`.status.conditions[?(@.type=='Succeeded')].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Job struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec JobSpec `json:"spec"`
// +optional
Status JobStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type JobList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []Job `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &Job{}, &JobList{})
return nil
})
}
+71
View File
@@ -0,0 +1,71 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
const (
JobClassConditionAccepted = "Accepted"
JobClassConditionReady = "Ready"
)
type ExecutionResourcePolicy struct {
// +optional
Defaults ExecutionResourceRequirements `json:"defaults,omitempty"`
// +optional
Minimum ExecutionResourceRequirements `json:"minimum,omitempty"`
// +optional
Maximum ExecutionResourceRequirements `json:"maximum,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="self.controllerName == oldSelf.controllerName",message="controllerName is immutable"
// +kubebuilder:validation:XValidation:rule="self.parametersRef == oldSelf.parametersRef",message="parametersRef is immutable"
type JobClassSpec struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
ControllerName string `json:"controllerName"`
ParametersRef ParametersReference `json:"parametersRef"`
// +optional
AllowedNamespaces *metav1.LabelSelector `json:"allowedNamespaces,omitempty"`
// +optional
Resources ExecutionResourcePolicy `json:"resources,omitempty"`
}
type JobClassStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,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="Controller",type=string,JSONPath=`.spec.controllerName`
// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=='Accepted')].status`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type JobClass struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec JobClassSpec `json:"spec"`
// +optional
Status JobClassStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type JobClassList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []JobClass `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &JobClass{}, &JobClassList{})
return nil
})
}
@@ -0,0 +1,51 @@
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
type KubernetesSchedulingParameters struct {
// +optional
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
// +optional
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
}
type KubernetesExecutionParametersSpec struct {
// +kubebuilder:validation:MinLength=1
ServiceAccountName string `json:"serviceAccountName"`
// +optional
RuntimeClassName string `json:"runtimeClassName,omitempty"`
// +optional
Scheduling KubernetesSchedulingParameters `json:"scheduling,omitempty"`
// +optional
PodSecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"`
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
// +kubebuilder:default=IfNotPresent
// +optional
ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster
type KubernetesExecutionParameters struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec KubernetesExecutionParametersSpec `json:"spec"`
}
// +kubebuilder:object:root=true
type KubernetesExecutionParametersList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []KubernetesExecutionParameters `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &KubernetesExecutionParameters{}, &KubernetesExecutionParametersList{})
return nil
})
}
@@ -0,0 +1,61 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
type NamespacedKeyReference struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Key string `json:"key"`
}
type OpenSandboxRequestMapping string
const (
OpenSandboxRequestMappingAdmissionOnly OpenSandboxRequestMapping = "AdmissionOnly"
OpenSandboxRequestMappingNative OpenSandboxRequestMapping = "Native"
)
// +kubebuilder:validation:XValidation:rule="self.allowInsecureHTTP || self.endpoint.startsWith('https://')",message="endpoint must use HTTPS unless allowInsecureHTTP is true"
type OpenSandboxExecutionParametersSpec struct {
// +kubebuilder:validation:MinLength=1
Endpoint string `json:"endpoint"`
APIKeySecretRef NamespacedKeyReference `json:"apiKeySecretRef"`
// +optional
PoolRef string `json:"poolRef,omitempty"`
// +kubebuilder:validation:Enum=AdmissionOnly;Native
// +kubebuilder:default=AdmissionOnly
// +optional
RequestMapping OpenSandboxRequestMapping `json:"requestMapping,omitempty"`
// +optional
AllowSecretEnv bool `json:"allowSecretEnv,omitempty"`
// +optional
AllowImageAuth bool `json:"allowImageAuth,omitempty"`
// AllowInsecureHTTP is intended for isolated development environments only.
// +optional
AllowInsecureHTTP bool `json:"allowInsecureHTTP,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster
type OpenSandboxExecutionParameters struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec OpenSandboxExecutionParametersSpec `json:"spec"`
}
// +kubebuilder:object:root=true
type OpenSandboxExecutionParametersList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []OpenSandboxExecutionParameters `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &OpenSandboxExecutionParameters{}, &OpenSandboxExecutionParametersList{})
return nil
})
}
+118
View File
@@ -0,0 +1,118 @@
package v1alpha1_test
import (
"context"
"os"
"path/filepath"
"testing"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/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"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
func TestJobCRDValidation(t *testing.T) {
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("KUBEBUILDER_ASSETS is unset; run make test to execute API integration tests")
}
scheme := runtime.NewScheme()
if err := executionv1alpha1.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}}
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)
}
})
client, err := ctrlclient.New(config, ctrlclient.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "api-validation"}}
if err := client.Create(ctx, namespace); err != nil {
t.Fatal(err)
}
t.Run("defaults desired state", func(t *testing.T) {
job := validJob("defaults")
if err := client.Create(ctx, job); err != nil {
t.Fatal(err)
}
if job.Spec.DesiredState != executionv1alpha1.JobDesiredStateRunning {
t.Fatalf("desiredState = %q, want Running", job.Spec.DesiredState)
}
})
t.Run("rejects ambiguous environment value", func(t *testing.T) {
literal := "visible"
job := validJob("invalid-env")
job.Spec.Task.Env = []executionv1alpha1.EnvVar{{
Name: "TOKEN",
Value: &literal,
ValueFrom: &executionv1alpha1.EnvVarSource{
//nolint:modernize // LocalObjectReference is an embedded Kubernetes API field.
SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "token"}, Key: "value"},
},
}}
if err := client.Create(ctx, job); !apierrors.IsInvalid(err) {
t.Fatalf("Create() error = %v, want Invalid", err)
}
})
t.Run("rejects immutable task update", func(t *testing.T) {
job := validJob("immutable")
if err := client.Create(ctx, job); err != nil {
t.Fatal(err)
}
job.Spec.Task.Image = "docker.io/library/busybox:1.37"
if err := client.Update(ctx, job); !apierrors.IsInvalid(err) {
t.Fatalf("Update() error = %v, want Invalid", err)
}
})
t.Run("allows one-way cancellation", func(t *testing.T) {
job := validJob("cancel")
if err := client.Create(ctx, job); err != nil {
t.Fatal(err)
}
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateCancelled
if err := client.Update(ctx, job); err != nil {
t.Fatalf("cancel update: %v", err)
}
job.Spec.DesiredState = executionv1alpha1.JobDesiredStateRunning
if err := client.Update(ctx, job); !apierrors.IsInvalid(err) {
t.Fatalf("reverse cancellation error = %v, want Invalid", err)
}
})
}
func validJob(name string) *executionv1alpha1.Job {
//nolint:modernize // ObjectMeta is promoted through embedded TypeMeta; embedlit produces invalid Go here.
return &executionv1alpha1.Job{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "api-validation"},
Spec: executionv1alpha1.JobSpec{
Task: executionv1alpha1.TaskSpec{Image: "docker.io/library/alpine:3.22"},
},
}
}
@@ -0,0 +1,676 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/api/core/v1"
metav1 "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 *EnvVar) DeepCopyInto(out *EnvVar) {
*out = *in
if in.Value != nil {
in, out := &in.Value, &out.Value
*out = new(string)
**out = **in
}
if in.ValueFrom != nil {
in, out := &in.ValueFrom, &out.ValueFrom
*out = new(EnvVarSource)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar.
func (in *EnvVar) DeepCopy() *EnvVar {
if in == nil {
return nil
}
out := new(EnvVar)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvVarSource) DeepCopyInto(out *EnvVarSource) {
*out = *in
if in.SecretKeyRef != nil {
in, out := &in.SecretKeyRef, &out.SecretKeyRef
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
if in.ConfigMapKeyRef != nil {
in, out := &in.ConfigMapKeyRef, &out.ConfigMapKeyRef
*out = new(v1.ConfigMapKeySelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVarSource.
func (in *EnvVarSource) DeepCopy() *EnvVarSource {
if in == nil {
return nil
}
out := new(EnvVarSource)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExecutionReference) DeepCopyInto(out *ExecutionReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionReference.
func (in *ExecutionReference) DeepCopy() *ExecutionReference {
if in == nil {
return nil
}
out := new(ExecutionReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExecutionResourcePolicy) DeepCopyInto(out *ExecutionResourcePolicy) {
*out = *in
in.Defaults.DeepCopyInto(&out.Defaults)
in.Minimum.DeepCopyInto(&out.Minimum)
in.Maximum.DeepCopyInto(&out.Maximum)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionResourcePolicy.
func (in *ExecutionResourcePolicy) DeepCopy() *ExecutionResourcePolicy {
if in == nil {
return nil
}
out := new(ExecutionResourcePolicy)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExecutionResourceRequirements) DeepCopyInto(out *ExecutionResourceRequirements) {
*out = *in
in.Requests.DeepCopyInto(&out.Requests)
in.Limits.DeepCopyInto(&out.Limits)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionResourceRequirements.
func (in *ExecutionResourceRequirements) DeepCopy() *ExecutionResourceRequirements {
if in == nil {
return nil
}
out := new(ExecutionResourceRequirements)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExecutionStatus) DeepCopyInto(out *ExecutionStatus) {
*out = *in
if in.References != nil {
in, out := &in.References, &out.References
*out = make([]ExecutionReference, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionStatus.
func (in *ExecutionStatus) DeepCopy() *ExecutionStatus {
if in == nil {
return nil
}
out := new(ExecutionStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Job) DeepCopyInto(out *Job) {
*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 Job.
func (in *Job) DeepCopy() *Job {
if in == nil {
return nil
}
out := new(Job)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Job) 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 *JobClass) DeepCopyInto(out *JobClass) {
*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 JobClass.
func (in *JobClass) DeepCopy() *JobClass {
if in == nil {
return nil
}
out := new(JobClass)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *JobClass) 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 *JobClassList) DeepCopyInto(out *JobClassList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]JobClass, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobClassList.
func (in *JobClassList) DeepCopy() *JobClassList {
if in == nil {
return nil
}
out := new(JobClassList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *JobClassList) 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 *JobClassSpec) DeepCopyInto(out *JobClassSpec) {
*out = *in
out.ParametersRef = in.ParametersRef
if in.AllowedNamespaces != nil {
in, out := &in.AllowedNamespaces, &out.AllowedNamespaces
*out = new(metav1.LabelSelector)
(*in).DeepCopyInto(*out)
}
in.Resources.DeepCopyInto(&out.Resources)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobClassSpec.
func (in *JobClassSpec) DeepCopy() *JobClassSpec {
if in == nil {
return nil
}
out := new(JobClassSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JobClassStatus) DeepCopyInto(out *JobClassStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobClassStatus.
func (in *JobClassStatus) DeepCopy() *JobClassStatus {
if in == nil {
return nil
}
out := new(JobClassStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JobList) DeepCopyInto(out *JobList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Job, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobList.
func (in *JobList) DeepCopy() *JobList {
if in == nil {
return nil
}
out := new(JobList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *JobList) 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 *JobResult) DeepCopyInto(out *JobResult) {
*out = *in
if in.ExitCode != nil {
in, out := &in.ExitCode, &out.ExitCode
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobResult.
func (in *JobResult) DeepCopy() *JobResult {
if in == nil {
return nil
}
out := new(JobResult)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JobSpec) DeepCopyInto(out *JobSpec) {
*out = *in
in.Task.DeepCopyInto(&out.Task)
in.Resources.DeepCopyInto(&out.Resources)
if in.ActiveDeadlineSeconds != nil {
in, out := &in.ActiveDeadlineSeconds, &out.ActiveDeadlineSeconds
*out = new(int64)
**out = **in
}
if in.TTLSecondsAfterFinished != nil {
in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobSpec.
func (in *JobSpec) DeepCopy() *JobSpec {
if in == nil {
return nil
}
out := new(JobSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JobStatus) DeepCopyInto(out *JobStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.ResolvedJobClass != nil {
in, out := &in.ResolvedJobClass, &out.ResolvedJobClass
*out = new(ResolvedJobClassReference)
**out = **in
}
in.EffectiveResources.DeepCopyInto(&out.EffectiveResources)
if in.Execution != nil {
in, out := &in.Execution, &out.Execution
*out = new(ExecutionStatus)
(*in).DeepCopyInto(*out)
}
if in.StartTime != nil {
in, out := &in.StartTime, &out.StartTime
*out = (*in).DeepCopy()
}
if in.CompletionTime != nil {
in, out := &in.CompletionTime, &out.CompletionTime
*out = (*in).DeepCopy()
}
if in.Result != nil {
in, out := &in.Result, &out.Result
*out = new(JobResult)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobStatus.
func (in *JobStatus) DeepCopy() *JobStatus {
if in == nil {
return nil
}
out := new(JobStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KubernetesExecutionParameters) DeepCopyInto(out *KubernetesExecutionParameters) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesExecutionParameters.
func (in *KubernetesExecutionParameters) DeepCopy() *KubernetesExecutionParameters {
if in == nil {
return nil
}
out := new(KubernetesExecutionParameters)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KubernetesExecutionParameters) 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 *KubernetesExecutionParametersList) DeepCopyInto(out *KubernetesExecutionParametersList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KubernetesExecutionParameters, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesExecutionParametersList.
func (in *KubernetesExecutionParametersList) DeepCopy() *KubernetesExecutionParametersList {
if in == nil {
return nil
}
out := new(KubernetesExecutionParametersList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KubernetesExecutionParametersList) 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 *KubernetesExecutionParametersSpec) DeepCopyInto(out *KubernetesExecutionParametersSpec) {
*out = *in
in.Scheduling.DeepCopyInto(&out.Scheduling)
if in.PodSecurityContext != nil {
in, out := &in.PodSecurityContext, &out.PodSecurityContext
*out = new(v1.PodSecurityContext)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesExecutionParametersSpec.
func (in *KubernetesExecutionParametersSpec) DeepCopy() *KubernetesExecutionParametersSpec {
if in == nil {
return nil
}
out := new(KubernetesExecutionParametersSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KubernetesSchedulingParameters) DeepCopyInto(out *KubernetesSchedulingParameters) {
*out = *in
if in.NodeSelector != nil {
in, out := &in.NodeSelector, &out.NodeSelector
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Tolerations != nil {
in, out := &in.Tolerations, &out.Tolerations
*out = make([]v1.Toleration, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesSchedulingParameters.
func (in *KubernetesSchedulingParameters) DeepCopy() *KubernetesSchedulingParameters {
if in == nil {
return nil
}
out := new(KubernetesSchedulingParameters)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespacedKeyReference) DeepCopyInto(out *NamespacedKeyReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedKeyReference.
func (in *NamespacedKeyReference) DeepCopy() *NamespacedKeyReference {
if in == nil {
return nil
}
out := new(NamespacedKeyReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OpenSandboxExecutionParameters) DeepCopyInto(out *OpenSandboxExecutionParameters) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSandboxExecutionParameters.
func (in *OpenSandboxExecutionParameters) DeepCopy() *OpenSandboxExecutionParameters {
if in == nil {
return nil
}
out := new(OpenSandboxExecutionParameters)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *OpenSandboxExecutionParameters) 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 *OpenSandboxExecutionParametersList) DeepCopyInto(out *OpenSandboxExecutionParametersList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]OpenSandboxExecutionParameters, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSandboxExecutionParametersList.
func (in *OpenSandboxExecutionParametersList) DeepCopy() *OpenSandboxExecutionParametersList {
if in == nil {
return nil
}
out := new(OpenSandboxExecutionParametersList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *OpenSandboxExecutionParametersList) 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 *OpenSandboxExecutionParametersSpec) DeepCopyInto(out *OpenSandboxExecutionParametersSpec) {
*out = *in
out.APIKeySecretRef = in.APIKeySecretRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSandboxExecutionParametersSpec.
func (in *OpenSandboxExecutionParametersSpec) DeepCopy() *OpenSandboxExecutionParametersSpec {
if in == nil {
return nil
}
out := new(OpenSandboxExecutionParametersSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ParametersReference) DeepCopyInto(out *ParametersReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParametersReference.
func (in *ParametersReference) DeepCopy() *ParametersReference {
if in == nil {
return nil
}
out := new(ParametersReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResolvedJobClassReference) DeepCopyInto(out *ResolvedJobClassReference) {
*out = *in
out.ParametersRef = in.ParametersRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedJobClassReference.
func (in *ResolvedJobClassReference) DeepCopy() *ResolvedJobClassReference {
if in == nil {
return nil
}
out := new(ResolvedJobClassReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceValues) DeepCopyInto(out *ResourceValues) {
*out = *in
if in.CPU != nil {
in, out := &in.CPU, &out.CPU
x := (*in).DeepCopy()
*out = &x
}
if in.Memory != nil {
in, out := &in.Memory, &out.Memory
x := (*in).DeepCopy()
*out = &x
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceValues.
func (in *ResourceValues) DeepCopy() *ResourceValues {
if in == nil {
return nil
}
out := new(ResourceValues)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TaskSpec) DeepCopyInto(out *TaskSpec) {
*out = *in
if in.ImagePullSecrets != nil {
in, out := &in.ImagePullSecrets, &out.ImagePullSecrets
*out = make([]v1.LocalObjectReference, len(*in))
copy(*out, *in)
}
if in.Command != nil {
in, out := &in.Command, &out.Command
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Args != nil {
in, out := &in.Args, &out.Args
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpec.
func (in *TaskSpec) DeepCopy() *TaskSpec {
if in == nil {
return nil
}
out := new(TaskSpec)
in.DeepCopyInto(out)
return out
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"crypto/tls"
"flag"
"os"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
_ "k8s.io/client-go/plugin/pkg/client/auth"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
// +kubebuilder:scaffold:imports
)
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(executionv1alpha1.AddToScheme(scheme))
// +kubebuilder:scaffold:scheme
}
// nolint:gocyclo
func main() {
var metricsAddr string
var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string
var webhookPort int
var enableLeaderElection bool
var probeAddr string
var secureMetrics bool
var enableHTTP2 bool
var tlsOpts []func(*tls.Config)
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
flag.BoolVar(&secureMetrics, "metrics-secure", true,
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.")
flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.")
flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.")
flag.IntVar(&webhookPort, "webhook-port", 9443, "Port the webhook server listens on. "+
"Defaults to 9443. Set -1 to disable the webhook server.")
flag.StringVar(&metricsCertPath, "metrics-cert-path", "",
"The directory that contains the metrics server certificate.")
flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.")
flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
opts := zap.Options{
Development: true,
}
opts.BindFlags(flag.CommandLine)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
// if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
// Rapid Reset CVEs. For more information see:
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
// - https://github.com/advisories/GHSA-4374-p667-p6c8
disableHTTP2 := func(c *tls.Config) {
setupLog.Info("Disabling HTTP/2")
c.NextProtos = []string{"http/1.1"}
}
if !enableHTTP2 {
tlsOpts = append(tlsOpts, disableHTTP2)
}
// Initial webhook TLS options
webhookTLSOpts := tlsOpts
webhookServerOptions := webhook.Options{
TLSOpts: webhookTLSOpts,
Port: webhookPort,
}
if len(webhookCertPath) > 0 {
setupLog.Info("Initializing webhook certificate watcher using provided certificates",
"webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey)
webhookServerOptions.CertDir = webhookCertPath
webhookServerOptions.CertName = webhookCertName
webhookServerOptions.KeyName = webhookCertKey
}
webhookServer := webhook.NewServer(webhookServerOptions)
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
// More info:
// - https://pkg.go.dev/sigs.k8s.io/[email protected]/pkg/metrics/server
// - https://book.kubebuilder.io/reference/metrics.html
metricsServerOptions := metricsserver.Options{
BindAddress: metricsAddr,
SecureServing: secureMetrics,
TLSOpts: tlsOpts,
}
if secureMetrics {
// FilterProvider is used to protect the metrics endpoint with authn/authz.
// These configurations ensure that only authorized users and service accounts
// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
// https://pkg.go.dev/sigs.k8s.io/[email protected]/pkg/metrics/filters#WithAuthenticationAndAuthorization
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
}
// If the certificate is not specified, controller-runtime will automatically
// generate self-signed certificates for the metrics server. While convenient for development and testing,
// this setup is not recommended for production.
//
// TODO(user): If you enable certManager, uncomment the following lines:
// - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates
// managed by cert-manager for the metrics server.
// - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification.
if len(metricsCertPath) > 0 {
setupLog.Info("Initializing metrics certificate watcher using provided certificates",
"metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey)
metricsServerOptions.CertDir = metricsCertPath
metricsServerOptions.CertName = metricsCertName
metricsServerOptions.KeyName = metricsCertKey
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "a6325ed6.ddupan.top",
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
// when the Manager ends. This requires the binary to immediately end when the
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
// speeds up voluntary leader transitions as the new leader don't have to wait
// LeaseDuration time first.
//
// In the default scaffold provided, the program ends immediately after
// the manager stops, so would be fine to enable this option. However,
// 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 err != nil {
setupLog.Error(err, "Failed to start manager")
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)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "Failed to set up ready check")
os.Exit(1)
}
setupLog.Info("Starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "Failed to run manager")
os.Exit(1)
}
}
@@ -0,0 +1,307 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: jobclasses.execution.ayatori.ddupan.top
spec:
group: execution.ayatori.ddupan.top
names:
kind: JobClass
listKind: JobClassList
plural: jobclasses
singular: jobclass
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.controllerName
name: Controller
type: string
- jsonPath: .status.conditions[?(@.type=='Accepted')].status
name: Accepted
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
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:
allowedNamespaces:
description: |-
A label selector is a label query over a set of resources. The result of matchLabels and
matchExpressions are ANDed. An empty label selector matches all objects. A null
label selector matches no objects.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements.
The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies
to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
controllerName:
maxLength: 253
minLength: 1
type: string
parametersRef:
properties:
group:
type: string
kind:
type: string
name:
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.
type: string
required:
- group
- kind
- name
type: object
resources:
properties:
defaults:
properties:
limits:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
requests:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
type: object
maximum:
properties:
limits:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
requests:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
type: object
minimum:
properties:
limits:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
requests:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
type: object
type: object
required:
- controllerName
- parametersRef
type: object
x-kubernetes-validations:
- message: controllerName is immutable
rule: self.controllerName == oldSelf.controllerName
- message: parametersRef is immutable
rule: self.parametersRef == oldSelf.parametersRef
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
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,408 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: jobs.execution.ayatori.ddupan.top
spec:
group: execution.ayatori.ddupan.top
names:
kind: Job
listKind: JobList
plural: jobs
singular: job
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .status.conditions[?(@.type=='Accepted')].status
name: Accepted
type: string
- jsonPath: .status.conditions[?(@.type=='Scheduled')].status
name: Scheduled
type: string
- jsonPath: .status.conditions[?(@.type=='Succeeded')].status
name: Succeeded
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
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:
activeDeadlineSeconds:
format: int64
minimum: 1
type: integer
desiredState:
default: Running
enum:
- Running
- Cancelled
type: string
jobClassName:
type: string
resources:
properties:
limits:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
requests:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
type: object
task:
properties:
args:
items:
type: string
type: array
command:
items:
type: string
type: array
env:
items:
properties:
name:
pattern: ^[A-Za-z_][A-Za-z0-9_]*$
type: string
value:
type: string
valueFrom:
properties:
configMapKeyRef:
description: Selects a key from a ConfigMap.
properties:
key:
description: |-
The key to select from the ConfigMap's Data field.
Keys in the BinaryData field are not currently propagated to container env vars.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the ConfigMap or its
key must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
secretKeyRef:
description: SecretKeySelector selects a key of a Secret.
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the Secret or its key
must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
type: object
x-kubernetes-validations:
- message: exactly one key reference must be set
rule: has(self.secretKeyRef) != has(self.configMapKeyRef)
required:
- name
type: object
x-kubernetes-validations:
- message: exactly one of value or valueFrom must be set
rule: has(self.value) != has(self.valueFrom)
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
image:
minLength: 1
type: string
imagePullSecrets:
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: array
workingDir:
type: string
required:
- image
type: object
ttlSecondsAfterFinished:
format: int32
minimum: 0
type: integer
required:
- task
type: object
x-kubernetes-validations:
- message: task is immutable
rule: self.task == oldSelf.task
- message: resources are immutable
rule: self.resources == oldSelf.resources
- message: activeDeadlineSeconds is immutable
rule: has(self.activeDeadlineSeconds) == has(oldSelf.activeDeadlineSeconds)
&& (!has(self.activeDeadlineSeconds) || self.activeDeadlineSeconds
== oldSelf.activeDeadlineSeconds)
- message: jobClassName is immutable
rule: has(self.jobClassName) == has(oldSelf.jobClassName) && (!has(self.jobClassName)
|| self.jobClassName == oldSelf.jobClassName)
- message: desiredState may only transition from Running to Cancelled
rule: oldSelf.desiredState == self.desiredState || (oldSelf.desiredState
== 'Running' && self.desiredState == 'Cancelled')
status:
properties:
completionTime:
format: date-time
type: string
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
effectiveResources:
properties:
limits:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
requests:
properties:
cpu:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
memory:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
type: object
type: object
execution:
properties:
adapter:
type: string
references:
items:
properties:
id:
minLength: 1
type: string
type:
minLength: 1
type: string
required:
- id
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
required:
- adapter
type: object
observedGeneration:
format: int64
type: integer
resolvedJobClass:
properties:
controllerName:
type: string
name:
type: string
parametersRef:
properties:
group:
type: string
kind:
type: string
name:
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.
type: string
required:
- group
- kind
- name
type: object
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.
type: string
required:
- controllerName
- name
- parametersRef
- uid
type: object
result:
properties:
exitCode:
format: int32
type: integer
reason:
type: string
type: object
startTime:
format: date-time
type: string
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,339 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: kubernetesexecutionparameters.execution.ayatori.ddupan.top
spec:
group: execution.ayatori.ddupan.top
names:
kind: KubernetesExecutionParameters
listKind: KubernetesExecutionParametersList
plural: kubernetesexecutionparameters
singular: kubernetesexecutionparameters
scope: Cluster
versions:
- 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:
imagePullPolicy:
default: IfNotPresent
description: PullPolicy describes a policy for if/when to pull a container
image
enum:
- Always
- Never
- IfNotPresent
type: string
podSecurityContext:
description: |-
PodSecurityContext holds pod-level security attributes and common container settings.
Some fields are also present in container.securityContext. Field values of
container.securityContext take precedence over field values of PodSecurityContext.
properties:
appArmorProfile:
description: |-
appArmorProfile is the AppArmor options to use by the containers in this pod.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile loaded on the node that should be used.
The profile must be preconfigured on the node to work.
Must match the loaded name of the profile.
Must be set if and only if type is "Localhost".
type: string
type:
description: |-
type indicates which kind of AppArmor profile will be applied.
Valid options are:
Localhost - a profile pre-loaded on the node.
RuntimeDefault - the container runtime's default profile.
Unconfined - no AppArmor enforcement.
type: string
required:
- type
type: object
fsGroup:
description: |-
A special supplemental group that applies to all containers in a pod.
Some volume types allow the Kubelet to change the ownership of that volume
to be owned by the pod:
1. The owning GID will be the FSGroup
2. The setgid bit is set (new files created in the volume will be owned by FSGroup)
3. The permission bits are OR'd with rw-rw----
If unset, the Kubelet will not modify the ownership and permissions of any volume.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
fsGroupChangePolicy:
description: |-
fsGroupChangePolicy defines behavior of changing ownership and permission of the volume
before being exposed inside Pod. This field will only apply to
volume types which support fsGroup based ownership(and permissions).
It will have no effect on ephemeral volume types such as: secret, configmaps
and emptydir.
Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used.
Note that this field cannot be set when spec.os.name is windows.
type: string
runAsGroup:
description: |-
The GID to run the entrypoint of the container process.
Uses runtime default if unset.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence
for that container.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
runAsNonRoot:
description: |-
Indicates that the container must run as a non-root user.
If true, the Kubelet will validate the image at runtime to ensure that it
does not run as UID 0 (root) and fail to start the container if it does.
If unset or false, no such validation will be performed.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: boolean
runAsUser:
description: |-
The UID to run the entrypoint of the container process.
Defaults to user specified in image metadata if unspecified.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence
for that container.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
seLinuxChangePolicy:
description: |-
seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
Valid values are "MountOption" and "Recursive".
"Recursive" means relabeling of all files on all Pod volumes by the container runtime.
This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
"MountOption" mounts all eligible Pod volumes with `-o context` mount option.
This requires all Pods that share the same volume to use the same SELinux label.
It is not possible to share the same volume among privileged and unprivileged Pods.
Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
CSIDriver instance. Other volumes are always re-labelled recursively.
If not specified, "MountOption" is used.
This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
Note that this field cannot be set when spec.os.name is windows.
type: string
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
If unspecified, the container runtime will allocate a random SELinux context for each
container. May also be set in SecurityContext. If set in
both SecurityContext and PodSecurityContext, the value specified in SecurityContext
takes precedence for that container.
Note that this field cannot be set when spec.os.name is windows.
properties:
level:
description: Level is SELinux level label that applies to
the container.
type: string
role:
description: Role is a SELinux role label that applies to
the container.
type: string
type:
description: Type is a SELinux type label that applies to
the container.
type: string
user:
description: User is a SELinux user label that applies to
the container.
type: string
type: object
seccompProfile:
description: |-
The seccomp options to use by the containers in this pod.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile defined in a file on the node should be used.
The profile must be preconfigured on the node to work.
Must be a descending path, relative to the kubelet's configured seccomp profile location.
Must be set if type is "Localhost". Must NOT be set for any other type.
type: string
type:
description: |-
type indicates which kind of seccomp profile will be applied.
Valid options are:
Localhost - a profile defined in a file on the node should be used.
RuntimeDefault - the container runtime default profile should be used.
Unconfined - no profile should be applied.
type: string
required:
- type
type: object
supplementalGroups:
description: |-
A list of groups applied to the first process run in each container, in
addition to the container's primary GID and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
Note that this field cannot be set when spec.os.name is windows.
items:
format: int64
type: integer
type: array
x-kubernetes-list-type: atomic
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
sysctls (by the container runtime) might fail to launch.
Note that this field cannot be set when spec.os.name is windows.
items:
description: Sysctl defines a kernel parameter to be set
properties:
name:
description: Name of a property to set
type: string
value:
description: Value of a property to set
type: string
required:
- name
- value
type: object
type: array
x-kubernetes-list-type: atomic
windowsOptions:
description: |-
The Windows specific settings applied to all containers.
If unspecified, the options within a container's SecurityContext will be used.
If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is linux.
properties:
gmsaCredentialSpec:
description: |-
GMSACredentialSpec is where the GMSA admission webhook
(https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
GMSA credential spec named by the GMSACredentialSpecName field.
type: string
gmsaCredentialSpecName:
description: GMSACredentialSpecName is the name of the GMSA
credential spec to use.
type: string
hostProcess:
description: |-
HostProcess determines if a container should be run as a 'Host Process' container.
All of a Pod's containers must have the same effective HostProcess value
(it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
In addition, if HostProcess is true then HostNetwork must also be set to true.
type: boolean
runAsUserName:
description: |-
The UserName in Windows to run the entrypoint of the container process.
Defaults to the user specified in image metadata if unspecified.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: string
type: object
type: object
runtimeClassName:
type: string
scheduling:
properties:
nodeSelector:
additionalProperties:
type: string
type: object
tolerations:
items:
description: |-
The pod this Toleration is attached to tolerates any taint that matches
the triple <key,value,effect> using the matching operator <operator>.
properties:
effect:
description: |-
Effect indicates the taint effect to match. Empty means match all taint effects.
When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.
type: string
key:
description: |-
Key is the taint key that the toleration applies to. Empty means match all taint keys.
If the key is empty, operator must be Exists; this combination means to match all values and all keys.
type: string
operator:
description: |-
Operator represents a key's relationship to the value.
Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal.
Exists is equivalent to wildcard for value, so that a pod can
tolerate all taints of a particular category.
Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators).
type: string
tolerationSeconds:
description: |-
TolerationSeconds represents the period of time the toleration (which must be
of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,
it is not set, which means tolerate the taint forever (do not evict). Zero and
negative values will be treated as 0 (evict immediately) by the system.
format: int64
type: integer
value:
description: |-
Value is the taint value the toleration matches to.
If the operator is Exists, the value should be empty, otherwise just a regular string.
type: string
type: object
type: array
type: object
serviceAccountName:
minLength: 1
type: string
required:
- serviceAccountName
type: object
required:
- spec
type: object
served: true
storage: true
@@ -0,0 +1,83 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: opensandboxexecutionparameters.execution.ayatori.ddupan.top
spec:
group: execution.ayatori.ddupan.top
names:
kind: OpenSandboxExecutionParameters
listKind: OpenSandboxExecutionParametersList
plural: opensandboxexecutionparameters
singular: opensandboxexecutionparameters
scope: Cluster
versions:
- 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:
allowImageAuth:
type: boolean
allowInsecureHTTP:
description: AllowInsecureHTTP is intended for isolated development
environments only.
type: boolean
allowSecretEnv:
type: boolean
apiKeySecretRef:
properties:
key:
type: string
name:
type: string
namespace:
type: string
required:
- key
- name
- namespace
type: object
endpoint:
minLength: 1
type: string
poolRef:
type: string
requestMapping:
default: AdmissionOnly
enum:
- AdmissionOnly
- Native
type: string
required:
- apiKeySecretRef
- endpoint
type: object
x-kubernetes-validations:
- message: endpoint must use HTTPS unless allowInsecureHTTP is true
rule: self.allowInsecureHTTP || self.endpoint.startsWith('https://')
required:
- spec
type: object
served: true
storage: true
+19
View File
@@ -0,0 +1,19 @@
# This kustomization.yaml is not intended to be run by itself,
# since it depends on service name and namespace that are out of this kustomize package.
# It should be run by config/default
resources:
- bases/execution.ayatori.ddupan.top_jobs.yaml
- bases/execution.ayatori.ddupan.top_jobclasses.yaml
- bases/execution.ayatori.ddupan.top_kubernetesexecutionparameters.yaml
- bases/execution.ayatori.ddupan.top_opensandboxexecutionparameters.yaml
# +kubebuilder:scaffold:crdkustomizeresource
patches:
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
# patches here are for enabling the conversion webhook for each CRD
# +kubebuilder:scaffold:crdkustomizewebhookpatch
# [WEBHOOK] To enable webhook, uncomment the following section
# the following config is for teaching kustomize how to do kustomization for CRDs.
#configurations:
#- kustomizeconfig.yaml
+12
View File
@@ -0,0 +1,12 @@
# This file is for teaching kustomize how to substitute name and namespace reference in CRD
nameReference:
- kind: Service
version: v1
fieldSpecs:
- kind: CustomResourceDefinition
version: v1
group: apiextensions.k8s.io
path: spec/conversion/webhook/clientConfig/service/name
varReference:
- path: metadata/annotations
@@ -0,0 +1,30 @@
# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs.
# Add the volumeMount for the metrics-server certs
- op: add
path: /spec/template/spec/containers/0/volumeMounts/-
value:
mountPath: /tmp/k8s-metrics-server/metrics-certs
name: metrics-certs
readOnly: true
# Add the --metrics-cert-path argument for the metrics server
- op: add
path: /spec/template/spec/containers/0/args/-
value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs
# Add the metrics-server certs volume configuration
- op: add
path: /spec/template/spec/volumes/-
value:
name: metrics-certs
secret:
secretName: metrics-server-cert
optional: false
items:
- key: ca.crt
path: ca.crt
- key: tls.crt
path: tls.crt
- key: tls.key
path: tls.key
+233
View File
@@ -0,0 +1,233 @@
# Adds namespace to all resources.
namespace: ayatori-system
# Value of this field is prepended to the
# names of all resources, e.g. a deployment named
# "wordpress" becomes "alices-wordpress".
# Note that it should also match with the prefix (text before '-') of the namespace
# field above.
namePrefix: ayatori-
# Labels to add to all resources and selectors.
#labels:
#- includeSelectors: true
# pairs:
# someName: someValue
resources:
- ../crd
- ../rbac
- ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
#- ../webhook
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
#- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus
# [METRICS] Expose the controller manager metrics service.
- metrics_service.yaml
# [NETWORK POLICY] Control ingress to metrics and webhook ports.
# Allow metrics traffic from pods in namespaces labeled 'metrics: enabled'.
# Allow webhook traffic from all sources.
#- ../network-policy
# Uncomment the patches line if you enable Metrics
patches:
# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443.
# More info: https://book.kubebuilder.io/reference/metrics
- path: manager_metrics_patch.yaml
target:
kind: Deployment
# Uncomment the patches line if you enable Metrics and CertManager
# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line.
# This patch will protect the metrics with certManager self-signed certs.
#- path: cert_metrics_manager_patch.yaml
# target:
# kind: Deployment
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
#- path: manager_webhook_patch.yaml
# target:
# kind: Deployment
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
# Uncomment the following replacements to add the cert-manager CA injection annotations
#replacements:
# - source: # Uncomment the following block to enable certificates for metrics
# kind: Service
# version: v1
# name: controller-manager-metrics-service
# fieldPath: metadata.name
# targets:
# - select:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: metrics-certs
# fieldPaths:
# - spec.dnsNames.0
# - spec.dnsNames.1
# options:
# delimiter: '.'
# index: 0
# create: true
# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor
# kind: ServiceMonitor
# group: monitoring.coreos.com
# version: v1
# name: controller-manager-metrics-monitor
# fieldPaths:
# - spec.endpoints.0.tlsConfig.serverName
# options:
# delimiter: '.'
# index: 0
# create: true
# - source:
# kind: Service
# version: v1
# name: controller-manager-metrics-service
# fieldPath: metadata.namespace
# targets:
# - select:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: metrics-certs
# fieldPaths:
# - spec.dnsNames.0
# - spec.dnsNames.1
# options:
# delimiter: '.'
# index: 1
# create: true
# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor
# kind: ServiceMonitor
# group: monitoring.coreos.com
# version: v1
# name: controller-manager-metrics-monitor
# fieldPaths:
# - spec.endpoints.0.tlsConfig.serverName
# options:
# delimiter: '.'
# index: 1
# create: true
# - source: # Uncomment the following block if you have any webhook
# kind: Service
# version: v1
# name: webhook-service
# fieldPath: .metadata.name # Name of the service
# targets:
# - select:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPaths:
# - .spec.dnsNames.0
# - .spec.dnsNames.1
# options:
# delimiter: '.'
# index: 0
# create: true
# - source:
# kind: Service
# version: v1
# name: webhook-service
# fieldPath: .metadata.namespace # Namespace of the service
# targets:
# - select:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPaths:
# - .spec.dnsNames.0
# - .spec.dnsNames.1
# options:
# delimiter: '.'
# index: 1
# create: true
# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert # This name should match the one in certificate.yaml
# fieldPath: .metadata.namespace # Namespace of the certificate CR
# targets:
# - select:
# kind: ValidatingWebhookConfiguration
# fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options:
# delimiter: '/'
# index: 0
# create: true
# - source:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPath: .metadata.name
# targets:
# - select:
# kind: ValidatingWebhookConfiguration
# fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options:
# delimiter: '/'
# index: 1
# create: true
# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPath: .metadata.namespace # Namespace of the certificate CR
# targets:
# - select:
# kind: MutatingWebhookConfiguration
# fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options:
# delimiter: '/'
# index: 0
# create: true
# - source:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPath: .metadata.name
# targets:
# - select:
# kind: MutatingWebhookConfiguration
# fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options:
# delimiter: '/'
# index: 1
# create: true
# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPath: .metadata.namespace # Namespace of the certificate CR
# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
# +kubebuilder:scaffold:crdkustomizecainjectionns
# - source:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert
# fieldPath: .metadata.name
# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
# +kubebuilder:scaffold:crdkustomizecainjectionname
@@ -0,0 +1,4 @@
# This patch adds the args to allow exposing the metrics endpoint using HTTPS
- op: add
path: /spec/template/spec/containers/0/args/0
value: --metrics-bind-address=:8443
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-service
namespace: system
spec:
ports:
- name: https
port: 8443
protocol: TCP
targetPort: 8443
selector:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
+2
View File
@@ -0,0 +1,2 @@
resources:
- manager.yaml
+102
View File
@@ -0,0 +1,102 @@
apiVersion: v1
kind: Namespace
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
labels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
spec:
selector:
matchLabels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
replicas: 1
template:
metadata:
annotations:
kubectl.kubernetes.io/default-container: manager
labels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
spec:
# TODO(user): Uncomment the following code to configure the nodeAffinity expression
# according to the platforms which are supported by your solution.
# It is considered best practice to support multiple architectures. You can
# build your manager image using the makefile target docker-buildx.
# affinity:
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions:
# - key: kubernetes.io/arch
# operator: In
# values:
# - amd64
# - arm64
# - ppc64le
# - s390x
# - key: kubernetes.io/os
# operator: In
# values:
# - linux
securityContext:
# Projects are configured by default to adhere to the "restricted" Pod Security Standards.
# This ensures that deployments meet the highest security requirements for Kubernetes.
# For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- command:
- /manager
args:
- --leader-elect
- --health-probe-bind-address=:8081
image: controller:latest
name: manager
ports:
- containerPort: 8081
name: health
protocol: TCP
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
# TODO(user): Configure the resources accordingly based on the project requirements.
# More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 10m
memory: 64Mi
volumeMounts: []
volumes: []
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10
@@ -0,0 +1,26 @@
# Allow metrics traffic from pods in namespaces labeled 'metrics: enabled'.
# Add this label to namespaces whose pods should scrape metrics.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: allow-metrics-traffic
namespace: system
spec:
podSelector:
matchLabels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
policyTypes:
- Ingress
ingress:
# Allow pods in namespaces labeled 'metrics: enabled' to scrape metrics.
- from:
- namespaceSelector:
matchLabels:
metrics: enabled # Only from namespaces with this label
ports:
- port: 8443
protocol: TCP
+2
View File
@@ -0,0 +1,2 @@
resources:
- allow-metrics-traffic.yaml
+11
View File
@@ -0,0 +1,11 @@
resources:
- monitor.yaml
# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus
# to securely reference certificates created and managed by cert-manager.
# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml
# to mount the "metrics-server-cert" secret in the Manager Deployment.
#patches:
# - path: monitor_tls_patch.yaml
# target:
# kind: ServiceMonitor
+27
View File
@@ -0,0 +1,27 @@
# Prometheus Monitor Service (Metrics)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-monitor
namespace: system
spec:
endpoints:
- path: /metrics
port: https # Ensure this is the name of the port that exposes HTTPS metrics
scheme: https
bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
tlsConfig:
# TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables
# certificate verification, exposing the system to potential man-in-the-middle attacks.
# For production environments, it is recommended to use cert-manager for automatic TLS certificate management.
# To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml,
# which securely references the certificate from the 'metrics-server-cert' secret.
insecureSkipVerify: true
selector:
matchLabels:
control-plane: controller-manager
app.kubernetes.io/name: ayatori
+19
View File
@@ -0,0 +1,19 @@
# Patch for Prometheus ServiceMonitor to enable secure TLS configuration
# using certificates managed by cert-manager
- op: replace
path: /spec/endpoints/0/tlsConfig
value:
# SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc
insecureSkipVerify: false
ca:
secret:
name: metrics-server-cert
key: ca.crt
cert:
secret:
name: metrics-server-cert
key: tls.crt
keySecret:
name: metrics-server-cert
key: tls.key
+27
View File
@@ -0,0 +1,27 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over execution.ayatori.ddupan.top.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-job-admin-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs
verbs:
- '*'
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs/status
verbs:
- get
@@ -0,0 +1,33 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the execution.ayatori.ddupan.top.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-job-editor-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs/status
verbs:
- get
@@ -0,0 +1,29 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to execution.ayatori.ddupan.top resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-job-viewer-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs
verbs:
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobs/status
verbs:
- get
@@ -0,0 +1,27 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over execution.ayatori.ddupan.top.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-jobclass-admin-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses
verbs:
- '*'
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses/status
verbs:
- get
@@ -0,0 +1,33 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the execution.ayatori.ddupan.top.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-jobclass-editor-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses/status
verbs:
- get
@@ -0,0 +1,29 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to execution.ayatori.ddupan.top resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-jobclass-viewer-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses
verbs:
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- jobclasses/status
verbs:
- get
@@ -0,0 +1,27 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over execution.ayatori.ddupan.top.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-kubernetesexecutionparameters-admin-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters
verbs:
- '*'
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters/status
verbs:
- get
@@ -0,0 +1,33 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the execution.ayatori.ddupan.top.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-kubernetesexecutionparameters-editor-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters/status
verbs:
- get
@@ -0,0 +1,29 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to execution.ayatori.ddupan.top resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-kubernetesexecutionparameters-viewer-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters
verbs:
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- kubernetesexecutionparameters/status
verbs:
- get
@@ -0,0 +1,27 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over execution.ayatori.ddupan.top.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-opensandboxexecutionparameters-admin-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters
verbs:
- '*'
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters/status
verbs:
- get
@@ -0,0 +1,33 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the execution.ayatori.ddupan.top.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-opensandboxexecutionparameters-editor-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters/status
verbs:
- get
@@ -0,0 +1,29 @@
# This rule is not used by the project ayatori itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to execution.ayatori.ddupan.top resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: execution-opensandboxexecutionparameters-viewer-role
rules:
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters
verbs:
- get
- list
- watch
- apiGroups:
- execution.ayatori.ddupan.top
resources:
- opensandboxexecutionparameters/status
verbs:
- get
+36
View File
@@ -0,0 +1,36 @@
resources:
# All RBAC will be applied under this service account in
# the deployment namespace. You may comment out this resource
# if your manager will use a service account that exists at
# runtime. Be sure to update RoleBinding and ClusterRoleBinding
# subjects if changing service account names.
- service_account.yaml
- role.yaml
- role_binding.yaml
- leader_election_role.yaml
- leader_election_role_binding.yaml
# The following RBAC configurations are used to protect
# the metrics endpoint with authn/authz. These configurations
# ensure that only authorized users and service accounts
# can access the metrics endpoint. Comment the following
# permissions if you want to disable this protection.
# More info: https://book.kubebuilder.io/reference/metrics.html
- metrics_auth_role.yaml
- metrics_auth_role_binding.yaml
- metrics_reader_role.yaml
# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by
# default, aiding admins in cluster management. Those roles are
# not used by the ayatori itself. You can comment the following lines
# if you do not want those helpers be installed with your Project.
- execution_opensandboxexecutionparameters_admin_role.yaml
- execution_opensandboxexecutionparameters_editor_role.yaml
- execution_opensandboxexecutionparameters_viewer_role.yaml
- execution_kubernetesexecutionparameters_admin_role.yaml
- execution_kubernetesexecutionparameters_editor_role.yaml
- execution_kubernetesexecutionparameters_viewer_role.yaml
- execution_jobclass_admin_role.yaml
- execution_jobclass_editor_role.yaml
- execution_jobclass_viewer_role.yaml
- execution_job_admin_role.yaml
- execution_job_editor_role.yaml
- execution_job_viewer_role.yaml
+40
View File
@@ -0,0 +1,40 @@
# permissions to do leader election.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: leader-election-role
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
@@ -0,0 +1,15 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: leader-election-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: leader-election-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+17
View File
@@ -0,0 +1,17 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: metrics-auth-role
rules:
- apiGroups:
- authentication.k8s.io
resources:
- tokenreviews
verbs:
- create
- apiGroups:
- authorization.k8s.io
resources:
- subjectaccessreviews
verbs:
- create
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: metrics-auth-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: metrics-auth-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+9
View File
@@ -0,0 +1,9 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: metrics-reader
rules:
- nonResourceURLs:
- "/metrics"
verbs:
- get
+11
View File
@@ -0,0 +1,11 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: manager-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
+15
View File
@@ -0,0 +1,15 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: manager-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: manager-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+8
View File
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: controller-manager
namespace: system
@@ -0,0 +1,14 @@
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: Job
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: job-sample
spec:
jobClassName: default
task:
image: docker.io/library/alpine:3.22
command: ["/bin/sh", "-c"]
args: ["echo hello from Ayatori"]
ttlSecondsAfterFinished: 3600
@@ -0,0 +1,13 @@
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: JobClass
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: default
spec:
controllerName: execution.ayatori.ddupan.top/kubernetes
parametersRef:
group: execution.ayatori.ddupan.top
kind: KubernetesExecutionParameters
name: default
@@ -0,0 +1,10 @@
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: KubernetesExecutionParameters
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: default
spec:
serviceAccountName: ayatori-job
imagePullPolicy: IfNotPresent
@@ -0,0 +1,14 @@
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: OpenSandboxExecutionParameters
metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: microvm
spec:
endpoint: https://opensandbox-api.example.internal
apiKeySecretRef:
namespace: ayatori-system
name: opensandbox-api
key: api-key
requestMapping: AdmissionOnly
+7
View File
@@ -0,0 +1,7 @@
## Append samples of your project ##
resources:
- execution_v1alpha1_job.yaml
- execution_v1alpha1_jobclass.yaml
- execution_v1alpha1_kubernetesexecutionparameters.yaml
- execution_v1alpha1_opensandboxexecutionparameters.yaml
# +kubebuilder:scaffold:manifestskustomizesamples
+526
View File
@@ -0,0 +1,526 @@
# Job API v1alpha1 草案
- 状态:Draft
- 日期:2026-09-17
- API group:`execution.ayatori.ddupan.top`
- Kind:`Job`
- Scope:Namespaced
## 目标
`Job` 表达一次有限时长、有明确退出结果的机器执行。调用者描述任务载荷和资源需求,平台
选择 execution backend 并持续观察,直到任务成功、失败或取消。
首个 adapter 使用 Kubernetes `batch/v1 Job`,第二个 adapter 使用 OpenSandbox lifecycle
与 execd API。API 不暴露 PodSpec、sandbox ID 创建参数或具体 adapter 配置,但允许表达两个
真实后端共有的 OCI image、进程、环境变量和资源语义。
`Job` 是短生命周期控制对象。完成后依据 `ttlSecondsAfterFinished` 回收,长期业务状态由调用
者保存,日志由 observability 平台保存。详细保留策略见 ADR-0005。
## 非目标
v1alpha1 不提供:
- DAG、workflow 或多步骤 task;
- 定时执行与可复用 Job template;
- 并行 completions、indexed job 或 gang scheduling;
- 自动业务重试;
- 暂停后恢复;
- 交互式 shell、endpoint、snapshot 或长生命周期 sandbox;
- workspace、cache、artifact 上传协议或结构化 task outputs;
- 永久 Job history。
上述能力应由后续独立资源或经过真实需求验证的兼容字段提供,不能通过透传 PodSpec 或
OpenSandbox extensions 提前进入 API。
## 示例
```yaml
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: Job
metadata:
generateName: hello-
namespace: ci
spec:
jobClassName: default
task:
image: docker.io/library/alpine:3.22
imagePullSecrets: []
command: ["/bin/sh", "-c"]
args:
- echo "hello ${TARGET}"
workingDir: /workspace
env:
- name: TARGET
value: world
- name: TOKEN
valueFrom:
secretKeyRef:
name: example-token
key: token
- name: CONFIG_VALUE
valueFrom:
configMapKeyRef:
name: example-config
key: value
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 3600
desiredState: Running
```
## Spec
```yaml
spec:
jobClassName: string
task: TaskSpec
resources: ResourceRequirements
activeDeadlineSeconds: int64
ttlSecondsAfterFinished: int32
desiredState: Running | Cancelled
```
### `jobClassName`
可选。引用平台管理员维护的 cluster-scoped `JobClass`。调用者选择服务等级或执行
能力,而不是直接选择 adapter driver。省略时由 namespace policy 解析默认 class;不存在
默认值时 Job 保持未接受状态,不得静默选择任意后端。
一旦 Job 被接受,该字段不可变。解析出的实际 class 写入 status,以便默认策略后来变化时仍
能恢复原执行。
`JobClass` 及其强类型 parameters API 定义后端配置和调度策略。Job API 不暴露
Kubernetes namespace、OpenSandbox endpoint、API key 或 backend raw configuration。
### `task`
必填,创建后不可变。
```yaml
task:
image: string
imagePullSecrets: []LocalObjectReference
command: []string
args: []string
workingDir: string
env: []EnvVar
```
- `image`:必填 OCI image reference。首版允许 tag;生产策略可以通过 admission 要求 digest。
- `imagePullSecrets`:可选,同 namespace 的私有 registry 凭据引用。
- `command`:可选,覆盖 image entrypoint;空值表示使用 image 默认值。
- `args`:可选,传给 entrypoint/command。
- `workingDir`:可选;为空时使用 image/backend 默认值。
- `env`:可选,名称必须唯一。
`command` 和 `args` 使用 argv 语义,不隐式经过 shell。需要 shell 展开时,调用者必须显式
指定 `/bin/sh -c` 等命令。
#### 环境变量
```yaml
- name: EXAMPLE
value: literal
- name: TOKEN
valueFrom:
secretKeyRef:
name: example
key: token
optional: false
```
`value` 与 `valueFrom` 必须且只能设置一个。v1alpha1 支持同 namespace 的 `SecretKeyRef` 和
`ConfigMapKeyRef`,两者具有相同的引用、optional 和等待语义。Adapter 负责以适合后端且不
写入 Job status 的方式传递值。引用对象或 key 缺失时 Job 保持未开始并通过 Condition 报告。
Secret 内容不得复制到 Event、日志或 backend reference;ConfigMap 值虽然不视为机密,也不
写入 status,避免状态膨胀和不同后端行为不一致。
Kubernetes adapter 保留原生 `SecretKeyRef`/`ConfigMapKeyRef`,由 kubelet 在执行节点解析,
controller 不读取内容。OpenSandbox create API 只接收已经解析的环境变量值,因此该 adapter
必须读取引用并把值放入 sandbox create request。JobClass 必须明确允许 Secret 的这种
传递路径,且 controller 的日志、Event 和 status 不得记录请求正文。未来需要避免把真实凭据
暴露给 sandbox 进程时,使用 OpenSandbox Credential Vault 或 Ayatori 独立 Credential 能力,
而不是改变 `SecretKeyRef` 的既有语义。
私有镜像凭据采用 Kubernetes `kubernetes.io/dockerconfigjson` Secret。Kubernetes adapter
直接传递引用;OpenSandbox adapter 选择与目标 image registry 匹配的条目,并映射到其
`image.auth` create 参数。无法解析、没有匹配 registry 或所选 OpenSandbox runtime 不支持
per-request image auth 时,Job 以明确 reason 失败,不得退回匿名拉取后隐藏真实原因。
### `resources`
可选,使用 Kubernetes `resource.Quantity` 表示数值,但不复用完整 Pod
`ResourceRequirements` 行为。
v1alpha1 支持 `cpu` 和 `memory` 的 requests/limits。Requests 表达准入与调度需求,limits
表达执行上限。JobClass 可以提供默认值和允许范围;解析后的实际资源写入 status。
Adapter 必须显式验证能否满足请求,不能无提示地忽略 limit。后端无法区分 request 与 limit
时,其映射规则属于 JobClass,并在 Job 接受前确定。对 OpenSandbox,CPU 和内存
limits 直接映射为 sandbox VM/container 的 `resourceLimits`;requests 用于 Ayatori 的准入与
调度,并可由 JobClass 映射到后端 resource request 或 capacity profile。映射失败必须
显式拒绝或失败,不能静默降低资源保证。
### `activeDeadlineSeconds`
可选,必须大于零。表示从实际执行开始到任务必须终止的最长时间,不包含排队、class 解析或
后端 provisioning 时间。到期后 controller 请求终止后端,最终以 `Succeeded=False`、
`reason=DeadlineExceeded` 结束。
后端自身的 timeout 可以作为执行机制,但 Ayatori controller 仍以 `status.startTime` 和观察
结果维护领域语义。调度等待超时是不同概念,v1alpha1 不提供。
### `ttlSecondsAfterFinished`
可选,必须大于或等于零。语义与 Kubernetes Job 一致:从终态 transition time 起计算,零
表示立即具备删除资格。该字段在任务完成前后均可修改,但不能保证在既有 TTL 已过期后通过
延长 TTL 阻止并发删除。
平台应通过 schema、CEL 或 policy 设置最大值和推荐默认值。controller 本身不偷偷填充一个
无法从 spec 观察到的永久策略。
### `desiredState`
可选,默认 `Running`。允许的状态迁移只有:
```text
Running → Cancelled
```
设置 `Cancelled` 表示请求终止当前执行并保留 Job 至 TTL 到期。取消是尽力而为的异步操作;
只有 adapter 确认执行不会继续后,Job 才进入终态。字段不得从 `Cancelled` 改回 `Running`。
重新执行必须创建新的 Job。
v1alpha1 不提供 suspend/resume。对任意后端可靠实现 checkpoint/resume 并非共同能力,且暂停
不应被伪装为取消。
## 不可变性
创建后仅允许修改:
- `spec.desiredState`,且只能单向变为 `Cancelled`;
- `spec.ttlSecondsAfterFinished`。
`task`、`resources`、`activeDeadlineSeconds` 和 `jobClassName` 均不可变。首选 CRD CEL
validation 表达这些约束;只有 schema/CEL 无法正确表达时才引入 admission webhook。
Controller reconcile 的技术重试不表示任务重跑。v1alpha1 每个 Job 最多启动一个逻辑执行;
adapter 必须使用 Job UID 作为幂等键。若请求结果未知,controller 必须先 Observe,不能因为
网络超时重新创建可能已经开始的执行。
首个 Kubernetes adapter 创建 `backoffLimit: 0`、`restartPolicy: Never` 的原生 Job,避免继承
Kubernetes 默认的多次业务执行语义。需要重新执行时创建新的 Ayatori Job。
## Status
```yaml
status:
observedGeneration: 1
conditions:
- type: Accepted
status: "True"
reason: Valid
observedGeneration: 1
lastTransitionTime: ...
- type: Scheduled
status: "True"
reason: BackendCreated
observedGeneration: 1
lastTransitionTime: ...
- type: Succeeded
status: "Unknown"
reason: Running
observedGeneration: 1
lastTransitionTime: ...
resolvedJobClass:
name: default
uid: 8aa4...
controllerName: execution.ayatori.ddupan.top/kubernetes
parametersRef:
group: execution.ayatori.ddupan.top
kind: KubernetesExecutionParameters
name: default
uid: c413...
effectiveResources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
execution:
adapter: kubernetes
references:
- type: Job
id: 5cb0...
startTime: ...
completionTime: ...
result:
exitCode: 0
reason: Completed
```
### Conditions
使用标准 `metav1.Condition`。v1alpha1 定义三个核心 Condition:
- `Accepted`:spec、引用、policy 和 JobClass 已解析,可进入调度;
- `Scheduled`:后端已确定并存在可观察的逻辑执行;
- `Succeeded`:任务结果。`Unknown` 表示尚未结束,`True` 表示成功,`False` 表示已经失败或
取消。
失败不使用单独的 `Failed` Condition。`Succeeded=True` 与 `Failed=True` 会形成需要额外维护的
互斥状态,而标准三态 Condition 已能完整表达一次执行:运行中为 `Unknown`、成功为 `True`、
失败为 `False`。失败类型由稳定 reason 区分;这与 Tekton `TaskRun` 的状态约定一致。
不增加与 Conditions 重复的 `phase` 字段。面向 CLI 的阶段摘要由 printer columns 或客户端从
Conditions 推导,避免两个状态源发生漂移。
常用 `Succeeded` reason 初始包括:
- `Pending`、`Scheduling`、`Running`;
- `Completed`;
- `ProcessFailed`;
- `DeadlineExceeded`;
- `Cancelled`;
- `BackendLost`;
- `ResultUnknown`。
Reason 是稳定、机器可读的 PascalCase 标识;message 面向人类且不得承载程序逻辑。
## 状态机
状态机名称用于设计、测试和 metrics,不增加持久化 `status.phase`。当前状态必须能够从 spec、
deletionTimestamp、Conditions、时间和 execution references 唯一推导。
### 状态定义
| 状态 | 判定摘要 | 含义 |
|---|---|---|
| `Resolving` | `Accepted!=True`,非终态 | 等待 JobClass、Secret、ConfigMap 或 policy 解析 |
| `Scheduling` | `Accepted=True`、`Scheduled!=True` | 选择 adapter 并幂等创建后端执行 |
| `Starting` | `Scheduled=True`、无 `startTime` | 后端已存在,任务主体尚未确认开始 |
| `Running` | 有 `startTime`、`Succeeded=Unknown` | 任务主体正在执行 |
| `Cancelling` | `desiredState=Cancelled`、非终态 | 正在确认后端已经停止 |
| `ResultUnknown` | `Succeeded=Unknown/ResultUnknown` | 无法证明任务仍在运行或已经停止 |
| `Succeeded` | `Succeeded=True` | 成功终态 |
| `Failed` | `Succeeded=False`,reason 非 `Cancelled` | 失败终态 |
| `Cancelled` | `Succeeded=False/Cancelled` | 取消终态 |
| `Deleting` | 存在 `deletionTimestamp` | finalizer 正在停止并清理后端,覆盖其他状态 |
存在多个判定条件时按 `Deleting → terminal → Cancelling → ResultUnknown → Running → Starting →
Scheduling → Resolving` 的优先级推导,保证状态唯一。
`ResultUnknown` 不是终态,不设置 `completionTime`,也不启动 TTL。只有确认任务已经停止,才能
转为成功、失败或取消。暂时无法联系后端不等于后端执行失败。
### 正常转移
```text
Resolving
│ 引用与策略解析完成
▼
Scheduling
│ 后端逻辑执行已建立并持久化引用
▼
Starting
│ adapter 确认任务主体开始
▼
Running ───────────────→ Succeeded
└──────────────────→ Failed
```
后端在任务主体开始前就确定失败,例如 image pull、runtime 不兼容或 provisioning 失败,可以从
`Scheduling` 或 `Starting` 直接进入 `Failed`,此时 `startTime` 允许为空。
### 取消转移
```text
Resolving ─┐
Scheduling ─┤
Starting ─┼→ Cancelling → Cancelled
Running ─┤
ResultUnknown ─┘
```
尚未创建后端执行时,取消可以立即确认。已经存在或可能存在后端执行时,必须反复执行
Cancel/Observe,确认不会继续运行后才能进入 `Cancelled`。取消请求与成功完成并发时,以先从
后端确认到的不可逆事实为准:已经成功完成的任务保持 `Succeeded`,不能改写成 `Cancelled`。
### 不确定结果与恢复
```text
Ensure/Observe 返回歧义
↓
ResultUnknown
├── 找回执行 → Starting / Running
├── 找到终态 → Succeeded / Failed / Cancelled
└── 管理员确认无法继续 → Failed(BackendLost)
```
在 `Ensure` 请求超时且尚未成功写入 external reference 时,adapter 必须使用 Job UID 查询后端,
不能直接再次创建。Controller 重启后遵循相同规则。
### 删除与 TTL 转移
任意状态收到 deletionTimestamp 后进入 `Deleting`。若执行可能存在,先 Cancel/Observe,再
Delete 后端资源并移除 finalizer。TTL controller 只对具有 `completionTime` 的三个终态发起
删除;`Resolving`、`Scheduling`、`Starting`、`Running`、`Cancelling` 和 `ResultUnknown` 均不
具备 TTL 删除资格。
### 状态不变量
- `Succeeded=True/False` 是不可逆终态;终态 reason、`completionTime` 和 result 不再改变。
- `startTime` 和 `completionTime` 一旦设置不可改变;两者都存在时 completionTime 不早于
startTime。
- `Succeeded=True` 必须具有 `completionTime`,可以没有 exit code,但 adapter 应说明原因。
- `Succeeded=False` 必须具有 `completionTime`;进程失败且能取得退出码时必须保存 exit code。
- 非终态的 `Succeeded` 必须为 `Unknown`,不得省略为具有歧义的空状态。
- `Scheduled=True` 前不得设置 `startTime`;一旦为 True 不再回退。
- execution references 只能由 controller 写入;已有引用不能静默替换成新的逻辑执行。
- Job UID 是执行幂等键;同名但不同 UID 的 Job 必须被视为不同执行。
- `desiredState=Cancelled` 后不得创建新的后端执行。
- reconcile 错误和退避不得修改任务的业务结果。
### 状态机测试矩阵
实现必须至少覆盖以下 table-driven unit tests,并为关键恢复路径提供 envtest:
| 类别 | 场景 | 必要断言 |
|---|---|---|
| 正常 | 创建、开始、退出 0 | 单次 Ensure,时间与成功终态正确 |
| 正常 | 主进程非零退出 | `Succeeded=False/ProcessFailed` 与 exit code |
| 解析 | JobClass 后创建 | 不提前 Ensure,引用出现后继续 |
| 解析 | Secret/ConfigMap 或 key 后创建 | 不泄露值,解析后只启动一次 |
| 后端 | image pull 或 provisioning 失败 | 未设置 startTime 的失败终态合法 |
| 幂等 | Ensure 成功但 status 写入前崩溃 | 通过 UID 找回,不能创建第二次执行 |
| 幂等 | 重复 reconcile 与重复事件 | 不产生额外执行,不改变终态时间 |
| 恢复 | controller 在各非终态重启 | 从持久 status/reference 恢复正确状态 |
| 未知 | Ensure/Observe 超时且结果不明 | 保持非终态,不设 completionTime,不触发 TTL |
| 未知 | 后端恢复后找回运行任务 | 从 ResultUnknown 返回 Running |
| 未知 | 管理员确认执行丢失 | 只在确认后进入 `Failed/BackendLost` |
| 取消 | 在解析、调度、启动、运行阶段取消 | 不再创建或确认停止后才进入 Cancelled |
| 竞态 | 取消与成功完成并发 | 已确认成功不被取消覆盖 |
| 超时 | active deadline 到期 | 请求取消,确认停止后 `DeadlineExceeded` |
| 删除 | 每个非终态阶段删除 | finalizer 清理完成前对象不消失 |
| 删除 | 后端暂时不可达 | finalizer 保留并重试,不误报已清理 |
| TTL | 三种终态到期 | 到期前不删,到期后带 UID precondition 删除 |
| TTL | controller 在等待 TTL 时重启 | informer 恢复计时,最终删除一次 |
| TTL | 到期附近延长 TTL | 最终 GET 重新核对最新 TTL |
| 隔离 | 同名 Job 删除并以新 UID 重建 | 旧队列项和旧后端不得影响新 Job |
| 校验 | 修改不可变字段或取消后恢复 Running | schema/CEL 拒绝请求 |
| 引用 | OpenSandbox 保存 Sandbox 与 Command 引用 | 顺序重试后引用稳定且无凭据 |
### 时间
- `startTime`:adapter 确认任务主体开始执行的时间,而不是 CR 创建或 backend provisioning
时间;设置后不可改变。
- `completionTime`:进入最终成功、失败或取消状态的时间;设置后不可改变。
TTL 以 `completionTime` 为基准。若后端已经完成但结果暂时无法确认,不得猜测 completionTime。
### Execution reference
`status.execution` 是 execution 领域定义的正式 API 字段,保存 controller 重启后重新 Observe
所需的最小稳定引用:
- `adapter`:实际 adapter 类型;
- `references`:一个或多个由 adapter 定义的不透明外部引用。
```yaml
execution:
adapter: opensandbox
references:
- type: Sandbox
id: sandbox-123
- type: Command
id: command-456
```
单个 `externalID` 不足以表达 OpenSandbox 的 sandbox 与 command 两级资源。`type` 和 `id` 的
值由对应 adapter 定义,调用者只能用于诊断和关联,不能据此实现领域逻辑。execution 领域将
每个引用限制为 `type` 与 `id` 两个非空、有长度上限的字符串,不提供任意 metadata map 或
raw JSON。引用不包含 endpoint、凭据或 Secret 内容。Kubernetes adapter 可以另外通过 owner
reference 管理原生 Job,但仍需把恢复所需引用持久化,并保证同名重建安全。
该结构不提升为跨领域共享的万能 ExternalReference。VM、数据库和 LB 等领域根据真实后端
需要定义自己的受限引用 schema,只有多个领域出现语义完全一致的实际重复后才考虑共享。
### Result
`result.exitCode` 只在后端能够确定主进程退出码时设置。调度失败、取消、后端丢失等情况可以
没有退出码。`result.reason` 提供简短分类;详细诊断写入 Condition message 和 observability,
不得把完整日志写入 status。
Job UID 是跨后端日志、metrics 和 traces 的主要 correlation identity。Adapter 必须将
namespace、name 和 UID 传入执行环境或后端 metadata;高基数字段如何索引由 observability
平台决定,API 不要求把 UID 配置为日志 label。
## 删除与 finalizer
Execution controller 在可能创建外部执行前添加
`execution.ayatori.ddupan.top/job-cleanup` finalizer。
删除一个活动 Job 表示取消并清理,而不是 orphan:
1. 请求 adapter 终止执行;
2. Observe,确认执行不会继续;
3. 删除后端临时资源与短期凭据;
4. 移除 finalizer。
首版不提供用户可选 orphan policy。让一次性任务脱离控制面继续运行既难以观察,也可能产生
副作用。后端长期不可达时由管理员根据 runbook 判断并强制移除 finalizer,该操作必须可审计。
TTL controller 只发起 Job 删除,所有手工删除和 TTL 删除都经过相同 finalizer 路径。
## Adapter contract 对 API 的保证
每个 execution adapter 必须提供以下语义,而非暴露自身 SDK 类型:
```text
Ensure 幂等地建立以 Job UID 标识的一个逻辑执行
Observe 返回尚未开始、运行、成功、失败、取消或结果未知
Cancel 请求停止且可被重复调用
Delete 清理后端临时资源且可被重复调用
```
`Ensure` 的网络超时不能直接触发第二次执行。Adapter 必须能够通过 UID/metadata 查找已创建的
后端对象,或返回 `ResultUnknown` 交由人工处理。
Kubernetes adapter 与 OpenSandbox adapter 实现后,应复审 contract 和 API。只有两个真实
实现都需要且语义相同的字段才提升为通用能力;后端特有功能优先进入 JobClass 或独立
资源,不增加 `rawConfig`。
OpenSandbox 支持从 OCI image 创建 sandbox,但不保证每个 image 都能在所选 runtime、架构或
安全 profile 下成功启动。Adapter 对已知不支持的组合应尽早报告;image pull、进程启动或
运行时不兼容等实际后端失败最终统一表现为 `Succeeded=False`,并以 reason/message 保留可
诊断原因。这不要求 Ayatori 在提交前证明任意 OCI image 一定可运行。
## 待后续设计
- capability-based class 自动选择;
- 私有 image registry 的凭据和统一 workload identity;
- artifact、workspace 与 cache 的独立 API;
- Job 创建速率、并发、quota、公平调度以及是否集成 Kueue;
- observability correlation 的具体 OpenTelemetry/Loki 字段约定;
- 调用者错过 TTL 时是否需要可选的最小审计记录。
这些问题不阻塞首个 Kubernetes adapter 的 API review;image pull 的最小凭据路径必须在实现
前通过 Kubernetes 与 OpenSandbox adapter 测试验证。
## 成熟实现参考
- [Kubernetes Job](https://kubernetes.io/docs/concepts/workloads/controllers/job/)
- [Kubernetes Job API](https://kubernetes.io/docs/reference/kubernetes-api/batch/job-v1/)
- [Tekton Pipeline API](https://tekton.dev/docs/pipelines/pipeline-api/)
- [Kueue Workload](https://kueue.sigs.k8s.io/docs/concepts/workload/)
- [OpenSandbox API specifications](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/api/index.md)
+411
View File
@@ -0,0 +1,411 @@
# JobClass API v1alpha1 草案
- 状态:Draft
- 日期:2026-09-17
- API group:`execution.ayatori.ddupan.top`
- Kind:`JobClass`
- Scope:Cluster
## 目标
`JobClass` 是平台管理员提供给 Job 调用者的执行服务等级。名称表达稳定的用户语义,
例如 `default`、`rootless`、`microvm` 或 `trusted-infra`;调用者不需要知道它当前由 Kubernetes
还是 OpenSandbox 实现。
JobClass 负责:
- 选择拥有该 class 的 adapter/controller;
- 引用 adapter 自己的强类型参数对象;
- 限制允许使用该 class 的 namespace;
- 提供跨后端一致的资源默认值与范围;
- 向 Job controller 报告配置是否被接受、后端是否可用。
它不负责保存队列状态、并发配额、Job history 或任意后端 raw config。
## 设计依据
- Kubernetes `RuntimeClass` 使用 cluster-scoped class 将调用者与具体 runtime handler、调度约束
和 overhead 隔离。
- `StorageClass` 允许管理员用稳定名称提供不同服务等级,并由调用者显式或默认选择。
- Gateway API `GatewayClass` 使用 `controllerName + parametersRef` 将稳定 class API 与实现专用
参数分离,并通过 `Accepted` Condition 报告配置有效性。
- Kueue `ResourceFlavor` 将资源规格与具体节点标签、taint 等 placement 细节分开。
Ayatori 采用 GatewayClass 风格的参数引用,不在 JobClass 中建立随 adapter 数量膨胀的
union,也不使用 `map[string]any`。
## 示例
### Kubernetes execution
```yaml
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: JobClass
metadata:
name: rootless
annotations:
execution.ayatori.ddupan.top/is-default-job-class: "true"
spec:
controllerName: execution.ayatori.ddupan.top/kubernetes
parametersRef:
group: execution.ayatori.ddupan.top
kind: KubernetesExecutionParameters
name: rootless
allowedNamespaces:
matchLabels:
ayatori.ddupan.top/execution: enabled
resources:
defaults:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "2"
memory: 2Gi
maximum:
limits:
cpu: "8"
memory: 16Gi
---
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: KubernetesExecutionParameters
metadata:
name: rootless
spec:
serviceAccountName: ayatori-job
runtimeClassName: runc
scheduling:
nodeSelector:
ayatori.ddupan.top/node-role: execution
tolerations:
- key: ayatori.ddupan.top/execution
operator: Equal
value: "true"
effect: NoSchedule
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
```
### OpenSandbox execution
```yaml
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: JobClass
metadata:
name: microvm
spec:
controllerName: execution.ayatori.ddupan.top/opensandbox
parametersRef:
group: execution.ayatori.ddupan.top
kind: OpenSandboxExecutionParameters
name: microvm
allowedNamespaces:
matchLabels:
ayatori.ddupan.top/microvm-access: "true"
resources:
defaults:
requests:
cpu: "1"
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
maximum:
limits:
cpu: "8"
memory: 16Gi
---
apiVersion: execution.ayatori.ddupan.top/v1alpha1
kind: OpenSandboxExecutionParameters
metadata:
name: microvm
spec:
endpoint: https://opensandbox-api.example.internal
apiKeySecretRef:
namespace: ayatori-system
name: opensandbox-api
key: api-key
poolRef: microvm
requestMapping: AdmissionOnly
allowSecretEnv: true
allowImageAuth: true
allowInsecureHTTP: false
```
`endpoint` 默认必须使用 HTTPS。隔离的本地开发环境可以显式设置
`allowInsecureHTTP: true` 使用 HTTP;生产配置不得启用该开关。
## JobClass spec
```yaml
spec:
controllerName: string
parametersRef: ParametersReference
allowedNamespaces: LabelSelector
resources: ExecutionResourcePolicy
```
### `controllerName`
必填、创建后不可变。使用 domain-prefixed path 标识负责处理该 class 和 Job 的 controller,例如:
```text
execution.ayatori.ddupan.top/kubernetes
execution.ayatori.ddupan.top/opensandbox
```
它是 controller 所有权标识,不是任意可执行插件名称。一个 controller 只能处理自己明确支持
的名称。未来 adapter 拆成独立 Deployment 时,class 和 Job API 无需改变。
### `parametersRef`
必填、创建后不可变:
```yaml
parametersRef:
group: execution.ayatori.ddupan.top
kind: KubernetesExecutionParameters
name: rootless
```
v1alpha1 只允许引用 cluster-scoped 参数对象,并限制 `group`、`kind`、`name` 的长度与格式。
每个 controller 明确列出支持的 kind;引用 ConfigMap、Secret 或未知 CRD 不被接受。
参数引用只有一层,参数 CRD 可以进一步引用 Secret 等运行配置。禁止嵌套通用参数链和 raw
JSON,避免 class 成为无法校验的配置转发器。
### `allowedNamespaces`
可选 Kubernetes `LabelSelector`。Job 所在 namespace 必须匹配才可使用该 class。省略表示允许
所有 namespace;这是显式的管理员选择,而不是用户能力。
该检查由 Job controller 执行,并应尽可能增加 CEL/admission policy 作为快速反馈。用户即使
知道 privileged class 名称,也不能仅靠设置 `jobClassName` 绕过授权。
Namespace label 在 Job 被接受后发生变化,不中断已经运行的 Job,但影响新的 Job。紧急终止
使用独立管理员操作,不通过修改 selector 隐式杀死任务。
### `resources`
可选,定义后端无关的 CPU、内存策略:
```yaml
resources:
defaults:
requests: {cpu, memory}
limits: {cpu, memory}
minimum:
requests: {cpu, memory}
limits: {cpu, memory}
maximum:
requests: {cpu, memory}
limits: {cpu, memory}
```
规则为:
1. Job 未设置的值由 defaults 补齐;
2. 解析结果必须满足 minimum/maximum;
3. CPU 与内存 request 不得大于对应 limit;
4. 解析后的 effective resources 写入 Job status;
5. 后续修改 class 不改变已经接受的 Job;
6. adapter 不能静默降低 effective resources。
只支持 CPU 和内存。GPU、临时磁盘等资源在出现真实后端需求后增加,不先复制完整 Kubernetes
ResourceList。
Runtime/VM overhead 是 adapter 参数或后端调度实现,不计入用户请求的 task resources。
Kubernetes adapter 应优先利用 RuntimeClass Pod overhead;OpenSandbox adapter 在其 capacity
profile 中计算 microVM overhead。
## 默认 class 选择
Job 显式设置 `spec.jobClassName` 时始终优先使用该值。省略时按以下顺序解析:
1. Job namespace annotation
`execution.ayatori.ddupan.top/default-job-class`;
2. 唯一带有
`execution.ayatori.ddupan.top/is-default-job-class: "true"` annotation 的 JobClass。
若不存在默认 class,Job 保持 `Accepted=False/NoDefaultJobClass`。若存在多个全局默认值,
Job 保持 `Accepted=False/AmbiguousDefaultJobClass`,同时产生平台告警;不得模仿
StorageClass 选择最新创建对象,因为执行隔离与权限不应随创建时间变化。
解析后 Job status 保存:
```yaml
resolvedJobClass:
name: rootless
uid: 8aa4...
controllerName: execution.ayatori.ddupan.top/kubernetes
parametersRef:
group: execution.ayatori.ddupan.top
kind: KubernetesExecutionParameters
name: rootless
uid: c413...
```
Job 后续 reconcile 使用已解析引用,不能因 namespace 默认值或全局默认 class 改变而切换
adapter。若同名 class 被删除并重建,UID 不匹配,现存 Job 不得自动采用新对象。
## Status
```yaml
status:
observedGeneration: 1
conditions:
- type: Accepted
status: "True"
reason: Accepted
observedGeneration: 1
lastTransitionTime: ...
- type: Ready
status: "True"
reason: BackendReachable
observedGeneration: 1
lastTransitionTime: ...
```
### `Accepted`
表示 controller 已识别 controllerName,parametersRef 指向受支持且 schema 有效的对象,通用
resource policy 自洽。无效 class 使用 `Accepted=False` 和稳定 reason,例如:
- `UnsupportedController`;
- `InvalidParametersReference`;
- `ParametersNotFound`;
- `InvalidResourcePolicy`。
### `Ready`
表示该 class 当前具备接受新执行的基本条件。Kubernetes adapter 检查 RuntimeClass 等集群级
依赖;具体 namespace 中的 ServiceAccount 在 Job 调度时检查。OpenSandbox adapter 检查参数
引用、认证材料和后端健康端点。
`Ready=False` 阻止创建新的后端执行,但不改变已经开始 Job 的终态。Controller 仍必须尝试
Observe、Cancel 和 Delete 已存在执行,不能因 class 不 Ready 而停止清理。
Ready 是观测值,不是容量预留。容量不足、排队和并发配额属于调度系统,不通过 Ready 频繁
抖动。
## 生命周期与修改
- `controllerName` 和 `parametersRef` 不可变;切换后端必须创建新 class 名称。
- `allowedNamespaces` 与 resource policy 可以修改,只影响尚未接受的新 Job。
- adapter 参数对象允许更新 endpoint、Secret 引用和其他运维配置,以支持凭据轮换与故障切换。
- 参数更新不得使 adapter 为现存 Job 创建新的逻辑执行;Job 中已持久化的 execution reference
始终优先。
JobClass controller 添加保护 finalizer。删除 class 前必须确认不存在引用其 UID 的非终态
Job。终态 Job 已完成后端清理,不阻塞 class 删除;其 TTL 回收不再需要 class 后端配置。
参数对象删除保护由各 adapter controller 负责。在仍有 class 引用时,参数对象不得被无提示
删除。强制移除 finalizer 是管理员恢复操作,必须有 runbook 和审计记录。
## KubernetesExecutionParameters
这是 Kubernetes adapter 自己拥有的 cluster-scoped 管理员 API,不是 Job 用户 API。首版字段:
```yaml
spec:
serviceAccountName: string
runtimeClassName: string
scheduling:
nodeSelector: map[string]string
tolerations: []Toleration
podSecurityContext: PodSecurityContext
imagePullPolicy: Always | IfNotPresent | Never
```
首版原生 `batch/v1 Job` 与 Ayatori Job 位于同一 namespace,因此 Secret/ConfigMap、ResourceQuota、
NetworkPolicy、日志和 owner reference 都保持原生语义。普通调用者只拥有 Ayatori Job 权限,
不应拥有修改生成的 batch Job/Pod 的权限。
`serviceAccountName` 是每个允许 namespace 中预先提供的同名 ServiceAccount。缺失时 Job 保持
未调度并报告原因,不回退到 `default` ServiceAccount。
参数允许使用 Kubernetes 强类型的 Toleration 和 PodSecurityContext,因为这是明确属于
Kubernetes adapter 的管理员 API;这不构成向 Job API 透传 PodSpec。
## OpenSandboxExecutionParameters
这是 OpenSandbox adapter 自己拥有的 cluster-scoped 管理员 API。首版字段:
```yaml
spec:
endpoint: string
apiKeySecretRef:
namespace: string
name: string
key: string
poolRef: string
requestMapping: AdmissionOnly | Native
allowSecretEnv: bool
allowImageAuth: bool
```
- endpoint 必须为 HTTPS,Dev 显式允许的本地配置除外;不得包含认证信息。
- API key 只通过 namespaced Secret 引用,status/Event 不显示内容。
- poolRef 映射为 OpenSandbox 支持的 pool/profile 选择,不允许 Job 覆盖。
- `requestMapping=Native` 要求后端忠实接受 requests 与 limits;`AdmissionOnly` 表示 requests
只参与 Ayatori 准入,limits 映射为 OpenSandbox resourceLimits。
- Secret env 与 per-request image auth 都会使 adapter 读取 Kubernetes Secret 并把解析值发送
到 OpenSandbox API,必须由管理员分别显式启用。
OpenSandbox 参数不暴露任意 `extensions` map。未来确需使用某项 extension 时,将其提升为该
参数 CRD 中经过校验的命名字段。
## Condition 与 Job 状态机交互
Job 只有在以下条件同时满足时进入 `Accepted=True`:
- class 已按默认或显式名称解析;
- class UID 与已解析引用一致;
- class `Accepted=True`;
- namespace 符合 allowedNamespaces;
- Job resources 成功解析并处于允许范围;
- Job 使用的 Secret/ConfigMap 存在且可以按 optional 语义解析。
`JobClass Ready=False` 时,Job 保持 `Accepted=True`、`Scheduled=False`,等待后端恢复。
这样 class 配置合法性与当前可用性不会混成同一状态。
若 Job 已经 Scheduled,后续 class Ready 或 namespace label 变化不撤销执行。若 class 或参数
对象意外消失,controller 仍以 Job status 中的 controllerName、参数 UID 和 execution
references 尝试恢复;无法安全观察时进入非终态 `ResultUnknown`,不能切换 class 重跑。
## 测试矩阵
实现至少覆盖:
- 显式 class、namespace 默认和全局默认的优先级;
- 零个与多个全局默认 class;
- allowedNamespaces 允许、拒绝及接受后 label 变化;
- unsupported controllerName 和错误 parameters kind;
- parameters 不存在、稍后出现、UID 删除重建;
- resource defaults、min/max、request 大于 limit 和 quantity 边界;
- class policy 更新不改变已接受 Job 的 effective resources;
- class Ready=False 阻止新 Ensure,但不阻止现存执行 Observe/Cancel/Delete;
- Kubernetes ServiceAccount/runtime 配置缺失且不回退;
- OpenSandbox API key Secret 缺失、轮换及后端健康恢复;
- allowSecretEnv/allowImageAuth 拒绝不允许的 Job;
- class 删除被非终态 Job 阻止,终态清理后允许删除;
- 同名 class 或参数对象以新 UID 重建时不劫持现存 Job。
## 延后事项
- class 级并发和速率限制;
- Kueue LocalQueue/ClusterQueue 映射;
- capability-based 自动 class 选择;
- 多集群 Kubernetes executor;
- GPU、临时磁盘和其他扩展资源;
- workload identity 与 OpenSandbox Credential Vault;
- class 成本、优先级与抢占策略。
## 成熟实现参考
- [Kubernetes RuntimeClass](https://kubernetes.io/docs/concepts/containers/runtime-class/)
- [Kubernetes StorageClass](https://kubernetes.io/docs/concepts/storage/storage-classes/)
- [Gateway API GatewayClass](https://gateway-api.sigs.k8s.io/reference/api-types/gatewayclass/)
- [Kueue ResourceFlavor](https://kueue.sigs.k8s.io/docs/concepts/resource_flavor/)
+36 -8
View File
@@ -4,9 +4,11 @@
Git / CLI / Backstage Git / CLI / Backstage
│ │
▼ ▼
Kubernetes API + CRD kube-apiserver + etcd + CRD
API / state coordination plane
│ │
Ayatori controllers Ayatori controller-manager
scheduling / lifecycle / recovery / GC
│ │
┌──────┼──────────────┐ ┌──────┼──────────────┐
│ │ │ │ │ │
@@ -19,6 +21,20 @@ Terraform OpenBao / DNS / KaaS
Ansible 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 实例。两者可以 Dev 与 Prod 使用独立的 Kubernetes API、数据库、身份和 controller 实例。两者可以
@@ -34,16 +50,16 @@ Ayatori 不承载或重新实现数据面。控制面故障只应阻止创建与
## 资源分层 ## 资源分层
平台提供正交产品能力,例如: 平台只为已经验证的管理缺口提供正交产品能力。当前优先资源为:
- `Job`、`Sandbox`、`ManualTask`
- `VirtualMachine`
- `LoadBalancer` - `LoadBalancer`
- `Database` - `Database`
- `Bucket` - `Bucket`
- `DNSRecord` - `VirtualMachine`
- `Credential`
- `KubernetesCluster` `Run`/当前实验性的 `Job`、`ManualTask` 等可以作为控制面执行原语,但不是因为底层能运行 OCI
image 就自动成为面向使用者的计算产品。`DNSRecord`、`Credential`、`KubernetesCluster` 等只在
出现独立生命周期和真实消费者后加入;尤其 KaaS 不是预定终点。
只有具备独立领域生命周期的能力才应成为高阶资源。应用本身通过 GitOps 组合上述资源, 只有具备独立领域生命周期的能力才应成为高阶资源。应用本身通过 GitOps 组合上述资源,
重复组合可通过模板或 Composition 表达,而不是扩展中央 Application API。 重复组合可通过模板或 Composition 表达,而不是扩展中央 Application API。
@@ -56,5 +72,17 @@ Ayatori 不承载或重新实现数据面。控制面故障只应阻止创建与
2. 通过固定版本的 Terraform module 或 Ansible playbook 执行。 2. 通过固定版本的 Terraform module 或 Ansible playbook 执行。
3. 仅在必要时使用 GitOps bridge。 3. 仅在必要时使用 GitOps bridge。
Proxmox 是已知例外:其远程 API 不能覆盖所需的完整 VM 生命周期。VirtualMachine adapter 可以
按操作能力选择 Proxmox API、部署在节点上的受限强类型 Agent/CLI,或生成 `ManualTask`。Agent
必须提供版本化操作、幂等查询、operation ID 与审计,不能暴露任意 shell,也不能把 CLI 输出
直接当作长期稳定协议。
Controller 无论采用哪种执行方式,都必须提供一致的 ownership、conditions、删除语义、 Controller 无论采用哪种执行方式,都必须提供一致的 ownership、conditions、删除语义、
错误分类和恢复行为。 错误分类和恢复行为。
## API Server 边界
首选 kube-apiserver + CRD,持续复用其成熟的 watch、RBAC、版本化存储和 API 生态。
generic-apiserver 或聚合 API Server 不会减少领域 controller 的数量,只会把资源服务端、
兼容性和存储迁移责任转移给 Ayatori。只有 CRD 的限制已经形成可复现、不可通过合理领域建模
解决的阻碍时,才重新评估自建 API Server。
+70
View File
@@ -0,0 +1,70 @@
# Database 模块
Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只建立 PostgreSQL Instance 的纯领域
模型,不注册 CRD、不启动 controller,也不访问 PostgreSQL、OpenBao 或 Kubernetes Secret。
## 来源基线
完整设计合同及首批领域模型与测试提取自原 PostgreSQL Tenant Operator:
- 仓库:`git.ddupan.top/panxiao81/postgresql-tenant-operator`
- source commit:`dae546e58efa1be81e930861c87f7fb13bb12113`
- 原路径:`internal/domain/instance/`、`docs/domain-instance.md`
- 迁移日期:2026-09-20
本目录迁入该基线的 specification、architecture、API、领域、部署、安全、开发、迁移与运维
文档,并只进行 Ayatori 产品归属、API group、目录和链接适配;其余已批准行为保持不变。
迁移只使用该 commit 中已提交的文件。源仓库
`feature/instance-extension-observations` 工作树中的 `instance.go` 修改与
`instance_extensions_test.go` 未进入本切片。
代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新;
首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新
验证或删除时失效,且支持判定不授权 Tenant provisioning。后续 Ready 切片实现管理能力判定、
registry 准备决策与完整回读、Ready 重验及本轮 evidence 前置检查;沿用已批准合同,不能把旧运行
链路接回该模型。各层验证边界见 [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 只释放本地资源。
当前只有 `ObserveVersion` 版本查询用例,不能产生完整 CapabilityObservation 或 Ready。
controller 接入、Secret watch、finalizer、registry 与真实权限检查仍待后续切片;并发 CR 更新
必须由调用者通过 resourceVersion 校验。应用层沿用源实现的串行处理,本阶段未引入新的调度框架。
运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部
DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、
namespace 边界、有效值轮换、metadata 无关变化、中途轮换、重建/重试、并发读取、Forget/Close
与 TLS DNS/IP SAN、错误 CA/主机名和禁止明文降级。CI 使用 Pod runner 执行,由 runner 提供
可用的 Docker,workflow 只做预检、不自行启动 daemon;不依赖 VM。普通 lint 之外还检查
integration 标签代码。领域单测、真实 API 行为与真实 PostgreSQL 行为分别验收,不以本切片
替代整个 Instance controller 的集成验收。
## 设计入口
- [系统规格](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;
在对应实现切片完成前,不应把其中命令理解为当前仓库已经可执行的入口。
+190
View File
@@ -0,0 +1,190 @@
# v1alpha1 API 合同
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| API group | `database.ayatori.ddupan.top` |
| version | `v1alpha1` |
| 最后更新 | 2026-09-10 |
本文把已批准的系统规格映射为 CRD 字段合同。API types、生成 CRD、sample 和测试必须与本文
一致。Ayatori 尚未注册这些 API,本页是后续实现的规范来源。
## 通用约定
- PostgreSQL identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`。
- 所有引用名称使用 Kubernetes DNS label/name 的相应校验。
- Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不超过 241 个字符,确保派生的
`<instanceRef>-<metadata.name>-postgresql` 不超过 Kubernetes DNS subdomain 的
253 字符限制。
- port、TLS mode、deletion policy 等固定默认值由 CRD defaulting 提供。database、
loginRole、Secret 名称等依赖其他字段的值是 controller 语义默认值:字段保持省略,
controller 计算 effective value 并通过 status/受管资源展示,不引入 mutating webhook。
- 需要读取旧值或跨字段的校验由 CEL 或 controller 完成。
- `status` 由 controller 独占写入,禁止出现密码、Token、管理用户名或完整连接串。
- 两个 Kind 都只承诺一个 `Ready` Condition;调用方不得依赖内部协调阶段。
## PostgreSQLInstance
cluster-scoped,short name 为 `pginstance`。
### Spec
| JSON path | 类型 | 必填/默认 | 合同 |
| --- | --- | --- | --- |
| `spec.endpoint.host` | string | 必填 | PostgreSQL DNS 名;必须被服务端证书 DNS SAN 覆盖 |
| `spec.endpoint.hostaddr` | string | 必填 | 单个 IPv4/IPv6;必须被服务端证书 IP SAN 覆盖 |
| `spec.endpoint.port` | int32 | `5432` | 1–65535 |
| `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier |
| `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` |
| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 |
| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
`adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller
namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO
同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订,
现有 API types、生成 CRD 和 samples 尚未更新。
Instance endpoint、管理凭据引用可以修改。修改后 controller 重新验证。
2026-09-14 修订:v1alpha1 不实现 allowedExtensions;现有 API types、生成 CRD 和
samples 中的字段待后续移除,不作为一个可配置但被忽略的策略保留。
扩展请求按目标 PostgreSQL 实际可安装列表判断,可用列表由应用层查询。
endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置
的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。
### Status
| JSON path | 类型 | 含义 |
| --- | --- | --- |
| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation |
| `status.phase` | enum | `Pending`、`Validating`、`InitializingRegistry`、`Ready`、`Deleting` |
| `status.postgresqlVersion` | string | 从 server 回读的版本,不用于客户端解析 |
| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` |
print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。
Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry
可访问且权限预检成功。它不代表数据库已经备份或高可用。
管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO
暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。
## PostgreSQLTenant
namespaced,short name 为 `pgtenant`。
### Spec
| JSON path | 类型 | 必填/默认 | 合同 |
| --- | --- | --- | --- |
| `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 |
| `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier |
| `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login |
| `spec.extensions` | set[string] | 空集合 | 必须属于目标实例实际可安装的扩展列表;成功创建后只允许追加 |
| `spec.credential.secretName` | string | `<instance>-<name>-postgresql` | 合法的同 namespace ESO target Secret 名称 |
| `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` |
Tenant 不声明 OpenBao mount 或 path。controller 使用部署级 mount/base path 和
`namespace/name` 推导稳定路径,并用 UID metadata 验证所有权。ExternalSecret 固定为
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可以由用户指定,只需
满足 Kubernetes Secret 名称校验,不限制命名内容;省略时使用相同默认名。
`instanceRef`、`database`、`loginRole` 和 `credential.secretName` 在首次成功创建外部
状态后不可变。
`extensions` 只允许集合不变或追加;移除返回 `ImmutableField`,不会执行
`DROP EXTENSION`。`deletionPolicy` 在对象进入删除前可以修改;删除开始后以 finalizer
首次观察到的值为准,避免清理过程中改变授权范围。
### Status
| JSON path | 类型 | 含义 |
| --- | --- | --- |
| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation |
| `status.phase` | enum | controller 状态机的权威 checkpoint |
| `status.database` | string | 应用语义默认值后的实际 database 名称 |
| `status.loginRole` | string | 应用语义默认值后的实际 owner/login role 名称 |
| `status.databaseOID` | uint32 | 回读的 database OID,仅供诊断 |
| `status.credential.secretRef.name` | string | 同 namespace 目标 Secret 名称 |
| `status.credential.openBaoURL` | string | 完整 KV v2 API URL,不含认证信息 |
| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` |
Secret reference 不重复 namespace,因为它必定与 Tenant 同 namespace。OpenBao URL 格式
为 `<consumer-address>/v1/<mount>/data/<derived-path>`;不得包含 Token、用户名、密码或
query credential。
Tenant phase 枚举为 `Pending`、`Planned`、`CredentialCreated`、`RoleCreated`、
`DatabaseCreated`、`ExternalSecretCreated`、`CredentialProjected`、`Ready`、`Deleting`。
它不包含 `Failed` 或 `Retained`;失败类型由 Condition Reason 表达。
print columns:`Instance`、`Database`、`Phase`、`Secret`、`Ready`、`Age`。完整 OpenBao URL 只在
YAML/JSON status 中输出。
两个 Kind 的 `status.phase` 都是 controller 状态机的权威 checkpoint。controller 用它
选择下一候选动作,但必须在动作前后核对外部事实,不能仅凭 phase 跳过幂等检查。status
丢失或领先于实际状态时必须保守重建/纠正。自动化就绪判断仍应读取 `Ready` Condition;
phase 用于进度展示、恢复和排障。
## Condition
每种类型最多一个 Condition;更新必须保留正确的 `lastTransitionTime` 语义。
| Reason | Kind | 可重试性 |
| --- | --- | --- |
| `Reconciling` | 两者 | 正常进行中 |
| `Ready` | 两者 | 已收敛 |
| `InvalidSpec` | 两者 | 修改 spec 前不会恢复 |
| `ImmutableField` | Tenant | 恢复原值或重新迁移 |
| `DependencyUnavailable` | 两者 | 自动重试 |
| `AuthenticationFailed` | Instance | 修复凭据/TLS 后重试 |
| `InsufficientPrivileges` | Instance | 修复管理 role 后重试 |
| `InstanceNotReady` | Tenant | Instance 恢复后重试 |
| `Conflict` | Tenant | 人工解除名称/所有权冲突 |
| `ProvisioningFailed` | Tenant | 按错误类别退避重试 |
| `CredentialProjectionFailed` | Tenant | ESO/Secret 恢复后重试 |
`Ready=True` 必须使用 Reason `Ready`。处理中为 `Unknown/Reconciling`;已知未满足合同为
`False`。Condition message 可以包含资源名和错误类别,禁止包含凭据值或完整 Secret。
## 删除语义
- `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。
- `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。
- Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用
(包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。
不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。
- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得
开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。
## 示例
```yaml
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLInstance
metadata:
name: shared
spec:
endpoint:
host: postgresql.home.arpa
hostaddr: 192.0.2.10
port: 5432
database: postgres
sslMode: verify-full
adminCredentialRef:
name: shared-postgresql-admin
---
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: netbox
namespace: netbox
spec:
instanceRef: shared
database: netbox
loginRole: netbox
extensions: [pg_trgm]
credential:
secretName: shared-netbox-database-credentials
deletionPolicy: Retain
```
+99
View File
@@ -0,0 +1,99 @@
# 系统架构
本文是已批准 [`specification.md`](specification.md) 的架构视图。规范定义外部行为,
本文解释组件边界;二者冲突时以规范为准。Ayatori Database 模块目前只有首批领域模型,尚未
注册 API 或接入运行链路。
## 组件与数据流
```text
GitOps / kubectl / Terraform / Backstage
|
v
Kubernetes API (CRD)
|
v
Ayatori Database controller
| | |
v v v
PostgreSQL DBMS OpenBao KV ExternalSecret
catalog+registry |
v
Kubernetes Secret
```
- Kubernetes `spec` 保存期望状态;`status.phase` 保存 controller 状态机 checkpoint,
其他 status 字段保存可重建的观察结果。整个 status 都必须能由外部事实保守恢复。
- PostgreSQL catalog 保存 database、role、grant 和 extension 的实际状态。
- 两个 CR 的 `status.phase` 是 controller 状态机的权威 checkpoint。
- PostgreSQL 管理 database 中的 controller registry 只负责所有权、安装身份和保留标记。
- OpenBao KV v2 是应用凭据的事实来源。
- External Secrets Operator(ESO)读取 OpenBao,并创建应用使用的 Kubernetes Secret。
controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 OpenBao PKI,也不直接
把明文凭据写入 Kubernetes API。
## 资源模型
`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的
DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。
实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。
管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的
Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取
管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。
`PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner
的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV
记录,以及同 namespace 的 ExternalSecret 和目标 Secret。
Tenant namespace 只提供 Kubernetes RBAC 和身份边界。database 与 role 名称在一个
Instance 内仍然全局唯一。
## Reconcile 与所有权
系统采用最终一致性,不在 Kubernetes、PostgreSQL、OpenBao 和 ESO 之间假装存在分布式
事务。每个外部写入前在 CR status 记录阶段,执行幂等操作,回读验证,再推进阶段:
```text
Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated
-> ExternalSecretCreated -> CredentialProjected -> Ready
```
controller 每轮同时读取 CR、registry、PostgreSQL catalog、OpenBao metadata 和 ESO
投射状态。`status.phase` 是状态机 checkpoint,但不能替代外部回读;丢失或与事实冲突
时必须保守重建/纠正。`metadata.generation` 只表示 spec 修改;Condition 的
`observedGeneration` 表示该版本是否已经完成一次有结论的协调。
所有权使用 Instance UID、Tenant UID 与 namespace/name 验证。database/role COMMENT
可以辅助排障,但不能代替 registry。未知资源只报告 `Conflict`,不得修改、接管或
删除。Retain 后用相同名称重建 CR 会获得新 UID,因此仍然冲突。
## 创建与删除边界
创建时先校验全部输入和冲突,再生成一次密码并写入 OpenBao,随后创建 role、database、
extension 和 ExternalSecret。只有 ESO 已投射 Secret 且应用凭据实际登录成功,Tenant
才可 Ready。
`Retain` 是默认删除策略,只移除 Kubernetes 管理关系并保留外部资源。显式 `Delete`
使用 finalizer,在重新验证所有权后依次删除 ExternalSecret/Secret、连接、database、
role、OpenBao KV 历史和 registry。详细恢复与逃生步骤见
[`operations.md`](operations.md)。
## 网络与 TLS
Instance 同时公布 DNS `host` 和 IP `hostaddr`。PostgreSQL server 证书必须包含对应的
DNS SAN 和 IP SAN,消费者自行选择可达目标,并可使用 `verify-full` 验证。OpenBao PKI
持有 CA 私钥并签发服务端证书;controller 只挂载公开 CA bundle。
OpenBao 的 controller 内部地址和外部消费者地址可以不同。Tenant status 同时提供目标
Kubernetes Secret reference 和不含认证信息的 OpenBao KV v2 API URL。
## 文档入口
- API 字段与 Condition:[`api-reference.md`](api-reference.md)
- 安装、依赖和配置:[`deployment.md`](deployment.md)
- 本地与 CI 测试:[`development.md`](development.md)
- 安全模型与最小权限:[`security.md`](security.md)
- 现有数据库迁移:[`migration.md`](migration.md)
- 日常排障和删除逃生:[`operations.md`](operations.md)
+130
View File
@@ -0,0 +1,130 @@
# 部署与配置
> 本页迁入作为 Database 模块的目标部署合同。Ayatori manager flags、manifests 与发布装配尚未
> 实现;原设计行为保持有效,但当前仓库不能直接按本页完成部署。
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao |
| 最后更新 | 2026-09-10 |
本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。当前 manifests 尚未实现这些
配置,示例是后续实现合同,不可直接用于现有脚手架。
## 依赖与顺序
1. 准备 PostgreSQL VM、持久盘、备份和网络入口。
2. 用 OpenBao PKI 签发 PostgreSQL server 证书,包含 Instance `host` 的 DNS SAN 与
`hostaddr` 的 IP SAN;配置 PostgreSQL 强制 TLS。
3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。
4. 在 OpenBao KV v2 写入管理 role 凭据。
5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。
6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller
namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的
`ClusterSecretStore`。
7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。
8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。
任何一步都不得把真实密码、Token、kubeconfig 或 CA 私钥提交进 Git。
## Controller 配置合同
controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须
在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。
| CLI flag | 必填/默认 | 说明 |
| --- | --- | --- |
| `--openbao-address` | 必填 | controller 可访问的 OpenBao API address |
| `--openbao-consumer-address` | 默认同 `--openbao-address` | 写入 Tenant status,必须能被预期外部消费者解析 |
| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 |
| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role |
| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` |
| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 |
| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 |
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/`
在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以
`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata`
API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。
Tenant 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过
Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer
address 拼为 `<address>/v1/<mount>/data/<base-path>/<namespace>/<metadata.name>`。
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、评估现有 Tenant,并走明确迁移;实现应把 mount/base path 视为安装
身份的一部分并在 registry 留存,以便检测错误配置。
## PostgreSQL 管理 role
生产部署禁止使用 superuser。管理 role 至少需要:
- 连接管理 database、读取必要 catalog;
- 创建/修改受管 login role;
- 创建 database 并指定 owner;
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
- 连接租户 database 并创建实例实际支持、租户申请的 extension;
- 创建和维护 controller 专属 registry schema/table;
- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。
部分 PostgreSQL 操作天然要求较高权限,尤其终止其他 session 和安装某些 extension。
应优先使用 PostgreSQL 预定义角色、受控 SECURITY DEFINER 管理函数或限定数据库的
授权;任何不得不使用 superuser 的 extension 都必须按实例单独记录,不得扩大默认
controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成测试固化。
## OpenBao 与 ESO
controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2
data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。
管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。
`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。
controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
`sslmode`。
## Kubernetes RBAC
- controller 可读/写 Instance、Tenant 的 status/finalizer 和 Event。
- controller 可在 Tenant namespace 创建、读取、更新、删除 ExternalSecret,并只读检查
对应 Secret 是否完成投射。
- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller
配置或其他 namespace 的 ExternalSecret。
- controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace
的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。
- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用
凭据,只检查 Secret 存在性和 ESO 状态。
## 升级与回滚
v1alpha1 尚不承诺跨版本转换。升级前备份 CR、PostgreSQL registry 和 OpenBao metadata,
先在隔离 Kind 环境运行 E2E。禁止在同一组 CR 上同时运行两个 controller 版本。若新版本
在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或 registry schema 迁移时,
必须先写独立升级规格和回滚步骤。
## 上线验证
```text
PostgreSQL TLS 与备份验证
-> OpenBao auth/policy 验证
-> ClusterSecretStore Ready
-> controller Ready/leader elected
-> Instance Ready
-> 测试 Tenant Ready
-> DNS host 与 IP hostaddr 分别登录
-> 删除测试 Tenant 并验证所选策略
```
生产 homelab 上线前还必须完成 [`security.md`](security.md) 的权限检查和
[`operations.md`](operations.md) 的备份/逃生检查。
+233
View File
@@ -0,0 +1,233 @@
# 开发与测试环境
> 本页迁入作为 Database 模块的测试分层与 fixture 合同。旧项目的 Make target、devcontainer
> 和脚手架版本尚未适配 Ayatori;实现时应复用 Ayatori 现有工具链,并保持这里定义的测试边界。
## Ayatori 已接入的凭据切片测试
本节命令已在 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 不可达、权限和镜像拉取失败。
这些测试尚不包含 Instance CRD/controller、Secret watch、status/finalizer 事件链、registry、
权限探测矩阵、ESO 或 Tenant 供应。版本查询成功不意味着 Instance Ready。
本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab
中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性
容器。这样既避免污染真实数据,也能把启动顺序固化为命令。
## 是否需要开发 VM
默认不需要。仓库的 devcontainer 使用独立 Docker-in-Docker daemon,Go 工具链、
Kind 节点和依赖容器都与宿主机环境隔离。宿主机只需要能够运行支持 privileged
container 的 Docker/Dev Container 环境。
只有以下情况才建议增加一台可随时重建的开发 VM:
- 宿主机不允许 privileged devcontainer;
- 无法安全使用 Docker socket 或 Docker-in-Docker;
- 本机地址段与 Kind/Docker 网络持续冲突;
- 需要长期运行、接近 homelab 网络和 TLS 配置的验收环境。
即使使用 VM,也应在 VM 内继续执行本文相同的容器化流程;不要把 VM 配置成第二套
手工维护的开发环境。
## 环境分层
| 层次 | Kubernetes | PostgreSQL / OpenBao | 用途 |
| --- | --- | --- | --- |
| 单元测试 | fake client | fake client | SQL 计划、状态转换和错误分类 |
| controller 集成测试 | envtest | fake adapter | CRD、watch、status、finalizer、ExternalSecret 对象 |
| adapter 集成测试 | 不需要 | Docker Compose | 真实协议、权限和幂等行为 |
| E2E | 一次性 Kind + ESO | Kind 内测试实例 | 凭据投射、TLS、完整网络和删除路径 |
envtest 只启动 API server 和 etcd,没有 kubelet、scheduler、ESO 或 controller-manager,
因此不能用它验证 Deployment、Pod 调度或 Service 网络。此类行为必须留给 Kind
E2E。
## 首次准备
推荐用支持 Dev Containers 的编辑器打开仓库。devcontainer 会提供 Go、Docker、
Kubebuilder、Kind 和 kubectl。脚本固定 Kubebuilder 4.15.0、Kind 0.33.0 和
kubectl 1.36.0,与当前脚手架和 Kubernetes Go module 对齐。容器启动后先确认:
```sh
go version
docker info
kubebuilder version
kind version
kubectl version --client
```
不要在仓库中保存真实 OpenBao Token、数据库密码或 kubeconfig。Compose 中的
`postgres-dev-only` 和 `dev-only-root-token` 是仅绑定回环地址、随容器销毁的公开
测试值,不得复制到其他环境。
## 日常开发的正确顺序
### 1. 生成并验证纯 Go/Kubernetes 部分
```sh
make manifests generate
make test
make lint
```
`make test` 会下载与 `go.mod` 中 Kubernetes minor 版本匹配的 envtest 二进制,
启动临时 API server/etcd,测试结束后自动关闭。
规格实现后,快速测试必须覆盖默认值/校验、Condition `observedGeneration`、两个 CR 的
status 状态机、不可变字段、extension 只追加、registry 所有权和外部错误分类。envtest 只断言 controller 创建了正确
的 ExternalSecret;它不能证明 ESO 已生成 Secret。
### 2. 启动 PostgreSQL/OpenBao adapter 依赖
只有开发 PostgreSQL/OpenBao adapter 或完整 reconcile 时才需要:
```sh
make dev-up
make dev-smoke
```
启动顺序由 Compose healthcheck 保证:
1. 创建独立 Compose 网络;
2. 启动 PostgreSQL 和 OpenBao;
3. 等待 PostgreSQL `pg_isready` 成功;
4. 等待 OpenBao `bao status` 成功;
5. smoke test 执行 `SELECT 1`;
6. smoke test 在 OpenBao dev server 默认的 `secret/` KV v2 mount 写入并读回测试管理
凭据。
本机进程使用以下端点:
```text
PostgreSQL: postgresql://postgres:[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。registry 测试会删除并重建固定的测试 schema,因此禁止将该变量指向真实
homelab database。测试后运行 `make dev-down` 清理依赖。
Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问
Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络,
并通过 `postgres:5432` 运行集成测试;清理前先断开该网络,才能删除 Compose
network。本机执行仍使用默认的 `127.0.0.1:15432`。
本机运行 controller 时,先确认当前 kubeconfig 指向专用 Kind,而不是真实 homelab:
```sh
kubectl config current-context
make setup-test-e2e
kubectl config current-context
make install
make run
```
此时 controller 运行在开发容器内,可以直接访问上面的回环端口。若要验证 Tenant
Ready,专用 Kind 还必须安装 ESO、创建测试 ClusterSecretStore,并让 Kind workload
能够访问测试 OpenBao。不要把包含
`127.0.0.1` 端点的样例部署到 Kind 内;Pod 中的回环地址只指向 Pod 自身。
### 4. 清理
```sh
make dev-down
make cleanup-test-e2e
```
`dev-down` 会删除 Compose volume;所有数据库和 OpenBao dev 数据都应视为一次性。
## E2E 顺序
CI 的 E2E 与本机 `make run` 不同:controller 会作为 Pod 运行在 Kind 中。因此完整
E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等待依赖 Ready 后
再创建 `PostgreSQLInstance` 和 `PostgreSQLTenant`:
```text
创建 Kind
-> 安装 CRD
-> 部署 PostgreSQL/OpenBao fixture,签发含 DNS/IP SAN 的测试证书
-> 安装 ESO,配置 OpenBao auth/policy 和 ClusterSecretStore
-> 等待依赖 Ready 并写入测试管理凭据
-> 构建并加载 controller image
-> 部署 controller
-> 创建 Instance
-> 等待 Instance Ready
-> 创建 Tenant
-> 等待 Tenant Ready
-> 验证 registry、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret
-> 分别使用 DNS host 与 IP hostaddr 登录
-> 删除 Tenant 并分别验证 Retain 与 Delete(含故障点重试)
-> 删除 Kind
```
当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动
PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry
migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需
后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。
## 测试数据与泄漏检查
- 只使用显眼的固定 canary 测试密码,测试后扫描日志、Event、Condition、metrics 和
CR dump,出现 canary 即失败。
- 每个最终一致性阶段都注入一次中断,重启后验证密码不变且阶段只向前推进。
- 清空、落后或伪造超前的 `status.phase` 后验证它能从外部事实保守恢复/纠正,且不会
跳过任何回读。
- 为未知同名 database、role、Bao record 和伪造 COMMENT 分别构造 Conflict。
- Delete 在每个外部删除步骤失败后重试,确认未误删非当前 UID 资源。
- 迁移测试按 [`migration.md`](migration.md) 完整执行,不以单纯 `pg_restore` 成功代替
应用读写和回滚验证。
## 故障排查
查看依赖状态与日志:
```sh
docker compose -f hack/dev/compose.yaml ps
docker compose -f hack/dev/compose.yaml logs postgres openbao
```
如果 envtest 报端口监听失败,通常是当前执行环境禁止监听回环端口,而非 controller
失败;在 devcontainer 或允许本机监听的 runner 中执行。若 Kind 无法创建,先运行
`docker info`,确认当前用户可以访问 devcontainer 内的 Docker daemon。
+253
View File
@@ -0,0 +1,253 @@
# Instance 领域对象规格
状态:Draft,含已确认决策。日期:2026-09-13。
上层合并边界见 [ADR-0008](../decisions/0008-merge-postgresql-tenant-operator.md)。本文只展开 Instance,不包含 Tenant 的供应
实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。
## 1. 对象职责与生命周期
Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、
根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao,
不查询权限或初始化 registry。
本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。**
应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收
context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。
每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。
管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。
身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或
registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。
新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。
下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。
## 2. 字段与值对象
所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过
下一轮装载新的 definition 处理,不提供任意 SetPhase/SetReady/SetEndpoint。
| 字段 | 类型与内容 | 来源/持久化 | 修改规则 |
| --- | --- | --- | --- |
| identity | InstanceIdentity:UID、name | CR metadata | 本次对象身份内不可变;同名新 UID 是新对象 |
| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 |
| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 |
| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 |
| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 |
| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 |
| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 |
| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 |
| reportedVersion | 可选服务器版本字符串 | status.postgresqlVersion;验证后从服务器更新 | 仅供展示,不能证明连接成功 |
| deleting | 是否已请求删除 | metadata.deletionTimestamp 映射 | 本轮不可变;优先于其他动作 |
| evidence | 可选 CapabilityEvidence | 本轮外部回读;不新增 status 字段 | 重建时始终为空,不能从 Ready Condition 伪造 |
Endpoint 的构造约束沿用 API:非空 host、合法 IP、1–65535 端口、合法 PostgreSQL
identifier、显式 TLS mode,禁止隐式降级。CredentialReference 包含合法 Secret 名称
及非空字段名,不包含 namespace 或 Bao path;namespace 由应用层固定为 controller
自身 namespace。这里校验领域值,不在对象里校验整个 controller 部署配置。
CapabilityEvidence 包含本轮目标绑定(Instance UID、revision、endpoint、凭据引用)、
server version、管理能力检查结果、registry 观察结果。registry 结果区分
Absent/NeedsMigration/Usable;连接失败不能当作 Absent。它不包含密码、token 或 DSN。
管理能力要求来自规格中的 role/database/grant/extension 操作,不等价于“能执行
SHOW server_version”。具体权限探测矩阵需在 PostgreSQL 适配器规格中定义,不能
让一个没有定义检查内容的布尔值承担验收。
不属于 Instance 的字段:Tenant 清单、客户端、连接池、token TTL、CA 文件句柄、
Kubernetes resourceVersion。resourceVersion 留在应用层作为乐观并发保存的前提。
### 扩展支持判定(2026-09-14 已确认方向)
v1alpha1 按目标 PostgreSQL 实际可安装的扩展列表判断请求,不实现管理员 allowlist。
allowlist 仅保留为后续可选策略,不接受一个看似生效、实际被忽略的策略字段;现有
CRD 的 allowedExtensions 应在对应 API 改动中移除,本次只修订文档。
应用层查询实际可用扩展并提供与本轮目标绑定的观察;Instance 只做集合判断,不
访问数据库。不沿用之前提议的字符正则,不自动改大小写或名称;SQL 适配器仍须
安全引用 identifier。可用列表不是已安装列表,也不保证权限或其他安装前提满足。
未观察/查询失败不得当作空集合或不支持;不得用旧目标的列表授权新目标的操作。
非空请求须属于已观察的可用集合,返回不支持的名称;空请求无需扩展支持判定,
但不绕过 Instance 的其他就绪要求。安装后仍需回读,不能以集合匹配代替安装验证。
列表变化不触发自动卸载;已有扩展的漂移处理留到 Tenant 用例细化。
## 3. 设计签名
```text
Reconstitute(identity, revision, definition, checkpointSnapshot, deleting)
-> Instance | InvalidDefinition
Instance.BeginValidation() -> Outcome
Instance.AssessManagement(observation: CapabilityObservation) -> Outcome
Instance.PlanRegistryPreparation(observation: CapabilityObservation)
-> AlreadyUsable | PreparationAllowed | PreparationDenied
Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome
Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome
Instance.CheckExtensions(requested: ExtensionSet)
-> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved
Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady
Instance.BeginDeletion() -> Outcome
Instance.Snapshot() -> InstanceSnapshot
```
Outcome 是正常推进、已知失败或方法前提不成立,不包含重试秒数、Kubernetes patch
或原始驱动错误。InstanceSnapshot 只包含 checkpoint、observedRevision、readiness、
reportedVersion,不能序列化 evidence。快照与集合访问返回值副本。
CapabilityObservation 是不可变的事实输入:目标绑定、服务器版本、管理能力检查项和
registry 观察结果;各检查项区分成功、失败、未观察,未观察不视为成功。失败只含安全
类别,不含驱动异常或凭据。对象校验目标绑定与当前身份/配置一致,拒绝不匹配输入,
不改变状态;完整性不足不能产生 Ready。观察结果由应用层收集,对象不能自行证明
这些事实的真实性或实时性;采集来源、同轮次关联和并发检查由应用层保证。
RegistryPreparationResult 为操作失败(目标绑定、安全失败类别)或操作后的完整回读
观察。单独的“迁移调用成功”不是就绪证据。CapabilityEvidence 是对象接受并判定满足
要求的观察值,不是调用方传入的 Ready 布尔值。
### 构造与恢复
Reconstitute 校验期望 definition;无效输入不构造一个可参与用例决策的 Instance。
入口把 InvalidDefinition 映射成 InvalidSpec,不必为了报告坏 CR 而制造非法领域对象。
checkpoint 缺失或未知时保守使用 Pending;reportedVersion 和 Ready 都只是旧观察,
evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得通过供应检查。
### 方法合同
| 方法 | 前置条件/输入 | 行为与状态变化 | 失败语义 |
| --- | --- | --- | --- |
| BeginValidation | 未删除;初次登记、配置变更或需重建 checkpoint | 转 Validating,readiness=Unknown,清空 evidence;不做外部 IO,不推进 observedRevision | deleting 时不启动验证 |
| AssessManagement | 未删除;Validating;目标匹配的观察 | 判定管理访问、metadata、权限是否满足;registry 可用或可安全准备时转 InitializingRegistry,仍为 Unknown;不执行探测 | 失败保持 Validating,NotReady,observedRevision=当前版本 |
| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision |
| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready |
| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 |
| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 |
| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 |
| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 |
| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 |
领域方法只检查对象状态,不知道 checkpoint 是否已落盘。“已持久化 checkpoint”是
应用用例执行外部写入的前提。内存字段变成 InitializingRegistry 不代表已保存成功;不能
在同一轮无条件接着执行迁移。通过用例测试验证此约束,而不是伪造一个内存事务。
AssessManagement 成功只是中间步骤,observedRevision 不前移;完成就绪判定或
明确失败才产生相应有结论结果。旧版本字符串可供诊断,但失败会清空 evidence。
Instance 不在本轮暴露 CreateDatabase/DeleteDatabase:Tenant 的供应/销毁授权来自
Tenant 和 OwnershipClaim,不是从 Instance.Ready 推导。数据库执行能力如何承接
已授权动作,留到 Tenant 对象规格,不在这里设计第二个万能 service。
## 4. 应用层与外部访问边界
```text
应用层依赖的适配器能力(不传入 Instance):
InspectManagement(context, target) -> ManagementObservation | AccessFailure
InspectRegistry(context, target) -> RegistryObservation | AccessFailure
EnsureRegistry(context, target) -> Completed | AccessFailure
```
应用层在 IO 前绑定目标并关联结果,领域对象在接受观察时检查身份和配置匹配;旧
endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移,
不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。
适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance
管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、
认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。
registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。
管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret,
ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。
首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。
检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池
并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation
变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和
刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。
controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。
## 5. 状态转换与初始化走查
```text
Pending --BeginValidation/保存--> Validating
Validating --AssessManagement(观察)/保存--> InitializingRegistry
InitializingRegistry --AssessRegistryResult(回读结果)/保存--> Ready
Ready --配置变化或访问失败/保存--> Validating
Ready --registry 需修复/保存--> InitializingRegistry
任意阶段 --删除请求/保存--> Deleting
```
1. 入口读取 CR,装配 definition、checkpointSnapshot;客户端不注入领域对象。
2. 应用层按 checkpoint 协调用例;首次调用 BeginValidation,没有 IO。
3. 保存 Validating。若保存失败,结束本轮,不执行 registry 写入。
4. 下一轮应用层调用适配器探测实例,将观察交给 AssessManagement;领域判定通过后
保存 InitializingRegistry,保存失败则停止,不进行迁移。
5. 再下一轮应用层采集前置观察,调用 PlanRegistryPreparation。仅在意图已持久化且
领域允许时调用 EnsureRegistry;AlreadyUsable 则跳过写入,PreparationDenied 则
保存失败结果并停止。允许的操作完成后回读,交给 AssessRegistryResult 决定能否
Ready;操作失败也用安全结果交回,不在应用层直接修改 phase。
6. 入口用原 resourceVersion 前提保存快照;并发变更导致冲突时重新装载,不覆盖新状态。
7. 后续 Ready 检查先由应用层探测,再调用 AssessReadiness;Tenant 用例同样获取当前事实,不能
仅凭另一个 CR 的 Ready Condition 永久缓存授权。实际资源写入仍须处理并发变化。
阶段调度和外部操作顺序在应用层;“观察是否满足业务要求、是否允许准备 registry、
哪些结果算完成、失败退到哪里”在 Instance 方法内。controller 不重复这些规则,
也不直接把 phase 设置成 Ready。领域允许操作并不锁住外部世界,适配器仍须保障幂等
和并发安全;禁止把旧观察当成永久授权。
## 6. 不变量与恢复验收
- UID 不随名称复用;不同 UID 的 evidence/结果不可互用。
- 未完成当前配置的能力回读,不能新产生 Ready,也不能通过供应检查。
- checkpoint 可以落后或被伪造;每次初始化/供应前都核对事实。status 清空只需重新
验证和幂等准备,不删除 registry,更不能重新生成 Tenant 密码。
- 迁移成功而 status 保存失败:重试回读已存在 registry,安全完成,不重复破坏性写入。
- registry 在 Ready 后消失:下一次回读撤销 Ready,保存修复意图后才能重新准备。
- 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用
已超时的 IO 上下文而丢失失败状态。
- 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或
Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。
删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留
finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于
领域 IO,Instance 只根据删除请求禁用供应能力。
- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被
finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消
已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。
- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。
- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。
- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入
得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。
上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试
验证。本文为设计文档,未执行或宣称通过这些测试。
## 7. 本轮待评审与后续阻塞项
本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。
管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时
重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则
均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。
批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。
## 8. 领域实现与验证边界(2026-09-21)
`internal/database/domain/instance` 按上述方法合同实现 Ready 纯判定。输入分别表达连接、
metadata、role、database、grant、extension 管理能力,以及 registry 的未观察、缺失、需迁移、
可用、不兼容和不可访问状态。检查零值或未知值按证据不足处理;操作失败只使用封闭的安全
类别,不接收驱动错误。`Snapshot.Failure` 是 Condition 映射的领域输入,不新增 CRD/status 字段。
管理能力分别指目标连接可用、服务器 metadata 可读,以及执行规格 §7 所要求的角色、数据库、
授权和扩展管理操作的能力;不是仅凭版本查询或扩展可用列表判定权限。具体 SQL 权限探测矩阵、
最小权限角色和扩展权限例外仍须在 PostgreSQL adapter 切片定义并用真实后端验证。
registry 不兼容独立保留为领域失败类别,不将其误报为权限不足;公开 Condition Reason 的映射
留待 API/application 切片按原合同评审。
领域测试验证完整回读、缺少检查项、状态重建、重复判定、目标不匹配、配置变化、依赖失败、
registry 丢失/不兼容、操作结果不确定和删除限制。只有本轮完整能力判定通过后,Instance
前置条件检查才通过;这不授予 Tenant 所有权,也不替代实际写入前的并发校验。
本切片不新增 controller、adapter、Secret 读取或外部生命周期操作。checkpoint 保存失败、
resourceVersion 冲突、watch 与 finalizer 事件链需由后续 application/envtest 验证;SQL 探测、
registry 初始化/迁移、超时后的真实状态回读和并发幂等由 PostgreSQL 集成测试验证;Secret
变化后的连接刷新由 Kubernetes API 加真实 PostgreSQL 的集成测试验证。纯领域测试不能证明
Database 已可运行或这些集成合同已完成。
+149
View File
@@ -0,0 +1,149 @@
# 领域模型设计草案
状态:Draft,含已确认决策。日期:2026-09-13。
本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用
设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以
[系统规格](specification.md) 为准;下列未决问题不能由实现自行决定。
PR #6 的代码和已有 registry 表结构是可评估的实现素材,不反向决定领域模型。
## 1. 领域与统一语言
本系统的领域是“在共享 PostgreSQL 上供应并管理应用租户”,不是数据库服务器运维。
v1alpha1 先采用一个限界上下文,不把 PostgreSQL、Bao、Kubernetes 各自当成业务上下文。
| 术语 | 含义 | 不是什么 |
| --- | --- | --- |
| Instance | 平台登记的外部 PostgreSQL 管理对象及其供应策略 | 连接池、VM 或 controller 单例 |
| Tenant | 一个应用的数据库使用合同及受管资源生命周期 | PostgreSQL database 的别名 |
| Database | 租户数据库的名称、owner、扩展等期望描述与实际观察 | 包含 Bao 登录与连接关闭的操作接口 |
| LoginRole | 同时作为 database owner 和应用登录身份的角色 | 额外的 NOLOGIN owner |
| OwnershipClaim | 某个 Tenant 身份对一组资源名称与凭据位置的所有权声明 | 工作流阶段或仅凭名称推断的归属 |
| CredentialLocation | 固定推导的凭据位置及所有权关联 | 密码本身或用户可任意选择的 KV path |
| CredentialProjection | 把既定凭据交付到目标 Secret 的要求与观察结果 | controller 直接写入明文 Secret |
UID 表示一次 Kubernetes 对象身份;namespace/name 用于定位,不足以证明归属。
database OID 是诊断观察值,不充当本系统的租户身份。
## 2. 候选聚合边界
### Instance:实例能力与供应策略
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察,
以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。
其行为包括:
- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。
- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。
- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。
- 在 registry 初始化完成并回读验证后,接受新的就绪结果。
“探测实例”“准备管理 registry”是应用用例协调的外部操作,不是 Instance 的 IO 方法。
领域对象只接收观察值,负责前提、规则和状态决策;应用层调用适配器获取事实与执行
获准操作。领域对象不持有或调用外部访问端口、客户端或回调。具体选择见
[Instance 字段与行为](domain-instance.md),仍处于待评审状态。
### Tenant:供应合同与资源生命周期
Tenant 是另一个候选聚合根,通过身份引用 Instance,而不是 Instance 的聚合成员。
操作一个 Tenant 不应要求装载、锁定或保存整个实例的租户集合。
Tenant 持有有效的 database/role 名称、请求的扩展、凭据交付目标、删除策略,以及
已建立的资源绑定。它负责:
- 检查绑定后的不可变字段、extension 只追加规则。
- 判断外部部分状态属于本 Tenant、尚不存在,还是与未知资源冲突。
- 决定是否允许继续供应、何时达到 Ready、是否允许释放受管资源。
- 按 Retain/Delete 合同限制行为,禁止把保留资源自动认领给同名新 UID。
Database、LoginRole 和 CredentialProjection 暂不设独立聚合根或独立 CRUD 用例。
它们可作为 Tenant 内的资源描述与观察值;有规则才增加行为,不为了“充血”添加方法。
真实 PostgreSQL database/role 的存在不意味着内存中必须各有一个有身份的实体。
聚合边界是业务规则的保护边界,不表示 Tenant 对应的 PostgreSQL、Bao、ESO 资源
能够一次事务提交。跨系统供应必须允许部分完成。
### OwnershipClaim:跨租户唯一性与持久证据
名称唯一性不可能只靠某个 Tenant 的内存检查保证。需要一项领域能力,在持久化边界
原子认领资源;已有 registry 是其适配器候选,仍需结合 catalog 和 Bao metadata 检查。
Claim 与 Tenant 关联,但不随 Tenant CR 消失:Retain 后证据必须继续存在。因此不能
把它仅视为 CR 的附属 status。是否作为独立的小聚合,先以“可独立持久化、保留并保护
归属不变量的声明”建模;不因此引入新的 CRD。
- 同一身份、同一绑定的重复认领可以成功;不同 UID 或不同绑定不能覆盖。
- Claim 预留名称不等于证明同名外部资源由本 controller 创建。
- 实际写入仍须核对所有权,不能把先查后建当成并发安全保证。
- 当前 registry 的数据库事务不能覆盖 Bao;跨实例的凭据路径竞争也不能靠单个
registry 的唯一约束解决。写入前提与条件创建协议需单独设计和验收。
## 3. 领域、用例与适配器的分工
| 层 | 承担的职责 | 禁止承揽的职责 |
| --- | --- | --- |
| 领域对象/策略 | 身份、有效合同、归属判断、允许的动作、完成条件 | 外部 IO(包括通过接口间接调用)、解析 CLI、生成 Kubernetes Condition |
| 应用用例 | 装载模型与事实、持久化意图、调用能力、回读、提交结果 | 另写一套绕过领域规则的判断流程 |
| controller 入口 | CR 映射、调度、watch、重试、status/finalizer 写入 | 在 reconcile 中重新定义业务规则 |
| 基础设施适配器 | PostgreSQL、registry、Bao、ESO 的实际读写与并发保障 | 自行决定接管、改密码或扩大删除范围 |
| 启动装配 | 校验部署配置,创建共享客户端、连接管理器及用例依赖 | 把连接生命周期当成 Instance 的业务状态 |
领域可使用独立的身份、endpoint、identifier、extension 集合等值对象,不依赖 CRD
类型、pgx pool 或 Bao SDK。Kubernetes 对象的存取与 registry 的存取不是一个通用
`Save(Tenant)` 可以原子完成的事情;不虚构跨系统 Unit of Work。
暂不引入事件总线、事件溯源、通用聚合框架或全套 Repository CRUD。领域建模的依据
是业务规则,而不是接口和目录数量。
## 4. 状态与恢复
CR `status.phase` 仍是已批准的工作流 checkpoint,不在内存对象或 registry 再建一套
权威 phase。领域对象可以由 CR 的期望状态、checkpoint 和外部观察重新构造。
phase 只决定候选步骤,外部证据决定该步骤是否允许执行、是否已经完成。应用层在
写操作前保存意图,调用幂等操作后回读,再保存下一 checkpoint。status 写入失败时,
下次从外部事实识别完成结果;不能重发密码,也不能相信伪造的 Ready。
业务失败区分 InvalidSpec、ImmutableField、Conflict 等;依赖故障由适配器转换成
安全的能力失败,应用层决定重试并映射 Condition。凭据不进入模型序列化、status、
事件或错误明细;只能在实际需要它的执行边界短暂传递。
## 5. 用例走查与验收方向
| 场景 | 领域判定 | 应用与适配器执行/恢复 |
| --- | --- | --- |
| 登记 Instance | 当前配置的能力要求是否满足 | 读取管理凭据,验证连接与权限,准备并回读 registry;完成后才 Ready |
| 供应 Tenant | Instance 策略、绑定与归属允许供应 | 保存意图,认领资源,先写并回读 Bao 凭据,再创建 role/database,登录验证和 ESO 投射 |
| Bao 写入后进程中断 | 同一身份的部分状态可继续 | 回读原凭据继续,不生成第二份密码 |
| 两个 Tenant 竞争名称 | 只有匹配所有权的一方可继续 | 持久化认领和条件写入裁决竞争,失败方 Conflict,不覆盖资源 |
| Delete 中断 | 已消失资源可视为完成;剩余资源仍须归属正确 | 按规格顺序继续删除,全部回读不存在后才清 registry 和 finalizer |
| Retain 后同名 CR 重建 | 新 UID 不等于原所有者 | Conflict,不恢复管理、不改密码 |
领域测试验证规则与决策;adapter 测试验证锁、条件写入、SQL 与协议行为;controller
测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用
就声称实现了最终一致性。
## 6. 决策记录与待细化边界
1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证
物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置
的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。
2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记
unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子
完成两者,也不能在没有回读时声称已写入保留标记。
3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的
管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接
从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready;
首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并
重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。
4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后
不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失
后恢复;否则供应中途改名称可能产生无人管理的资源。
5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应,
有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer
与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。
Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。
相关用例在决策批准前不进入实现,不同时实现整套模型。
+125
View File
@@ -0,0 +1,125 @@
# 现有数据库迁移 Runbook
| 项目 | 内容 |
| --- | --- |
| 状态 | Review;尚未在临时 PostgreSQL 演练 |
| 适用范围 | 任意既有数据库迁移为新建 v1alpha1 Tenant |
| 最后更新 | 2026-09-10 |
v1alpha1 不接管现有 database、role 或 OpenBao record。本流程通过逻辑 dump/restore 把
数据迁移到 controller 创建的新资源,保留旧资源作为限时回滚点。
以下命令是顺序模板,不可原样复制到真实环境。先把尖括号变量解析成明确值,确认当前
连接目标,再逐条执行。dump 可能包含敏感业务数据,必须放在加密临时存储且不得提交 Git。
## 前置条件
- 已验证 PostgreSQL/OpenBao 备份和恢复;记录恢复点。
- Instance 已 Ready,目标 namespace 存在,ESO ClusterSecretStore Ready。
- 最终 database/login role 当前由旧应用占用,但改名后的保留名称、新推导的 Bao path
均不存在。
- 已记录旧 database owner、grants、extensions、locale/encoding、连接配置和验证清单。
- 已确认应用可停止写入,并确定回滚窗口和负责人。
- 已确认旧 login role 不被其他 database/应用共享,且角色改名不会破坏未纳入本次维护
的依赖。
## 迁移顺序
### 1. 盘点与预演
```sh
pg_dump --schema-only --no-owner --no-privileges \
--dbname='<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。确认:
- registry 记录 UID 正确;
- OpenBao metadata 属于该 Tenant;
- ExternalSecret Ready 且目标 Secret 已投射;
- 新凭据可以通过 DNS host 和 IP hostaddr 分别登录空 database。
### 5. Restore
从 OpenBao 或目标 Secret 安全取得新应用凭据,不要把密码放进 shell history。以新 login
owner 连接目标 database:
```sh
pg_restore --exit-on-error --no-owner --no-privileges \
--dbname='<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,禁止
通过 Tenant `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。
## 回滚
在新目标出现问题且旧资源仍保留时:
1. 立即停止新目标写入。
2. 评估切换后是否产生新数据;若有,先决定反向迁移或接受丢弃,不能盲目切回。
3. 将应用连接切回 retained database/role;若必须恢复原名称,先确保新受管目标已用
`Delete` 完整清理或改用不同名称,再安全地反向执行 rename。
4. 恢复旧凭据(MD5 环境可能需要重设),验证旧服务。
5. 保留失败 Tenant 供排障;选择 Retain 或 Delete 前明确其外部资源后果。
若已经删除旧资源,则只能使用已验证备份恢复,不再属于本 runbook 的快速回滚。
## 演练验收
发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录:
- 使用的 PostgreSQL major version 和命令版本;
- dump/restore 返回码和对象差异;
- DNS/IP TLS 登录结果;
- ESO 投射与应用启动结果;
- 回滚演练结果;
- 哪些命令或前置检查需要修订。
完成演练前,本文不得标记为 `Verified`。
+76
View File
@@ -0,0 +1,76 @@
# 运维与故障处理
| 项目 | 内容 |
| --- | --- |
| 状态 | Review;命令待实现后演练 |
| 最后更新 | 2026-09-10 |
## 日常检查
先看 API 合同,而不是从日志猜状态:
```sh
kubectl get postgresqlinstances
kubectl get postgresqltenants -A
kubectl get postgresqltenant -n <namespace> <name> -o yaml
kubectl describe postgresqltenant -n <namespace> <name>
```
随后检查 controller 日志、ExternalSecret/Secret、OpenBao metadata、registry 和
PostgreSQL catalog。排障时不得把 Secret data 或带 Token 的请求粘贴到 issue/日志。
`status.phase` 是 controller 状态机 checkpoint,也用于定位当前步骤;`Ready`
Condition/Reason 用于判断对外结果。phase 不能替代外部事实,清空或不一致时应由
controller 自动重建/纠正。
## 常见 Reason
| Reason | 首要检查 |
| --- | --- |
| `InvalidSpec` / `ImmutableField` | API 字段、identifier、不可变/只追加约束 |
| `DependencyUnavailable` | 网络、DNS、服务状态和超时 |
| `AuthenticationFailed` | 管理凭据、CA、DNS/IP SAN、OpenBao auth |
| `InsufficientPrivileges` | PostgreSQL grants、OpenBao policy、Kubernetes RBAC |
| `InstanceNotReady` | 先恢复所引用 Instance |
| `Conflict` | registry UID、同名 DB/role、OpenBao metadata;禁止直接覆盖 |
| `CredentialProjectionFailed` | ClusterSecretStore、ExternalSecret Condition、目标 Secret |
| `ProvisioningFailed` | `status.phase` 及对应外部资源的回读结果 |
修复依赖后让正常 reconcile 自动重试。不要通过删除/重建 CR 规避 Conflict;新 UID 只会
使已有保留资源继续冲突。
## Retain 后的资源
Retain 删除完成后,database、role、OpenBao record 和 registry 所有权记录仍存在但标记
unmanaged。v1alpha1 不支持重新关联。需要恢复管理时,使用 [`migration.md`](migration.md)
把数据迁移到一个全新受管名称;不要手工把 registry UID 改成新 CR UID。
## Delete 卡住
1. 暂停应用写入并记录 Tenant UID、Instance UID、database、role 和 Bao path。
2. 从 registry 和 OpenBao metadata 独立确认所有权。
3. 检查删除阶段,修复 PostgreSQL/OpenBao/ESO 依赖,让 controller 继续。
4. 若依赖永久丢失,列出每个可能残留的 database、role、KV metadata 和 Secret。
5. 只有确认接受这些残留后,才人工移除 finalizer。
最终 finalizer 名称由 API 实现固定后补入命令。人工移除 finalizer不会执行剩余清理,
也不会把外部资源变成可由新 CR 接管的资源。
## 备份与恢复
- PostgreSQL VM/磁盘备份必须与数据库一致性策略配套;仅复制在线磁盘不自动等于有效
PostgreSQL 备份。
- PostgreSQL 备份必须包含管理 database 中的 controller registry。
- OpenBao 使用独立的受支持备份/快照流程,且恢复点应与 PostgreSQL 尽量接近。
- Kubernetes 侧备份 CR、controller 配置、ClusterSecretStore 和公开 CA bundle,不备份
明文 Secret 作为凭据事实来源。
- 定期在隔离环境执行恢复演练,验证 registry、KV metadata、应用登录及 Retain/Delete。
恢复后先停止 controller,核对 PostgreSQL/OpenBao 时间点与 UID 映射,再启动单副本
controller 观察;出现一侧存在、一侧缺失时不得手工生成新密码或改 registry,应先按
Conflict 处理并决定恢复哪一侧。
## 升级与紧急停止
有疑似越权删除或凭据泄漏时,先把 controller Deployment scale 到 0,保留 CR、registry
和日志证据,再撤销 OpenBao token/role 并限制 PostgreSQL 管理 role。恢复前在隔离环境
复现并确认不会扩大破坏。一般依赖故障无需 scale down,最终一致性会自动重试。
+71
View File
@@ -0,0 +1,71 @@
# 安全模型
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 最后更新 | 2026-09-10 |
## 保护目标
- 应用密码只存在于 OpenBao、ESO 投射的目标 Secret 和需要使用它的进程内存中。
- controller 只能修改其 registry 能证明归属当前 Tenant UID 的资源。
- namespace 租户不能越权管理 Instance、其他 namespace 或 controller 配置。
- PostgreSQL 和 OpenBao 的网络身份使用受信 CA 验证,不因 DNS 不可用而降级 TLS。
## 信任边界
Kubernetes 管理员、OpenBao 管理员和 PostgreSQL 管理员是平台信任主体。能读取 Tenant
目标 Secret 或对应 OpenBao path 的主体等同于持有数据库账号。database owner 可以
改变自己 database 内的对象,因此 COMMENT 不能作为 controller 所有权依据。
VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 OpenBao 中的密码;完整
灾难恢复必须同时保护 PostgreSQL 与 OpenBao,并控制两份备份的访问权限。
## 凭据处理
- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。
- 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到
CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。
- 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。
- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。
- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、
DSN、Authorization header、密码或完整 OpenBao URL path 作为 metric label。
- panic、错误包装和测试失败输出必须经过凭据泄漏测试。
## TLS
- homelab 默认 `verify-full`,`disable` 只允许显式开发配置。
- server 证书同时覆盖 DNS `host` 和 IP `hostaddr`;消费者自行选择连接目标。
- OpenBao PKI 保管 CA 私钥并负责签发/续期。controller Deployment 只挂载公开 CA
bundle,挂载只读且使用最小文件权限。
- 证书轮换必须先发布同时信任新旧 CA 的 bundle,再轮换服务端证书,最后移除旧 CA。
## 最小权限
OpenBao controller identity 只管理固定 tenant base path,不读取管理凭据。管理凭据
ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者隔离,Tenant 不得
使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance
不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。
PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或
删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向
controller role 授予 EXECUTE。controller 不调用 shell 或 `psql` 拼接用户输入。
Kubernetes RBAC 应把 cluster-scoped Instance 管理限制给平台管理员。Tenant editor
不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。
## 删除保护
Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance UID、Tenant UID、名称和
OpenBao metadata。禁止对未知对象使用 `CASCADE`。删除 finalizer 卡住时只能按
[`operations.md`](operations.md) 核实外部状态后人工移除;该操作可能遗留资源。
## 发布前安全验收
- 使用错误 CA、错误 DNS 名和错误 IP 时连接失败;正确 DNS/IP SAN 均成功。
- namespace 用户不能修改 Instance 或跨 namespace Tenant/ExternalSecret。
- controller/ESO 的 OpenBao policy 互相隔离,越权请求被拒绝。
- 应用 login 不能创建 role/database,也不能连接其他租户 database。
- 日志、Event、Condition、metrics、CR 导出和测试 artifact 不含 canary password/token。
- 伪造 COMMENT、同名 database/role 或错误 UID metadata 均不能绕过 Conflict。
- Delete 只销毁 registry 可证明归属当前 Tenant 的资源。
+558
View File
@@ -0,0 +1,558 @@
# PostgreSQL Tenant Operator 系统规格说明书
| 项目 | 内容 |
| --- | --- |
| 状态 | Approved |
| 目标 API | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-13 |
| 批准日期 | 2026-09-10 |
| 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 |
本文档定义系统对用户和外部依赖呈现的行为,是 API、测试和实现共同遵守的合同。
实现若需要改变本文合同,必须先修改规格并重新获得批准。
文中的“必须”“禁止”“应当”“可以”分别对应强制要求、强制限制、推荐行为和可选
行为。
## 1. 背景
homelab 中的大部分应用共享一个运行在独立 VM 上的 PostgreSQL DBMS。应用需要各自
独立的 database、作为 owner 的 login role 和密码,但不需要独立 PostgreSQL 实例。
目前这些资源依靠人工 SQL 和人工 Secret 管理,难以重复、审计和检测漂移。
本系统使用 Kubernetes CRD 作为声明式 API,持续协调外部 PostgreSQL 与 OpenBao:
```text
PostgreSQLInstance / PostgreSQLTenant
|
v
Ayatori Database controller
| |
v v
PostgreSQL catalog OpenBao KV v2
```
## 2. 目标
v1alpha1 必须实现以下目标:
1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。
2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。
3. 根据实例实际可安装扩展列表检查并安装租户申请的 PostgreSQL extension。
4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。
5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。
6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。
7. 同时输出 PostgreSQL DNS hostname 和 IP address,不假定所有消费者都能使用集群内
DNS。
8. 持续检测并修正由本系统管理的非破坏性漂移。
9. 通过 Kubernetes Condition 报告进度、成功和可操作的失败原因。
10. 重复 reconcile、controller 重启及外部依赖暂时失败不得重复创建或破坏资源。
11. 删除 Tenant CR 时默认保留外部资源;显式选择 `Delete` 时提供完整清理路径。
## 3. 非目标
v1alpha1 不负责:
- 创建、升级、备份或高可用运行 PostgreSQL DBMS/VM;
- 创建或运维 OpenBao;
- 直接写入包含凭据明文的 Kubernetes Secret;Secret 必须由 ESO 投射;
- 动态凭据、定时或自动密码轮换;
- Web UI、独立 REST API 或 Backstage 插件;
- 跨实例迁移 database;
- schema/table 级别租户、多 login role 或跨租户 grant;
- 删除不属于本系统管理的 database、role、extension 或 OpenBao Secret;
- 接管不是由本系统创建的外部资源;
- 提供生产环境 SLA。
## 4. 参与者与事实来源
| 对象 | 事实来源 | 说明 |
| --- | --- | --- |
| 期望状态 | Kubernetes CR `spec` | 用户声明的合同 |
| 最近观察结果与当前阶段 | Kubernetes CR `status` | 可以丢失并重建,不是外部事实来源 |
| database/role/grant/extension | PostgreSQL catalog | 每轮 reconcile 必须重新读取 |
| 受管资源所有权与保留标记 | PostgreSQL controller registry | 与受管 DBMS 一起备份和恢复 |
| controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 |
| 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 |
| Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 |
| PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret |
平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和
PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理
`PostgreSQLTenant`。
## 5. 资源模型
### 5.1 PostgreSQLInstance
`PostgreSQLInstance` 是 cluster-scoped 资源,表示一个已经存在、可由 controller
管理的 PostgreSQL server。
它必须声明:
- PostgreSQL host、port 和管理连接使用的 database;
- PostgreSQL host address,供无法解析 DNS 的消费者使用;
- TLS mode;
- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。
可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance
中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装
权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。
实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全
建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。
Instance 身份和 endpoint 以管理员声明为准。修改 endpoint 不验证是否仍是原物理
服务器或原 registry,不增加服务器/安装身份绑定检查;但旧配置观察失效,必须按
新配置重新检查连接和管理能力。新 CR 按新 Instance 处理,不自动接管旧 UID 的租户
资源。controller 不迁移旧服务器上的数据,也不清理旧目标,影响由管理员负责评估。
Instance 开始受管时即添加 finalizer,成功保存后才参与供应,不等发现 Tenant 后
再补加。删除期间停止新供应;仍有引用它的 Tenant(包括正在删除的 Tenant)时保留
finalizer,无引用后才移除。不级联删除 Tenant 或任何外部数据库、角色、凭据。
引用检查失败不得当作无引用。首版不引入跨对象锁或准入控制:finalizer 不禁止同时
创建 Tenant CR,新 Tenant 遇到正在删除或已不存在的 Instance 时不得开始供应。
这不保证列表检查、CR 创建和在途外部操作之间的原子性;不是严格的跨对象事务。
### 5.2 PostgreSQLTenant
`PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应:
- 一个 `PostgreSQLInstance`;
- 一个 database;
- 一个同时作为 database owner、供应用使用的 `LOGIN` role;
- 零个或多个 extension;
- 一个 OpenBao KV v2 凭据位置;
- 一个同 namespace ExternalSecret 及其目标 Kubernetes Secret。
Tenant 的 namespace 用于 Kubernetes RBAC 和身份识别,不代表 PostgreSQL schema。
同一 Instance 中的 database 和 role 名称全局唯一。
## 6. 标识与默认值
以下是 v1alpha1 的标识合同:
| 字段 | 默认值 | 约束 |
| --- | --- | --- |
| Instance port | `5432` | 1–65535 |
| Instance host address | 无 | 必须是合法 IPv4 或 IPv6 address |
| 管理 database | `postgres` | 合法 PostgreSQL identifier |
| TLS mode | `verify-full` | 禁止隐式降级 |
| Tenant database | `metadata.name` | 同一 Instance 全局唯一 |
| login role | `metadata.name` | 同一 Instance 全局唯一 |
| deletion policy | `Retain` | `Retain` 或 `Delete` |
Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不得超过 241 个字符,确保
派生的 ExternalSecret/Secret 默认名称
`<instanceRef>-<metadata.name>-postgresql` 不超过 Kubernetes 253 字符限制。
固定默认值由 CRD defaulting 写入。依赖 `metadata.name` 或 `instanceRef` 的 database、
login role、ExternalSecret/Secret 名称属于 controller 语义默认值:省略字段不会被 admission
回写,controller 必须始终计算同一个 effective value,并通过 status 的 database、
loginRole、credential reference 以及实际资源展示。
v1alpha1 不为此引入 mutating webhook。
database 和 role 名称必须作为 PostgreSQL identifier 参数安全引用,禁止通过字符串
拼接执行。名称校验必须拒绝空字符串、NUL 和超过 PostgreSQL identifier 长度限制的
值,并统一限制为小写字母、数字和下划线。
Tenant 首次成功后,`instanceRef`、database、login role 和凭据位置必须
不可变。修改这些字段不是 rename 或 migration,API 必须拒绝或报告明确的
`ImmutableField`。
## 7. PostgreSQL 权限合同
建议的 v1alpha1 权限模型如下:
1. database 必须由 login role 拥有。
2. login role 必须是 `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`。
3. 必须撤销 `PUBLIC` 对租户 database 的连接权限,再显式允许 login role 连接。
4. controller 不得修改其他 database 或无关 role 的权限。
5. controller 只保证请求的 extension 存在;移除 extension 不得自动执行
`DROP EXTENSION`。
这意味着应用可以在自己的 database 内执行 schema migration,但不能创建其他
database、role 或访问其他租户。v1alpha1 不创建只有形式意义、却未隔离运行时权限的
额外 `NOLOGIN` owner。若以后应用能分别使用 migration 和 runtime 凭据,再通过新的
权限 profile 引入 owner/migrator/runtime 角色模型。
## 8. OpenBao 凭据合同
### 8.1 Controller 自身认证
controller 必须使用 Kubernetes auth 登录 OpenBao。controller 使用的 OpenBao API
address、提供给消费者的 OpenBao API address、auth mount、auth role 和 KV v2 mount
属于部署配置,不属于任何 CR。两个 API address 可以相同;若 controller 使用集群内
地址而外部消费者不能解析,则必须单独配置 consumer address。生产部署的 KV mount
默认为 `kv`;开发环境可以配置为 OpenBao dev server 默认的 `secret`。长期 OpenBao
Token 禁止写入 Deployment、CR 或镜像。
### 8.2 管理凭据
`PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及
用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。
平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读
Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。
Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问
PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。
首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码
变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关
字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际
故障报告,不能用旧连接的成功结果证明新凭据可用。
controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧
凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。
Tenant 凭据管理仍直接依赖 Bao。
### 8.3 租户凭据
Tenant 不声明凭据 path。controller 根据部署级 KV mount、base path 和 Tenant 的
namespace/name 推导唯一的 mount-relative path。base path 来自 controller 启动参数
`--openbao-tenant-base-path`,默认 `postgresql-tenants`。最终路径为
`<base-path>/<namespace>/<metadata.name>`。推导结果禁止以 `/` 开头,禁止包含空路径段、
`.`、`..`,也禁止把 KV v2 HTTP API 的 `data` 或 `metadata` 层编码进路径。
新 Tenant 的凭据建立顺序必须可从任意中断点恢复:
1. 验证 Instance、名称、extension 和目标 OpenBao 路径;
2. 确认目标 database、role 和 OpenBao 记录不存在,或能够验证为同一 Tenant
已创建的部分状态;
3. 生成密码;
4. 先创建带 controller 所有权 metadata 的 OpenBao KV v2 记录;
5. 从 OpenBao 重新读取凭据;
6. 使用该凭据创建作为 database owner 的 login role 和其他 PostgreSQL 资源;
7. 用 login role 实际连接目标 database;
8. 全部验证成功后将 Tenant 标记 Ready。
若第 4 步成功、后续 PostgreSQL 操作失败,下一轮必须读取同一份 OpenBao 凭据继续,
不得生成第二个密码。若 PostgreSQL 先存在而 OpenBao 记录不存在,controller 必须报告
Conflict,不得擅自重置已有 role 密码。
controller 必须在 PostgreSQL 管理 database 的专用 registry schema 中持久保存可验证的
Instance UID、Tenant UID 与 namespace/name 关联,不能只依赖会丢失的 CR status 判断
资源所有权。registry 必须可回读且不得改变数据库授权语义;database 或 role COMMENT
不能作为权威所有权记录。
默认写入字段固定为:
```text
username
password
database
host
hostaddr
port
sslmode
```
这些字段是 controller 的规范化输出合同。controller 不生成包含密码的 URI、JDBC URL
或应用专用键名。应用通过 ExternalSecret template、Helm values 或自身配置把原子字段
映射为 `DATABASE_URL`、独立环境变量或配置文件;因此 URI escaping 和应用特有格式也
由消费方负责。`host` 是 DNS 名称,`hostaddr` 是可直接连接的 IP;消费者自行选择其
支持且可达的连接目标。PostgreSQL server 证书必须同时包含与 `host` 匹配的 DNS SAN
和与 `hostaddr` 匹配的 IP SAN,使两种目标都能在 `verify-full` 下独立完成身份验证。
### 8.4 凭据输出与 ExternalSecret
controller 必须根据部署级 base path 推导 Tenant 的 KV path,Tenant 不能选择 mount 或
任意远端路径。ExternalSecret 固定命名为
`<instanceRef>-<metadata.name>-postgresql`。Tenant 可以通过
`spec.credential.secretName` 指定目标 Kubernetes Secret 名称;省略时使用同一默认名。
自定义名称只需是合法 Kubernetes Secret 名称,不限制命名内容;两者均与 Tenant 位于
同一 namespace。
controller 必须创建同 namespace ExternalSecret,从固定的 ClusterSecretStore 读取七个
原子字段。ExternalSecret 及目标 Secret 的名称通过 Tenant status 暴露。controller
不得直接读取 OpenBao 密码后写入 Kubernetes Secret。
Tenant status 还必须提供完整、可由外部消费者使用的 OpenBao KV v2 API URL。URL 可以
包含 consumer API address、mount 和 secret path,但不得包含 Token、密码或其他认证
信息。默认 `kubectl get` 表格显示目标 Secret 名称;完整 OpenBao URL 通过
`kubectl get postgresqltenant <name> -o yaml` 获取,避免表格列过长。
OpenBao metadata 必须能够标识 Tenant UID、namespace/name 和 Instance,使 controller
区分自己的残留记录与外部记录。任何凭据值都不得进入日志、Event、Condition、metric
label、trace、CR spec/status 或测试快照。
## 9. Reconcile 行为
系统采用最终一致性模型。Kubernetes、PostgreSQL、OpenBao 和 ESO 可以短暂处于不同
阶段;controller 不尝试实现跨系统事务,而是以 Kubernetes CR `status.phase` 作为
工作流 checkpoint,通过幂等外部操作和每轮回读验证最终收敛。
两个 CR 的状态机权威记录都在 `status.phase`。controller 根据 phase 选择下一项候选
动作,但 phase 不能替代外部状态检查:执行前后仍须回读 PostgreSQL catalog、registry、
OpenBao 和 Kubernetes/ESO。外部写入成功但 status 更新失败时,下一轮必须识别已完成
事实并推进 phase,不得重复生成密码或报告虚假冲突。
status 丢失时,controller 必须从 registry 的所有权记录和各外部系统实际状态保守重建
phase。若 status 被伪造或领先于实际状态,controller 必须纠正到安全阶段并补齐资源,
不能跳过验证。registry 不保存或驱动协调 phase。
Instance phase 按当前 generation 表示连接与初始化进度:
```text
Pending -> Validating -> InitializingRegistry -> Ready
(any phase) --------------------------------> Deleting
```
spec generation 改变后可以从 `Ready` 回到 `Validating`。Tenant phase 如下:
```text
Pending -> Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated
-> ExternalSecretCreated -> CredentialProjected -> Ready -> Deleting
```
失败不增加 `Failed` phase;phase 保留在无法推进的步骤,由 `Ready=False` 的 Reason 和
message 表达 `Conflict`、认证失败或依赖不可用。Retain 删除完成后 CR 已不存在,因此
没有持久的 `Retained` phase。
每轮 Tenant reconcile 必须按以下逻辑执行:
```text
读取 Tenant
-> 读取 Instance
-> 校验不可变字段与输入
-> 检查 Instance Ready
-> 读取 OpenBao 与 PostgreSQL 实际状态
-> 检测冲突或部分完成状态
-> 执行非破坏性补齐
-> 使用应用凭据验证登录
-> 创建并验证 ExternalSecret/Secret 投射
-> 回读实际状态
-> 更新 status
```
要求:
- 所有步骤必须幂等;
- 每个外部写入前必须先在 CR status 持久化足够的操作意图,写入后必须回读并推进
`status.phase`;
- 暂时性网络、锁和依赖错误必须重试;
- 输入错误、资源冲突和禁止操作不得忙循环重试,只在 generation 或依赖状态变化后
重试;
- 未知外部资源不得被修改、接管或删除;
- 用户从 `spec.extensions` 移除 extension 时不得执行卸载,必须报告该字段在 v1alpha1
中只允许追加;
- controller 重启不得影响已经签发的应用密码;
- `status` 丢失后必须可以从 registry、PostgreSQL、OpenBao 和 Kubernetes/ESO 重建。
## 10. Condition 合同
两个资源都必须提供唯一的 `Ready` Condition。可以增加辅助 Condition,但调用方只需
依赖 `Ready`。
| 状态 | 含义 |
| --- | --- |
| `Ready=Unknown` | 正在首次观察或 reconcile,尚无结论 |
| `Ready=False` | 当前 generation 未达到合同要求 |
| `Ready=True` | 当前 generation 已回读验证成功 |
Condition 必须带正确的 `observedGeneration`。资源自身的
`status.observedGeneration` 只在当前 generation 完成一次有结论的 reconcile 后更新。
最低 Reason 集合:
| Reason | 适用资源 | 含义 |
| --- | --- | --- |
| `Reconciling` | 两者 | 尚在处理 |
| `Ready` | 两者 | 当前 generation 已验证 |
| `InvalidSpec` | 两者 | 输入不满足规格 |
| `DependencyUnavailable` | 两者 | PostgreSQL 或 OpenBao 暂时不可用 |
| `AuthenticationFailed` | Instance | 管理凭据或 TLS 验证失败 |
| `InsufficientPrivileges` | Instance | 管理 role 缺少必要权限 |
| `InstanceNotReady` | Tenant | 引用的 Instance 未 Ready |
| `Conflict` | Tenant | 目标名称或 OpenBao 路径已被其他主体占用 |
| `ProvisioningFailed` | Tenant | 可重试的创建/验证失败 |
| `CredentialProjectionFailed` | Tenant | ESO 或目标 Secret 未达到期望状态 |
Condition message 必须适合人类排障,但禁止包含连接串密码、Token 或完整 Secret 数据。
## 11. 删除与保留
### 11.1 Retain
`Retain` 是默认策略:
- 删除 Tenant CR 不得删除 database、role、extension 或 OpenBao 记录;
- controller 不得因外部依赖不可用而永久阻止 Retain CR 删除;
- 保留资源必须继续携带原 Tenant UID 和 namespace/name 的所有权记录,但在 CR 删除后
明确处于 unmanaged 状态;
- 重新创建同名 Tenant 会产生新的 UID,必须因已有资源不属于新 UID 而报告 Conflict;
- v1alpha1 不提供重新关联、import 或 adoption;恢复管理必须使用第 12 节的迁移流程,
或等待后续版本定义显式纳管协议。
### 11.2 Delete
用户在创建 Tenant 时显式设置 `deletionPolicy: Delete`,表示删除 CR 时授权永久清理
该 Tenant 的外部资源。controller 必须使用 finalizer,并按以下顺序处理:
1. 再次验证 database、role 和 OpenBao 记录都属于当前 Tenant UID;
2. 删除 ExternalSecret,并确认目标 Kubernetes Secret 已删除;
3. 禁止该 login role 建立新连接;
4. 终止该 database 的现有连接;
5. 删除 database,database 内 extension 随之删除;
6. 删除 login role;
7. 删除 OpenBao KV 记录及其可恢复版本;
8. 回读确认外部资源均不存在;
9. 删除 controller registry 记录;
10. 移除 finalizer,允许 Kubernetes 删除 CR。
任一步失败都必须保持 finalizer 并从安全检查开始重试。controller 禁止使用
`CASCADE` 删除无法证明属于该 Tenant 的依赖对象。若 Instance 或 OpenBao 永久丢失,
管理员可以在核实外部状态后手工移除 finalizer;该逃生操作必须在运维 runbook 中明确
标记为可能遗留资源。
v1alpha1 不自动检查备份,也不承诺恢复被 `Delete` 删除的数据。显式选择 Delete 的
用户承担数据销毁语义;默认 Retain 用于避免普通误删。
## 12. 现有环境迁移
v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回滚的迁移 runbook。对每
个现有应用租户,推荐的停机迁移顺序是:
1. 盘点 database、role、owner、grant 和 extension,并完成可恢复备份;
2. 创建逻辑备份,必须使用可映射到新 owner 的格式,避免恢复旧 role ownership;
3. 停止应用写入并确认没有活动写事务;
4. 完成最终逻辑备份;
5. 将旧 database 和 role 重命名为带迁移时间戳的保留名称,释放最终名称;
6. 创建 `PostgreSQLTenant`,由 controller 创建最终 database、role 和 OpenBao 凭据;
7. 等待 Tenant Ready;
8. 以新 owner 恢复逻辑备份,并验证 row count、schema、extension 和应用权限;
9. 让 ESO 投射新凭据,重启或重新部署应用;
10. 验证应用读写后结束维护窗口;
11. 保留旧 database、role 和备份直到回滚窗口结束,再由管理员手工清理。
回滚时停止新应用写入、恢复原名称或连接配置,并重新使用旧凭据。迁移工具不得把旧
密码、管理凭据或 dump 文件提交到 Git。真实命令、锁定方式和各现有应用验证项见
[`migration.md`](migration.md),并必须在实现首个可用版本前通过临时 PostgreSQL 实例
演练。
## 13. 安全要求
1. 所有 PostgreSQL 与 OpenBao 网络访问必须支持超时和 context cancellation。
2. homelab 部署必须通过 Deployment 挂载的共享 CA bundle 验证 TLS server identity;
该 bundle 的信任根来自 OpenBao PKI,但不得包含 CA 私钥。Instance 默认使用
`verify-full`,其 host 必须与服务器证书名称匹配。开发环境可以显式使用 `disable`
明文连接。
3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL
superuser;若 extension 安装需要额外权限,必须单独记录例外。
4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。
管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身
namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。
5. namespace 用户不得修改 cluster-scoped Instance。
6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。
7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。
8. 错误包装、结构化日志和 tracing 必须经过 Secret 泄露测试。
详细威胁模型和部署 policy 见 [`security.md`](security.md)。
## 14. 可观测性要求
v1alpha1 至少必须提供:
- Kubernetes Events:开始 provisioning、成功及需要人工处理的失败;
- 结构化日志:resource namespace/name、Instance、generation、阶段和错误类别;
- controller-runtime 默认 reconcile metrics;
- 不包含 database、role、OpenBao path 等无界用户输入的低基数失败分类 metric。
日志和 metrics 的存在不能代替 Condition;Condition 是 API 使用者判断状态的主要方式。
## 15. 验收标准
实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖:
1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。
2. PostgreSQL 管理能力不可用时 Instance Ready=False,恢复后自动变为 Ready;已有
管理凭据可正常使用时,Bao/ESO 故障不单独影响 Instance Ready。首次装配缺少有效
管理 Secret 时不能 Ready;Tenant 的 Bao 操作失败按其自身依赖故障报告。
3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao
记录。
4. 应用凭据可以实际连接且不能创建其他 database/role。
5. 相同 generation 重复 reconcile 不改变密码、不重复创建资源。
6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。
7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致
Conflict,且不修改已有资源。
8. 目标实例实际不支持的 extension 在供应外部写入前被拒绝;扩展列表查询失败时
按依赖故障处理,不报告为不支持。安装结果仍须回读验证。
9. status 被清空后可以从两个外部事实来源重建。
10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告
Conflict。
11. 日志、Event、Condition、metric 和 CR 中不存在生成的密码或管理凭据。
12. 两个 namespace 对同一 Instance 申请相同名称时,只有第一个成功,第二个报告
Conflict。
13. 删除 Delete Tenant 时,任一步骤失败都可重试,且最终删除 database、login role、
OpenBao KV 历史和 finalizer。
14. 使用迁移 runbook 可以把一个现有 database 转移到新建的受管 database,并在回滚
窗口内恢复旧服务。
15. Tenant 只有在 ExternalSecret Ready、目标 Secret 存在且应用凭据实际可登录后才
Ready。
16. Tenant status 同时提供 Kubernetes Secret reference 和不含认证信息的 OpenBao API
URL。
17. DNS 不可用时,使用输出的 `hostaddr` 可以连接 PostgreSQL;server 证书同时覆盖
`host` 的 DNS SAN 和 `hostaddr` 的 IP SAN,两种连接目标均可通过 `verify-full`。
18. 两个 CR 的 `status.phase` 都能反映当前协调步骤;清空 status 后可以从外部事实重建,
且伪造或过期 phase 不会使 controller 跳过验证或外部操作。
单元测试验证纯决策逻辑,adapter 集成测试使用 Docker PostgreSQL/OpenBao,controller
集成测试使用 envtest,完整网络路径使用 Kind E2E。
## 16. 已确认决策
- v1alpha1 使用一个同时作为 database owner 的 login role,不创建额外 NOLOGIN owner。
- v1alpha1 不接管任意现有资源,但必须提供并演练 dump/restore 迁移路径。
- v1alpha1 同时实现默认 `Retain` 和显式 `Delete`;Delete 必须有 finalizer、所有权验证
和完整清理路径。
- OpenBao KV v2 mount 和 base path 是 controller 部署配置,mount 默认 `kv`,base path
由 `--openbao-tenant-base-path` 配置并默认 `postgresql-tenants`;Tenant 不能选择 mount
或任意远端 path,controller 根据 namespace/name 推导记录路径。
- 租户 KV 记录固定写入 `username/password/database/host/hostaddr/port/sslmode` 七个
原子字段;
controller 不生成连接 URI,应用负责映射和拼装自身配置。
- PostgreSQL TLS 使用 controller Deployment 挂载的共享 CA bundle。OpenBao PKI 是
CA 权威并继续签发、续期 PostgreSQL server 证书;controller 只消费公开 trust
bundle,不接触 CA 私钥。bundle 可以由 ConfigMap 或现有证书同步机制投射,不允许
Tenant 或 Instance 选择其他 CA;开发环境可以显式使用 `sslMode: disable`。
- 每个 PostgreSQLInstance 在其管理 database 中维护 controller 专用 registry schema。
registry 是受管资源所有权、安装身份和 Retain 后 unmanaged 标记的权威记录;两个
CR 的 `status.phase` 是 controller 状态机的权威 checkpoint,Instance status 不聚合
Tenant 清单。
- PostgreSQL database 和 role identifier 必须匹配 `^[a-z][a-z0-9_]{0,62}$`,不支持
需要双引号的大小写或特殊字符名称。
- External Secrets Operator 是 v1alpha1 的运行依赖。controller 管理同 namespace
ExternalSecret,但不直接写明文 Secret;Tenant status 同时输出目标 Secret reference
和供非 Kubernetes 消费者使用的 OpenBao API URL。
- PostgreSQLInstance 同时声明 DNS `host` 和 IP `hostaddr`;PostgreSQL server 证书必须
同时包含对应 DNS SAN 和 IP SAN,消费者自行选择连接目标。
## 17. 批准状态
2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员
allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。
2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret,
管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。
此项是已批准行为,现有 API types 与实现尚待后续修改。
具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和
实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威
checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。
## 18. 与当前脚手架的已知差异
当前 API skeleton 至少需要以下调整:
- 删除 Tenant 自选 OpenBao path 的能力,改由部署级 mount、base path 和 Tenant
identity 推导,并修正当前包含 `kv/` 前缀的示例;
- 增加 controller 部署级 OpenBao KV mount 和 TLS 配置;
- 增加部署级 OpenBao consumer address、ClusterSecretStore 和 KV base path 配置;
- 删除独立 `ownerRole` 字段,使 login role 成为 database owner;
- 为 Instance 增加 `hostaddr`,为 Tenant 增加目标 Secret 配置及 Secret/Bao URL 输出
status;
- 按已确认的 identifier 合同收紧校验;
- 增加 PostgreSQL controller registry,记录基于 UID 的所有权、安装身份和保留状态;
- 修正凭据 type 中遗留的 rotation 注释;
- 使 Condition、不可变字段和 extension 追加语义具备 API 校验或明确的 reconcile
结果。
这些是规格批准后的实现工作,不属于本规格本身。
@@ -1,4 +1,4 @@
# ADR-0001:采用 Kubernetes API 作为资源模型 # ADR-0001:采用 Kubernetes API machinery 作为状态协调平面
- 状态:Accepted - 状态:Accepted
- 日期:2026-09-17 - 日期:2026-09-17
@@ -10,16 +10,49 @@ homelab 的基础设施状态分散在多套工具和后端中。仅集中 IaC
## 决策 ## 决策
Ayatori 使用 Kubernetes API machinery 与 CRD 表达平台资源、引用和状态,但不将平台 Ayatori 使用 kube-apiserver、etcd、Kubernetes API machinery 与 CRD 构成 API 和状态协调
限定为容器编排系统。Controller 可以运行于专用 management environment,并管理集群外 平面。主要复用的是以下难以可靠重建的能力:
的 VM、LB、数据库、对象存储、DNS、凭据和托管 Kubernetes 控制面。
- 版本化对象 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 契约显式表达。
GitOps 是长期期望状态的主要提交入口;API 是当前意图、关系和状态的在线控制面;真实后端 GitOps 是长期期望状态的主要提交入口;API 是当前意图、关系和状态的在线控制面;真实后端
仍是运行事实来源。Controller 负责三者之间持续收敛。 仍是运行事实来源。Controller 负责三者之间持续收敛。
`generic-apiserver` 或 Kubernetes API aggregation 只会让 Ayatori 接管资源的服务端实现,并不会
替代上述领域 controller。除非 CRD/kube-apiserver 的存储模型、API 语义或扩展边界形成经过验证的
阻碍,Ayatori 不自行承担 watch、RBAC、API 兼容、存储版本迁移和高可用 API Server 的实现与运维。
## 结果 ## 结果
- 获得统一声明式 API、watch、RBAC、admission、conditions 和 controller 生态。 - 获得统一声明式 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、认证、备份和控制面升级。 - 必须维护 CRD 版本、conversion、认证、备份和控制面升级。
- 不在 API 中保存日志、指标、大对象或业务数据,只保存控制所需状态及引用。 - 不在 API 中保存日志、指标、大对象或业务数据,只保存控制所需状态及引用。
@@ -0,0 +1,109 @@
# ADR-0004:采用可拆分的模块化 Controller 架构
- 状态:Accepted
- 日期:2026-09-17
## 背景
Ayatori 将逐步提供任务执行、虚拟机、数据库、负载均衡、对象存储、托管 Kubernetes 和
人工操作等领域能力。这些能力拥有不同的生命周期、权限、网络位置和后端实现,若直接在
一个 controller 中相互调用并共享内部状态,后续接入新后端时容易形成代码耦合,也难以
独立扩缩容、发布和隔离故障。
另一方面,在首个领域能力完成前就拆分为多个独立服务,会立即引入镜像与部署管理、服务
间认证、版本兼容、分布式观测和故障处理成本,而这些成本尚未由真实运行需求证明。
Job 是首个领域对象。它既是最初的单次任务调度 API,也用于验证领域状态机与 Kubernetes
Job、OpenSandbox 和未来执行后端之间的适配边界。
## 决策
Ayatori 初期采用模块化单体:多个领域 controller 可以编译进同一个 controller manager,
但代码、API 所有权和依赖方向按照未来可独立部署的服务边界组织。
### 领域所有权
每个领域模块拥有自己的:
- CRD 与 API 版本;
- reconciler 和状态机;
- finalizer、conditions、删除及恢复语义;
- backend adapter contract;
- 领域测试。
初始领域包括但不限于 execution、compute、database、networking 和 human operations。领域
模块不得导入其他领域的内部实现,也不得直接修改其他领域所拥有对象的 spec 或 status。
### 跨领域协作
领域间的持久协作通过 Kubernetes API 对象、typed reference、owner reference 和
conditions 完成,而不是通过进程内 service 方法调用。
例如虚拟机完成创建后需要执行 provision,应创建或引用 Run 对象并观察其状态,而不是
直接调用 execution 模块的内部 Go API。更高层的资源组合由专门的领域对象或 GitOps 声明
完成,不引入统一包装所有底层能力的 Application controller。
该约束使 controller 即使暂时位于同一进程,其通信、失败和恢复行为仍与未来分进程部署
一致。
### Backend adapter
领域状态机只依赖本领域定义的最小 adapter contract,不依赖 Kubernetes Job、Crossplane、
OpenTofu、OpenSandbox 或具体厂商 SDK 类型。Adapter 负责:
- 幂等地确保期望外部资源存在;
- 观察并翻译外部状态;
- 执行取消、删除或 orphan 策略;
- 返回稳定的外部引用、能力和分类错误。
不同领域分别定义 adapter contract,不建立能包装所有资源类型的万能 Provider 接口。
后端不具备的能力必须显式报告,不通过虚假的统一语义隐藏差异。
Crossplane、OpenTofu 和 Ansible 等系统是可替换的 backend 实现或执行机制,不构成 Ayatori
面向用户的稳定 API。它们的 ProviderConfig、Workspace、playbook 等实现细节不得直接成为
领域 API 的必填契约。
### 共享代码
默认不建立跨领域的万能 service、repository 或 util 层。共享并非禁止,但必须来自已经
存在的真实重复,并同时满足:
1. 至少有两个真实调用者;
2. 重复的行为和语义一致,而不只是代码形状相似;
3. 调用方对其生命周期和预期演化方向一致;
4. 共享包不依赖任何具体领域的内部包。
适合共享的通常是机制,例如 conditions 操作、typed reference、重试退避、Secret 引用
读取、观测初始化和测试环境。领域状态机、资源策略、错误含义及后端选择不得为了消除少量
重复而抽取到共享层。
共享包使用表达具体职责的窄名称,初期保留在 `internal/shared/`。不建立内容持续膨胀的
通用 `util` 包,也不在实现稳定前承诺公共 Go API。
### 部署与拆分
controller manager 应支持按领域或 controller 集合选择性启用。初期可以使用一个二进制和
一个 Deployment;需要隔离时,优先使用同一制品部署为多个 Deployment。只有独立版本和
依赖关系成为真实需求后,才进一步拆分二进制或仓库。
出现下列任一情况时,应评估拆分部署:
- 需要独立扩缩容或显著不同的 reconcile 并发;
- 权限边界要求独立 ServiceAccount 与 RBAC;
- 后端只能从特定网络或节点访问;
- 沉重、不可信或冲突的 SDK 需要隔离;
- 一个领域的故障不应影响其他控制循环;
- 发布节奏或维护责任已经明确分离。
拆分不得改变领域 API,也不应将原本通过 API 对象完成的协作改为同步 RPC 链路。
## 结果
- 首个 Job 实现需要同时建立 execution 状态机和 adapter 边界,不能把 Kubernetes Job
细节写入领域模型。
- 初期避免承担不必要的微服务运维成本,同时保留按权限、网络位置和故障域拆分的路径。
- Kubernetes API 成为领域间异步协作和恢复边界,领域 controller 必须正确处理最终一致性。
- 部分机械代码会有意保留重复,直到共享语义被至少两个真实实现证明。
- 代码评审需要检查跨领域 import、对象写入所有权和后端类型泄漏。
- 当 Job 同时拥有 Kubernetes Job 与 OpenSandbox adapter 后,应复审 adapter contract,确认
它来自真实后端差异而非单一实现假设。
@@ -0,0 +1,98 @@
# ADR-0005:Job 使用短生命周期控制对象与 TTL 回收
- 状态:Accepted
- 日期:2026-09-17
## 背景
Ayatori 的 `Job` 表达一次有限时长、有退出结果的执行。CI、基础设施 controller 和用户可能
持续创建大量 Job。若所有 Job CR、后端 Kubernetes Job、Pod、Event 和日志都长期保存在
Kubernetes 中,etcd、apiserver list/watch、controller cache、备份及恢复会持续承担历史
数据成本。
Kubernetes API 适合作为活动执行的在线控制与协调面,但不应在没有明确产品需求时兼任无限
增长的执行历史数据库。当前也尚未证明 Ayatori 必须独立于调用者提供长期历史或审计检索。
Gitea Actions 等调用方已经拥有自己的执行历史;基础设施 controller 也应把 Job 结果转化为
所属领域资源的 status。执行日志属于观测数据,应进入统一 observability 平台,而不是由
Job CR 或专用执行历史存储重复保存。
Kubernetes 原生 `batch/v1 Job` 使用独立的 TTL-after-finished controller 回收完成对象。
该 controller 只支持原生 Job,不能直接处理 Ayatori CRD,但其基于 informer 与延迟工作队列
的实现模式可以复用。
## 决策
Ayatori 提供 `execution.ayatori.ddupan.top` API group 下的 `Job` Kind。与 Kubernetes
`batch/v1 Job` 同名不构成冲突,完整 GVK 明确资源身份。
Ayatori Job 是短生命周期控制对象,不是永久执行记录。首版不要求将结果归档到 PostgreSQL,
也不引入 `Archived` condition 或 `JobRecord` CRD。
### 终态与 TTL
Job 到达成功、失败或取消终态后保留有限时间,随后由 Ayatori 自己的 TTL controller 删除。
API 提供与原生 Job 语义一致的 `spec.ttlSecondsAfterFinished`;未设置时是否允许无限保留由平台
准入策略决定,而不是隐式默认永久保存。
TTL 从 controller 写入的可信终态时间开始计算。TTL controller:
1. watch Job 的新增和更新;
2. 只处理已终止、设置 TTL 且尚未删除的对象;
3. 未到期时使用延迟工作队列在到期时间重新入队;
4. 到期时重新读取最新对象并复核 UID、终态和 TTL;
5. 使用 UID precondition 发起删除,防止删除同名重建对象;
6. 由 Job finalizer 完成实际执行后端与临时凭据清理。
Controller 重启后,informer 的初始 LIST 会重新触发现存对象的 reconcile 并重建内存中的延迟
任务,因此不以周期性全量扫描作为正确性基础。
### 结果消费
调用者必须在 TTL 窗口内观察 Job 终态,并把需要长期存在的业务事实写入自身状态。例如 VM
provision Job 成功后,VM controller 更新 `Provisioned` condition;之后删除 Job 不影响 VM
状态。CI 系统负责保存自身 workflow 历史。
Job status 只保存控制和短期诊断需要的结构化结果,不保存完整日志、大型输出或 artifact。
### 日志与 artifact
各 execution adapter 必须为执行实例注入稳定的关联信息,使 stdout/stderr 能由共享
observability 管道采集,并能够按 Ayatori Job 的 namespace、name 和 UID 查询。Job status
可以保存查询观测数据所需的关联标识或受控链接,但日志内容及其索引、保留和查询能力属于
observability 平台。
Job 与后端执行对象的 TTL 应为日志采集提供合理窗口,但 GC 不以日志归档成功作为前置条件,
避免观测平台故障阻塞控制面资源回收。日志采集延迟、丢失和后端不可用通过 observability
自身的监控和告警处理。
Artifact 与日志语义不同。调用者需要消费的构建产物、状态文件或结构化输出必须显式写入
对象存储等持久后端,并通过引用交付;不能依赖日志系统作为 artifact 存储。
### GitOps 边界
一次性 Job 不由 Flux 持续管理。否则 TTL 删除会被视为漂移并重新创建,从而重复执行。GitOps
可以管理 Job template、schedule、execution class 和策略;CI、CLI、UI 或其他 controller
通过 Kubernetes API 命令式创建 Job。
### 未来归档
只有出现长期历史查询、统一审计、调用者无法及时消费结果等真实需求时,才引入
外部 `JobRecord`/History API。届时可以为需要持久化的 retention policy 增加归档流程,并将
归档成功作为删除前置条件;不要求所有 Job 无条件承担该成本。
## 结果
- etcd 中只保留活动 Job、短期已完成 Job 和需要人工处理的异常 Job。
- 首版不依赖 PostgreSQL 和对象存储即可完成 Job 纵向切片。
- 调用者必须正确 watch 或轮询结果;TTL 配置必须为其提供足够消费窗口。
- 历史日志由共享 observability 平台查询,Job CR 只提供执行关联信息。
- Job 删除后的历史默认不可从 Kubernetes API 恢复,这是有意接受的语义。
- TTL controller 是 execution 领域的一部分,可以和其他 controller 编译、部署在同一个
controller manager 中。
- 若未来增加归档,应作为独立产品能力和 retention policy 演进,不改变 Job 作为短生命周期
控制对象的基本定位。
## 参考
- [Kubernetes Automatic Cleanup for Finished Jobs](https://kubernetes.io/docs/concepts/workloads/controllers/ttlafterfinished/)
- [Kubernetes TTL-after-finished controller](https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/ttlafterfinished/ttlafterfinished_controller.go)
@@ -0,0 +1,45 @@
# 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 不自动产生新的产品层。
@@ -0,0 +1,61 @@
# 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 的产品优先级。
@@ -0,0 +1,92 @@
# ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块
- 状态:Accepted
- 日期:2026-09-20
## 背景
独立仓库 `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 保留入口。
+26 -27
View File
@@ -9,53 +9,52 @@
- 定义 API、conditions、ownership 和 executor 公共约定。 - 定义 API、conditions、ownership 和 executor 公共约定。
- 建立不可变制品与 Dev 到 Prod promotion。 - 建立不可变制品与 Dev 到 Prod promotion。
## 1. Job Service ## 1. Controller 纵向验证切片
- 实现最小 `Job` API。 - 使用当前最小 `Job` API 验证 watch、状态机、finalizer、取消、TTL、external reference 与
- Kubernetes Pod executor。 backend adapter。
- 统一日志、退出状态、超时、workspace、cache 与 artifact。 - Kubernetes executor 不能与 management API client 或同集群 namespace 语义绑定。
- 接入 Gitea Actions 和平台内部 IaC 执行。 - 验证完成后,将可复用机制收敛为内部 `Run`/execution 能力;不把这一切片扩展为 FaaS、
Cloud Run 或通用 Job Service。
## 2. OpenSandbox Executor ## 2. 首批资源产品
- 通过 OpenSandbox lifecycle 与 execd API 创建、执行和清理 sandbox。 - `Database`:PostgreSQL database、role、credential 与回收。
- 支持强隔离任务、未知代码、嵌套容器和 AI agent。 - `LoadBalancer`:Envoy 配置/xDS、健康检查、固定 VIP 与 GoBGP 路由宣告。
- 增加交互式 `Sandbox` API、TTL、endpoint 与 snapshot。 - `Bucket`:SeaweedFS bucket、policy、credential 与删除策略。
- 按纵向价值选择先后,不为三者预先建立统一 provider 框架。
## 3. Human Executor ## 3. Human Executor 与延迟自动化
- `ManualTask`、`TaskReport` 和版本化 Runbook。 - `ManualTask`、`TaskReport` 和版本化 Runbook。
- Telegram/Email 通知、领取、提醒和升级。 - Telegram/Email 通知、领取、提醒和升级。
- 后端验证与上游 reconcile 恢复。 - 后端验证与上游 reconcile 恢复。
## 4. LBaaS ## 4. Compute 与节点生命周期
- Envoy 配置/xDS adapter。 - 建立稳定的 `VirtualMachine` 北向 API,并支持现有资源 adopt。
- 健康检查与 GoBGP 路由宣告。 - 南向按能力组合 Proxmox API、节点受限 Agent/CLI 与 `ManualTask`,不假设 Proxmox API 完整。
- 固定 VIP、listener/backend 引用和故障恢复。 - Node 加入、drain 和 `SafeToRemove`;Node API 由 Ayatori Compute Agent 实现,不依赖 kubelet。
## 5. Compute 与节点生命周期
- Proxmox VM adapter 与现有资源 adopt。
- ComputeNode 加入、drain 和 `SafeToRemove`。
- StorageClass、StoragePool、Volume 与迁移计划。 - StorageClass、StoragePool、Volume 与迁移计划。
- 先支持人工磁盘迁移,再通过 Job executor 自动化。 - 先支持人工磁盘迁移,再按实际收益自动化。
## 6. 数据服务 ## 5. 条件性扩展
- PostgreSQL database/role/credential。 - OpenSandbox/microVM 可以作为内部 Run backend,但不由此产生 FaaS 产品承诺。
- SeaweedFS bucket/policy/credential。 - DNS、证书和 Credential 只有在跨系统协调收益明确时形成独立资源。
- DNS 与证书资源。 - KaaS 只有出现托管控制面、租户隔离或频繁集群生命周期的真实需求时才立项。
## 7. KaaS ### KaaS 候选方案
- 采用成熟 hosted-control-plane 后端。 - 采用成熟 hosted-control-plane 后端。
- 组合控制面、worker、LB、DNS、网络和凭据。 - 组合控制面、worker、LB、DNS、网络和凭据。
- 用户集群只暴露 worker node,控制面完全由平台托管。 - 用户集群只暴露 worker node,控制面完全由平台托管。
- 本节记录候选实现边界,不构成路线图承诺。
## 首个业务里程碑 ## 后续 Compute 验收场景
完成 Laptop Rebuild Readiness: Database 等首批资源优先落地。Compute 开始实施后,以 Laptop Rebuild Readiness 验证节点
生命周期与恢复能力;该场景不作为首批 Database、LoadBalancer 或 Bucket 的交付前置条件:
1. 临时节点加入。 1. 临时节点加入。
2. laptop 上的 workload 被重建、迁移或形成可执行人工任务。 2. laptop 上的 workload 被重建、迁移或形成可执行人工任务。
+16
View File
@@ -29,6 +29,20 @@ Ayatori 是具有产品质量的内部平台,而非初期即面向公众的通
平台允许对当前环境形成明确意见:Proxmox、OpenSandbox、Envoy、GoBGP、OpenBao、 平台允许对当前环境形成明确意见:Proxmox、OpenSandbox、Envoy、GoBGP、OpenBao、
PostgreSQL、SeaweedFS、Samba AD DNS、Cloudflare 和 Flux 都可以是已知实现。 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、数据库、对象存储或网络协议栈。 - 不替代 hypervisor、microVM runtime、数据库、对象存储或网络协议栈。
@@ -36,3 +50,5 @@ PostgreSQL、SeaweedFS、Samba AD DNS、Cloudflare 和 Flux 都可以是已知
- 不以隐藏全部后端信息或制造虚假多云可移植性为目标。 - 不以隐藏全部后端信息或制造虚假多云可移植性为目标。
- 不创建理解所有应用需求的中央 Application controller。 - 不创建理解所有应用需求的中央 Application controller。
- 不要求所有人工步骤立即自动化。 - 不要求所有人工步骤立即自动化。
- 不因为已有 Run、OpenSandbox 或 microVM backend,就构建 FaaS、Cloud Run 或应用托管产品。
- 不预先承诺 KaaS;它是需求驱动的候选能力。
+103
View File
@@ -0,0 +1,103 @@
module git.ddupan.top/panxiao81/ayatori
go 1.27.1
require (
github.com/jackc/pgx/v5 v5.11.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
)
require (
cel.dev/expr v0.25.1 // 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
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
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-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
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/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/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/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
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
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/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
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.4 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // 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.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/apiserver v0.37.0 // indirect
k8s.io/component-base v0.37.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/streaming v0.37.0 // indirect
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 // indirect
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
)
+257
View File
@@ -0,0 +1,257 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
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/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-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=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
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/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=
github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/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/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=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/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=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
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/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=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.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=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
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=
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.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=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.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.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=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
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=
k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk=
k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI=
k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80=
k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
k8s.io/apiserver v0.37.0 h1:TXg7OxsOWrAH8J4Zi/gBAZuMw1Dfdd+6cca2h4qjRqo=
k8s.io/apiserver v0.37.0/go.mod h1:OddHDF4gy9qyIb8o/3+qaeP6S0vEObWLgOygVqXksv0=
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
k8s.io/component-base v0.37.0 h1:3SdSa4+itMdFTDFTeR8CxKGmSTSMXFlKL4ky8OqjguM=
k8s.io/component-base v0.37.0/go.mod h1:LjOebp4R9y6LODWZQv102ZQxGheLcDO2ZJLAw6bbh4I=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM=
k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 h1:/YpDJ4vReG7ZmzSpBGxduXgywWkJU9zHubgJG03MT+Y=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0/go.mod h1:tJo1aepTXyR+8Xs3sUsGBDk4Ub2AM5dPAPKJx0mpm5c=
sigs.k8s.io/controller-runtime v0.25.0 h1:44KgRUPew331KSJpNu8zJow3iTR5W0p/SfrHdw3lV40=
sigs.k8s.io/controller-runtime v0.25.0/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
View File
@@ -0,0 +1,68 @@
/*
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]))
}
@@ -0,0 +1,116 @@
/*
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 (d *database) Version(ctx context.Context) (string, error) {
var version string
if err := d.pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
return "", safeError(err, application.ErrObservation)
}
return version, nil
}
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
}
@@ -0,0 +1,227 @@
//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)
})
}
}
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
if !errors.Is(err, application.ErrCredentialsChanged) {
t.Fatal("in-flight rotation was not detected")
}
if version != "" {
t.Fatal("observation returned data 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)
}
@@ -0,0 +1,332 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"context"
"errors"
"os/exec"
"regexp"
"strconv"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/envtest"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const (
fixtureHost = "fixture.invalid"
fixtureUser = "postgres"
dockerExec = "exec"
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
controllerNamespace = "database-controller"
secretName = "management"
)
// fixture 不接受外部 DSN,只创建自己的临时容器并按确切 ID 清理。
func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
t.Helper()
output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::5432",
"-e", "POSTGRES_PASSWORD="+fixturePassword, fixtureImage).Output()
if err != nil {
t.Fatalf("cannot start isolated PostgreSQL fixture: %s", fixtureCommandError(err))
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("unexpected container identifier")
}
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("fixture cleanup failed")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", `{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatalf("cannot inspect fixture port: %s", fixtureCommandError(err))
}
port, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
t.Fatal("invalid fixture port")
}
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil {
select {
case <-ctx.Done():
t.Fatal("fixture startup timed out")
case <-time.After(200 * time.Millisecond):
}
}
return id, port
}
// Output 将 stderr 保存在 ExitError 中;保留诊断,但不打印命令参数和测试密码。
func fixtureCommandError(err error) string {
detail := err.Error()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
}
redactor := strings.NewReplacer(
fixturePassword, "[REDACTED]",
rotatedPassword, "[REDACTED]",
)
return redactor.Replace(detail)
}
func TestFixtureCommandErrorPreservesDiagnosticsAndRedactsPasswords(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{
name: "missing docker executable",
err: &exec.Error{Name: "docker", Err: exec.ErrNotFound},
want: "executable file not found",
},
{
name: "daemon failure from stderr",
err: &exec.ExitError{Stderr: []byte("Cannot connect to the Docker daemon")},
want: "Cannot connect to the Docker daemon",
},
{
name: "passwords in stderr",
err: &exec.ExitError{Stderr: []byte("failure: " + fixturePassword + " " + rotatedPassword)},
want: "failure: [REDACTED] [REDACTED]",
},
{
name: "password in error text",
err: errors.New("failure: " + fixturePassword),
want: "failure: [REDACTED]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detail := fixtureCommandError(tt.err)
if !strings.Contains(detail, tt.want) {
t.Fatalf("diagnostic lost expected information: %q", tt.want)
}
if strings.Contains(detail, fixturePassword) || strings.Contains(detail, rotatedPassword) {
t.Fatal("diagnostic exposed a fixture password")
}
})
}
}
func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationTarget {
t.Helper()
id, err := instance.NewIdentity("fixture-uid", "fixture")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(1)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: fixtureHost,
HostAddr: "127.0.0.1",
Port: port,
ManagementDatabase: fixtureUser,
TLSMode: mode,
})
if err != nil {
t.Fatal(err)
}
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secretName,
UsernameKey: "login",
PasswordKey: "credential",
})
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, ref)
if err != nil {
t.Fatal(err)
}
value, err := instance.NewObservationTarget(id, revision, definition)
if err != nil {
t.Fatal(err)
}
return value
}
// 在真实读取前设置屏障,确定性验证观测期间 Secret 变化;实际数据仍来自 API server。
type gatedReader struct {
application.CredentialReader
beforeRead func()
}
func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
if r.beforeRead != nil {
r.beforeRead()
}
return r.CredentialReader.Read(ctx, ref)
}
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
type credentialFixture struct {
ctx context.Context
client *kubernetes.Clientset
reader *secretadapter.SecretCredentials
deniedReader *secretadapter.SecretCredentials
gate *gatedReader
service *application.InstanceService
target instance.ObservationTarget
containerID string
port int
}
func newCredentialFixture(t *testing.T) *credentialFixture {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
environment := &envtest.Environment{}
config, err := environment.Start()
if err != nil {
t.Fatal("envtest startup failed", err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Error("envtest cleanup failed", err)
}
})
client, err := kubernetes.NewForConfig(config)
if err != nil {
t.Fatal("cannot create test client")
}
for _, namespace := range []string{controllerNamespace, "unrelated"} {
_, err := client.CoreV1().Namespaces().Create(
ctx,
&corev1.Namespace{Name: namespace},
metav1.CreateOptions{},
)
if err != nil {
t.Fatal("cannot create fixture namespace")
}
}
reader, err := secretadapter.NewSecretCredentials(config, controllerNamespace)
if err != nil {
t.Fatal(err)
}
user, err := environment.AddUser(envtest.User{Name: "without-secret-access"}, config)
if err != nil {
t.Fatal(err)
}
deniedReader, err := secretadapter.NewSecretCredentials(user.Config(), controllerNamespace)
if err != nil {
t.Fatal(err)
}
containerID, port := postgresFixture(t, ctx)
gate := &gatedReader{CredentialReader: reader}
service, err := application.NewInstanceService(gate, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
return &credentialFixture{
ctx: ctx,
client: client,
reader: reader,
deniedReader: deniedReader,
gate: gate,
service: service,
target: target(t, port, instance.TLSDisable),
containerID: containerID,
port: port,
}
}
func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
t.Helper()
secret := &corev1.Secret{
Name: secretName,
Data: map[string][]byte{
"login": []byte(fixtureUser),
"credential": []byte(fixturePassword),
},
}
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
t.Fatal("cannot create fixture Secret")
}
}
func (f *credentialFixture) updateSecret(t *testing.T, change func(*corev1.Secret)) {
t.Helper()
secrets := f.client.CoreV1().Secrets(controllerNamespace)
secret, err := secrets.Get(f.ctx, secretName, metav1.GetOptions{})
if err != nil {
t.Fatal("cannot read fixture Secret")
}
change(secret)
if _, err := secrets.Update(f.ctx, secret, metav1.UpdateOptions{}); err != nil {
t.Fatal("cannot update fixture Secret")
}
}
func (f *credentialFixture) observeVersion(t *testing.T) {
t.Helper()
version, err := f.service.ObserveVersion(f.ctx, f.target)
if err != nil {
t.Fatal("version observation failed", err)
}
if version == "" {
t.Fatal("successful observation returned an empty version")
}
}
func (f *credentialFixture) queryPostgres(t *testing.T, sql string) string {
t.Helper()
output, err := exec.CommandContext(
f.ctx, "docker", dockerExec, f.containerID,
"psql", "-U", fixtureUser, "-tAc", sql,
).Output()
if err != nil {
t.Fatal("fixture SQL failed")
}
return strings.TrimSpace(string(output))
}
func (f *credentialFixture) backendIDs(t *testing.T) string {
t.Helper()
return f.queryPostgres(t, `
SELECT pid
FROM pg_stat_activity
WHERE application_name = 'ayatori-database-management'
ORDER BY pid
`)
}
@@ -0,0 +1,140 @@
//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 version, err := db.Version(ctx); err != nil || 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")
}
}
@@ -0,0 +1,58 @@
/*
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)
}
@@ -0,0 +1,70 @@
/*
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")
}
}
}
@@ -0,0 +1,171 @@
/*
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 的能力边界。
// 版本查询只是本切片的连通性观察,不能产生领域 Ready。
type Database interface {
Version(context.Context) (string, 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 返回当前目标和凭据下的版本;任何失败均返回空结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.ObservationTarget) (string, error) {
if err := target.Validate(); err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return "", ErrClosed
}
if err := ctx.Err(); err != nil {
return "", err
}
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
name := target.Identity().Name()
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
}
if credentials.username == "" || credentials.password == "" {
s.release(name)
return "", 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 "", err
}
current = &entry{
target: target,
credentials: credentials,
database: database,
}
s.entries[name] = current
}
version, err := current.database.Version(ctx)
if err != nil {
s.release(name)
return "", err
}
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
}
if latest != credentials {
s.release(name)
return "", ErrCredentialsChanged
}
return version, nil
}
func credentialError(err error) error {
if errors.Is(err, ErrCredentialsInvalid) {
return ErrCredentialsInvalid
}
return ErrCredentialsUnavailable
}
// Forget 只释放本地连接;不删除数据库或 registry,不替代 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)
}
}
@@ -0,0 +1,182 @@
/*
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"
)
// 延续源项目 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
}
func (d *databaseStub) Version(context.Context) (string, error) { return "17", 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{}
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: "test-only"}}
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 {
if _, err := service.ObserveVersion(ctx, testCase.target); err != nil {
t.Fatal(err)
}
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 TestInstanceAssemblyFailureRecovery(t *testing.T) {
ctx := context.Background()
target := serviceTarget(t, "uid-1", "first", "admin", 1)
source := &sourceStub{
credentials: Credentials{username: testUsername, password: "test-only"},
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")
}
}
@@ -0,0 +1,67 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance
import (
"errors"
"regexp"
)
// CredentialReferenceValues contains effective field mappings, not secret data.
// The application supplies defaults and fixes the namespace to the controller's.
// Namespace and provider-specific paths are deliberately not selectable here.
type CredentialReferenceValues struct {
Name string
UsernameKey string
PasswordKey string
}
// CredentialReference is an immutable reference to a management Secret.
// Its zero value is invalid; aggregate construction must Validate incoming values.
type CredentialReference struct {
values CredentialReferenceValues
}
// Instance and Secret names share the DNS subdomain syntax and 253-character limit.
var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) {
reference := CredentialReference{values: values}
if err := reference.Validate(); err != nil {
return CredentialReference{}, err
}
return reference, nil
}
// Values returns a copy of the reference, never secret contents.
func (r CredentialReference) Values() CredentialReferenceValues { return r.values }
// Validate enforces reference invariants without accessing Kubernetes or OpenBao.
// Checking that the referenced Secret contains nonempty credentials is an application
// responsibility. Errors omit input values and no implicit defaults are applied.
func (r CredentialReference) Validate() error {
if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) {
return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters")
}
if r.values.UsernameKey == "" {
return errors.New("management Secret username field is required")
}
if r.values.PasswordKey == "" {
return errors.New("management Secret password field is required")
}
return nil
}
@@ -0,0 +1,119 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance_test
import (
"strings"
"testing"
"git.ddupan.top/panxiao81/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")
}
}
@@ -0,0 +1,89 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package instance contains the pure domain model of a registered PostgreSQL instance.
// It does not depend on Kubernetes types, database drivers or credential providers.
package instance
import (
"errors"
"net/netip"
"regexp"
)
// TLSMode is an explicit transport policy, not a driver-specific default.
type TLSMode string
const (
TLSDisable TLSMode = "disable"
TLSRequire TLSMode = "require"
TLSVerifyCA TLSMode = "verify-ca"
TLSVerifyFull TLSMode = "verify-full"
)
// EndpointValues carries explicit, effective values across the application boundary.
// Defaults are supplied by the API/application mapping, never silently by the domain.
type EndpointValues struct {
Host string
HostAddr string
Port int
ManagementDatabase string
TLSMode TLSMode
}
// Endpoint is an immutable connection target. Equality compares its declared values,
// not physical server identity. Its zero value is invalid; aggregate construction
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
type Endpoint struct {
values EndpointValues
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func NewEndpoint(values EndpointValues) (Endpoint, error) {
endpoint := Endpoint{values: values}
if err := endpoint.Validate(); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// Values returns a copy, without exposing mutable state.
func (e Endpoint) Values() EndpointValues { return e.values }
// Validate checks local invariants only; it does not resolve DNS or perform IO.
// Errors intentionally omit input values.
func (e Endpoint) Validate() error {
if e.values.Host == "" {
return errors.New("endpoint host is required")
}
address, err := netip.ParseAddr(e.values.HostAddr)
if err != nil || address.Zone() != "" {
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
}
if e.values.Port < 1 || e.values.Port > 65535 {
return errors.New("endpoint port must be between 1 and 65535")
}
if !identifier.MatchString(e.values.ManagementDatabase) {
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
}
switch e.values.TLSMode {
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
return nil
default:
return errors.New("endpoint TLS mode must be explicitly supported")
}
}
@@ -0,0 +1,118 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance_test
import (
"strings"
"testing"
"git.ddupan.top/panxiao81/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")
}
}

Some files were not shown because too many files have changed in this diff Show More