diff --git a/.github/workflows/framework-runtime.yml b/.github/workflows/framework-runtime.yml new file mode 100644 index 00000000..8e2abf5b --- /dev/null +++ b/.github/workflows/framework-runtime.yml @@ -0,0 +1,134 @@ +name: Framework Runtime (ARM64) + +# Runtime acceptance uses pinned third-party product images and its own kind +# cluster. PR generation/unit/lint checks remain in test.yml and lint.yml. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: framework-runtime-${{ github.ref }} + cancel-in-progress: false + +jobs: + formal-runtime: + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + defaults: + run: + shell: bash + working-directory: operator-go + env: + KIND_VERSION: v0.32.0 + KUBECTL_VERSION: v1.36.2 + COMMONS_REVISION: 6e65371a2e11bef700e6493987aa81f9f73e95a3 + PYTHONDONTWRITEBYTECODE: "1" + steps: + - name: Check out SDK revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: operator-go + persist-credentials: false + + - name: Check out fixed platform restarter + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: zncdatadev/commons-operator + ref: ${{ env.COMMONS_REVISION }} + path: commons-operator + persist-credentials: false + + # The pinned commons checkout requires Go 1.25.8, satisfying the SDK's + # earlier 1.25.3 minimum without an implicit toolchain switch in that build. + - name: Set up Go for both modules and restarter + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: commons-operator/go.mod + cache-dependency-path: | + operator-go/go.sum + operator-go/examples/trino-operator/go.sum + commons-operator/go.sum + + # Docker and Python are part of the official ubuntu-24.04-arm image. + # Start/check that daemon explicitly; record its actual version as evidence. + - name: Check native ARM64 Docker and Python + run: | + test "$(uname -m)" = aarch64 + sudo systemctl start docker + docker version + case "$(docker info --format '{{.Architecture}}')" in + aarch64|arm64) ;; + *) exit 1 ;; + esac + python3 --version + test "$(git -C ../commons-operator rev-parse HEAD)" = "$COMMONS_REVISION" + + - name: Install pinned kind and kubectl with checksums + run: | + tool_dir="$RUNNER_TEMP/framework-tools" + mkdir -p "$tool_dir" + kind_base="https://github.com/kubernetes-sigs/kind/releases/download/$KIND_VERSION" + curl --fail --silent --show-error --location --retry 3 \ + "$kind_base/kind-linux-arm64" -o "$tool_dir/kind-linux-arm64" + curl --fail --silent --show-error --location --retry 3 \ + "$kind_base/kind-linux-arm64.sha256sum" -o "$tool_dir/kind-linux-arm64.sha256sum" + (cd "$tool_dir" && sha256sum --check kind-linux-arm64.sha256sum) + install -m 0755 "$tool_dir/kind-linux-arm64" "$tool_dir/kind" + kubectl_base="https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/arm64" + curl --fail --silent --show-error --location --retry 3 \ + "$kubectl_base/kubectl" -o "$tool_dir/kubectl" + curl --fail --silent --show-error --location --retry 3 \ + "$kubectl_base/kubectl.sha256" -o "$tool_dir/kubectl.sha256" + (cd "$tool_dir" && printf '%s kubectl\n' "$(cat kubectl.sha256)" | sha256sum --check) + chmod 0755 "$tool_dir/kubectl" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + make kustomize + printf '%s\n' "$PWD/bin" >> "$GITHUB_PATH" + + - name: Verify generation, unit tests and lint before runtime + run: | + make verify-generate + make test ENVTEST_K8S_VERSION=1.35.0 + make lint + assets="$(bin/setup-envtest use 1.35.0 --bin-dir "$PWD/bin" -p path)" + make -C examples/trino-operator test KUBEBUILDER_ASSETS="$assets" + make -C examples/trino-operator lint + git diff --exit-code + + - name: Record installed tool versions + run: | + mkdir -p "$RUNNER_TEMP/framework-tool-evidence" + docker version > "$RUNNER_TEMP/framework-tool-evidence/docker.txt" + kind version > "$RUNNER_TEMP/framework-tool-evidence/kind.txt" + kubectl version --client -o json > "$RUNNER_TEMP/framework-tool-evidence/kubectl.json" + kustomize version > "$RUNNER_TEMP/framework-tool-evidence/kustomize.txt" + go version > "$RUNNER_TEMP/framework-tool-evidence/go.txt" + python3 --version > "$RUNNER_TEMP/framework-tool-evidence/python.txt" + + # SIGTERM reaches the harness before the job timeout, leaving four minutes + # for its process/image/cluster cleanup and separate cleanup verdict. + - name: Run formal deployment, Trino and Retain acceptance + run: | + timeout --signal=TERM --kill-after=240s 65m \ + python3 -u -B hack/framework-e2e/run.py \ + --commons "$GITHUB_WORKSPACE/commons-operator" \ + --output "$RUNNER_TEMP/framework-runtime" \ + 2>&1 | tee "$RUNNER_TEMP/framework-runtime.log" + + - name: Upload reviewable evidence without kubeconfig or binaries + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: framework-runtime-arm64-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: warn + retention-days: 14 + path: | + ${{ runner.temp }}/framework-tool-evidence/*.txt + ${{ runner.temp }}/framework-tool-evidence/*.json + ${{ runner.temp }}/framework-runtime.log + ${{ runner.temp }}/framework-runtime/**/*.json + ${{ runner.temp }}/framework-runtime/**/*.log + ${{ runner.temp }}/framework-runtime/**/*.txt + !${{ runner.temp }}/framework-runtime/**/*kubeconfig* diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ee4a5815..51d5d688 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,11 +32,7 @@ jobs: with: version: ${{ steps.golangci.outputs.version }} - # examples/trino-operator is a separate Go module with its own .golangci.yml and its own pinned - # golangci-lint version, and the job above lints the root module only — so the reference - # implementation downstream operators copy was never linted at all. Driven through the module's - # own Makefile so its pin stays the single source of truth (it currently differs from the root's, - # which is fine: they are independent modules). + # The example is a separate module/config, using the root-pinned linter binary. examples-lint: name: Go Lint (examples/trino-operator) runs-on: ubuntu-latest @@ -51,6 +47,7 @@ jobs: - name: Run linter run: | + make golangci-lint make -C examples/trino-operator lint md-lint: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21e6d03b..d0b526fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,12 +81,9 @@ jobs: run: | make verify-generate - # examples/trino-operator is a separate Go module, so the root module's jobs never compiled it. - # It is the reference implementation downstream operators are told to copy, and it was green - # purely because nothing ran it. Its own Makefile pins its own tool versions, so it is driven - # through that rather than reimplementing the setup here. - # - # The e2e suite still does not run: it needs a Kind cluster (see the module's `test-e2e` target). + # The formal Trino reference remains a separate module. Its real API-server + # sample test requires explicit root envtest assets; runtime delivery has a + # separate manually dispatched ARM64 workflow. examples-test: name: Go Test (examples/trino-operator) permissions: @@ -103,4 +100,7 @@ jobs: - name: Running Tests run: | - make -C examples/trino-operator test + make setup-envtest ENVTEST_K8S_VERSION=1.35.0 + assets="$(bin/setup-envtest use 1.35.0 --bin-dir "$PWD/bin" -p path)" + make -C examples/trino-operator test KUBEBUILDER_ASSETS="$assets" + make -C examples/trino-operator build diff --git a/.gitignore b/.gitignore index 24b130e6..b88bba3a 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,6 @@ _bmad* .claude/worktrees .serena .worktree/ + +# Local discussion, iteration notes and runtime evidence (never commit) +/.local/ diff --git a/AGENTS.md b/AGENTS.md index b34cd1c3..2d62a70b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Overview `operator-go` is a Golang SDK/framework for building Kubernetes operators. It provides a reusable reconciliation framework, CRDs, and utilities for creating product-specific operators. -**Key Features:** +**Key features of the existing GenericReconciler SDK:** - **GenericReconciler**: Template Method Pattern-based reconciliation framework - **Extension System**: Hook-based customization at cluster/role/role-group levels, with per-product registries - **Resource Builders**: Fluent builders for StatefulSet, Service, ConfigMap, PDB, RBAC, ServiceAccount @@ -22,19 +22,40 @@ > **IMPORTANT**: The `docs/` directory contains architecture documents that are the **authoritative source of design constraints** for this project. All implementations — including the SDK itself and any operators built with it — **must follow** the design defined in these documents. When code and documentation conflict, the documentation takes precedence. Consult these docs before making design decisions. -> **Scope of that rule.** `docs/architecture.md` is authoritative about **design intent**: a +> **Scope of that rule.** `docs/architecture.md` is the entry point for **design intent**. Its +> applicability section routes new-framework implementation to its `framework-design` section, +> while the separately scoped existing numbered sections apply to the GenericReconciler SDK. In the applicable scope, a > conflict means the code should change, not that the doc should be quietly relaxed. The > `AGENTS.md` files (this one and the per-package ones) are the opposite: they describe the API and > behavior that **exist today**, and must be corrected whenever the code changes. Never treat a > statement in an `AGENTS.md` as a requirement the code has yet to meet — anything aspirational -> belongs in `docs/architecture.md` and must be explicitly labelled as such. +> belongs in the applicable architecture specification and must be explicitly labelled as such. + +The new framework exposes contracts, generated input and registration in +`pkg/framework`; see [package instructions](pkg/framework/AGENTS.md). +Its pure build pipeline and controller are internal; the Trino reference uses +the public contracts and generated registration as its actual executable. +See [Trino instructions](examples/trino-operator/AGENTS.md) and the +[delivery guide](hack/framework-e2e/README.md). +The numbered SDK concepts below continue to describe the existing GenericReconciler API. + +### Local working records + +Store discussion notes, research, iteration plans, implementation progress, experimental +prototypes and runtime evidence under `.local/engineering-notes/`, which is Git-ignored. +The current discussion archive is `.local/engineering-notes/framework-redesign/README.md`. +Do not add these records to Git or place them under `docs/`; do not use `git add -f` +to bypass the ignore rule. Keep only maintained specifications, developer guidance and +examples in `docs/`. Reusable tests and verification tools stay with source/test tooling; +their generated reports belong in the local directory. Public docs and build/test paths +must not depend on local records being present. ### Documentation Structure | File | Description | |------|-------------| -| `docs/architecture.md` | **Core Technical Architecture** — design philosophy, layered architecture, core module specifications, design patterns, key problem solutions. This is the primary reference for all SDK design decisions. | -| `docs/security.md` | **Security Architecture** — application security (SecretClass, CSI, AutoTLS, Kerberos) and infrastructure security (RBAC, ServiceAccounts, Pod security) | +| `docs/architecture.md` | **Architecture specification** — product-description framework contracts and domain boundaries, followed by separately scoped GenericReconciler SDK sections. | +| `docs/security.md` | **Security specification** — framework authentication, secrets and explicit data-operation authorization; existing SDK security sections are scoped separately. | | `docs/DOC_CHANGELOG.md` | Changelog tracking all documentation updates | | `docs/examples/` | CRD example YAMLs demonstrating the SDK's data model | diff --git a/Dockerfile.dataops b/Dockerfile.dataops new file mode 100644 index 00000000..ade7724c --- /dev/null +++ b/Dockerfile.dataops @@ -0,0 +1,5 @@ +# Build context: bin/dataops-image, produced by make dataops-image. +FROM scratch +COPY dataops /dataops +USER 65532:65532 +ENTRYPOINT ["/dataops"] diff --git a/Makefile b/Makefile index f5eaf342..afdb8038 100644 --- a/Makefile +++ b/Makefile @@ -21,18 +21,29 @@ help: ## Display this help. ##@ Development +MATERIALIZER_IMG ?= quay.io/zncdatadev/operator-go-materializer:0.0.0-dev +MATERIALIZER_ARCH ?= $(shell go env GOARCH) +MATERIALIZER_CONTEXT = $(LOCALBIN)/materializer-$(MATERIALIZER_ARCH) + +.PHONY: materializer-build +materializer-build: ## Compile the formal materializer as a static Linux binary. + mkdir -p "$(MATERIALIZER_CONTEXT)" + CGO_ENABLED=0 GOOS=linux GOARCH=$(MATERIALIZER_ARCH) go build -mod=readonly -trimpath -buildvcs=false -o "$(MATERIALIZER_CONTEXT)/materialize" ./cmd/materialize + +.PHONY: materializer-image +materializer-image: materializer-build ## Build the materializer image; no registry push. + docker build --network=none --platform linux/$(MATERIALIZER_ARCH) --build-arg SOURCE_REVISION="$$(git rev-parse HEAD)" -f cmd/materialize/Dockerfile -t "$(MATERIALIZER_IMG)" "$(MATERIALIZER_CONTEXT)" + .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./pkg/..." .PHONY: manifests -manifests: controller-gen ## Generate the CRDs backing the test mock cluster resources. -# This SDK ships API types, not CRDs — a product operator generates its own from the types it -# embeds. The only CRDs generated here are the ones envtest installs for pkg/testutil's mock -# cluster resources, and they are generated rather than hand-written on purpose: a hand-written -# CRD drifts from the Go types, and the schema-free version this replaced meant the API server -# performed no defaulting, validation or pruning in ANY test in the repository. +manifests: controller-gen ## Generate test cluster and independent framework data CRDs. +# Products generate their own cluster CRDs. The framework also owns the independent +# data identity/operation protocol and the mock cluster CRDs used by envtest. $(CONTROLLER_GEN) crd paths="./pkg/testutil/..." output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) crd paths="./pkg/framework/dataops/..." output:crd:artifacts:config=config/framework-data/bases .PHONY: fmt fmt: ## Run go fmt against code. @@ -59,23 +70,12 @@ verify-generate: generate manifests ## Fail if the committed generated files are # Scoped to the paths generation writes, so the target stays usable with unrelated work in progress # — a check that fails on any dirty file is a check nobody runs locally. # -# examples/trino-operator is a separate module and is checked too: it embeds the commons API types, -# so a change to pkg/apis leaves its CRD stale, and it is the reference implementation downstream -# operators copy. Its own Makefile pins its own controller-gen, so it is driven through that. -# -# '*/config/rbac/*' is in the pathspec because that module's generated ClusterRole is the canonical -# operator-side permission set docs/security.md §3.3 points adopters at. Without it, an edited -# +kubebuilder:rbac marker whose regenerated role.yaml was never committed passed CI. -# -# The trailing /* is load-bearing, and its absence is why the sibling '*/config/crd/bases' entry had -# been inert since it was written. A git pathspec containing a wildcard is wildmatched against the -# FULL path with no directory-prefix expansion, so '*/config/rbac' matches a path that IS that -# directory and never a file inside it — the guard passed unconditionally. Verified by dirtying -# examples/trino-operator/config/rbac/role.yaml: plain `git status` shows it, the old pathspec -# reported nothing, the new one reports it. The literal `config/crd/bases` (no wildcard) always -# worked, which is why only the root module was ever actually covered. - $(MAKE) -C examples/trino-operator generate manifests - @drift="$$(git status --porcelain -- '*zz_generated*.go' '*/config/crd/bases/*' config/crd/bases '*/config/rbac/*')"; \ +# The formal Trino example is a separate module. Its own generator checks the +# committed input, CRD and registration companion against the product definition. +# It does not use controller-gen or generate its deployment RBAC. Keep the scoped +# status guard for root generated files and committed example delivery inputs. + $(MAKE) -C examples/trino-operator verify-generate + @drift="$$(git status --porcelain -- '*zz_generated*.go' '*/config/crd/bases/*' config/crd/bases config/framework-data/bases '*/config/rbac/*')"; \ if [ -n "$$drift" ]; then \ echo "Generated files are out of date. Run 'make generate manifests' and commit the result:"; \ echo "$$drift"; \ @@ -171,3 +171,13 @@ $(ENVTEST): $(LOCALBIN) 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)) + +DATAOPS_IMAGE ?= operator-go-dataops:dev +DATAOPS_ARCH ?= $(shell go env GOARCH) +.PHONY: dataops-build dataops-image +dataops-build: ## Build the independently deployed explicit-data executor. + mkdir -p bin/dataops-image + CGO_ENABLED=0 GOOS=linux GOARCH=$(DATAOPS_ARCH) go build -mod=readonly -trimpath -buildvcs=false -o bin/dataops-image/dataops ./cmd/dataops + +dataops-image: dataops-build ## Package the data executor separately from product operators. + docker build --platform linux/$(DATAOPS_ARCH) -f Dockerfile.dataops -t $(DATAOPS_IMAGE) bin/dataops-image diff --git a/README.md b/README.md index acf13a73..b030fd1c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ English | [简体中文](./README_zh-CN.md) A Golang SDK/framework for building Kubernetes operators. Built on [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime), it provides a reusable reconciliation framework, CRDs, and utilities for creating product-specific operators. +## New framework reference + +The new `pkg/framework` API lets a product declare its configuration and runtime while the SDK owns folding, overrides, resource assembly and reconciliation. Start with the [Trino reference operator](examples/trino-operator/README.md), [core specification](docs/architecture.md#framework-design) and [delivery/validation guide](hack/framework-e2e/README.md). Use the reviewed source revision for these packages; their presence here does not assert that `@latest` or a public image release already includes them. + +The remaining overview and quick start below describe the existing GenericReconciler API. They are not the authoring path used by the new Trino reference. + ## Overview **operator-go** is designed to work seamlessly with [Kubebuilder](https://book.kubebuilder.io/), the standard framework for building Kubernetes APIs. We recommend following the [Kubebuilder documentation](https://book.kubebuilder.io/quick-start.html) to scaffold your operator project, then integrate operator-go to leverage its powerful reconciliation framework. @@ -120,10 +126,10 @@ func (r *TrinoClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request A complete example operator is available in [`examples/trino-operator/`](./examples/trino-operator/), demonstrating: -- CRD definition with `ClusterInterface` implementation -- RoleGroupHandler for coordinator and worker roles -- Extension registration for custom logic -- Webhook setup for validation and defaulting +- Typed product configuration and generated presence-preserving API/CRD +- ProductDefinition runtime generation for coordinator and worker roles +- Generated registration through the public framework/operator API +- Platform dependencies, logging, typed lifecycle and retained-storage consumers; runtime evidence is recorded separately ## Development diff --git a/README_zh-CN.md b/README_zh-CN.md index 4424b55f..fa300f3e 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -11,6 +11,12 @@ 一个用于构建 Kubernetes Operator 的 Golang SDK/框架。基于 [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) 构建,提供可复用的调和框架、CRD 和实用工具,用于创建产品特定的 Operator。 +## 新框架入口 + +新的 `pkg/framework` API 由产品声明配置和运行方式,SDK 负责继承、覆盖、资源装配与持续收敛。从 [Trino 参考 operator](examples/trino-operator/README.md)、[核心规范](docs/architecture.md#framework-design)和[交付验收指南](hack/framework-e2e/README.md)开始。请使用已审阅的源码修订;这里存在实现不代表 `@latest` 或公共镜像已经发布该版本。 + +下文的概述和快速开始继续介绍现有 GenericReconciler API,不是新 Trino 参考实现的作者接入路径。 + ## 概述 **operator-go** 设计为与 [Kubebuilder](https://book.kubebuilder.io/) 无缝协作,Kubebuilder 是构建 Kubernetes API 的标准框架。我们建议遵循 [Kubebuilder 文档](https://book.kubebuilder.io/quick-start.html) 来搭建您的 Operator 项目脚手架,然后集成 operator-go 以利用其强大的调和框架。 @@ -120,10 +126,10 @@ func (r *TrinoClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request 完整的示例 Operator 可在 [`examples/trino-operator/`](./examples/trino-operator/) 中找到,演示了: -- 带有 `ClusterInterface` 实现的 CRD 定义 -- coordinator 和 worker 角色的 RoleGroupHandler -- 自定义逻辑的扩展注册 -- 用于验证和默认值设置的 Webhook 配置 +- 类型化产品配置与保留字段填写状态的生成 API/CRD +- coordinator 和 worker 角色的 ProductDefinition 运行描述 +- 通过公共 framework/operator API 的生成注册 +- 平台依赖、日志、类型化生命周期和保留存储消费者;实际运行证据独立记录 ## 开发 diff --git a/cmd/dataops/main.go b/cmd/dataops/main.go new file mode 100644 index 00000000..dcab663b --- /dev/null +++ b/cmd/dataops/main.go @@ -0,0 +1,48 @@ +// Command dataops runs the explicit retained-data operation controller. +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/zncdatadev/operator-go/pkg/framework/dataops" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + image := flag.String("worker-image", "", "Python 3 image for the fixed copy/erase worker") + flag.Parse() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, appsv1.AddToScheme, storagev1.AddToScheme, dataops.AddToScheme, + } { + if err := add(scheme); err != nil { + return err + } + } + config, err := ctrl.GetConfig() + if err != nil { + return err + } + manager, err := ctrl.NewManager(config, ctrl.Options{Scheme: scheme, Metrics: server.Options{BindAddress: "0"}, + HealthProbeBindAddress: "0"}) + if err != nil { + return err + } + if err = dataops.Register(manager, dataops.Options{WorkerImage: *image}); err != nil { + return err + } + return manager.Start(ctrl.SetupSignalHandler()) +} diff --git a/cmd/materialize/Dockerfile b/cmd/materialize/Dockerfile new file mode 100644 index 00000000..19667523 --- /dev/null +++ b/cmd/materialize/Dockerfile @@ -0,0 +1,10 @@ +# Build the Linux binary with `make materializer-image` from the SDK root. +# The image context contains only that binary; no shell or package manager is needed. +FROM scratch +ARG SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision=$SOURCE_REVISION \ + dev.kubedoop.framework.materialization-plan=v1 \ + dev.kubedoop.framework.properties-codec=properties-v1 +COPY --chmod=0755 materialize /materialize +USER 1001:1001 +ENTRYPOINT ["/materialize"] diff --git a/cmd/materialize/main.go b/cmd/materialize/main.go new file mode 100644 index 00000000..9031ded7 --- /dev/null +++ b/cmd/materialize/main.go @@ -0,0 +1,40 @@ +// Command materialize writes a validated file plan into an exclusive output tree. +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "materialize:", err) + os.Exit(1) + } +} + +func run(args []string) error { + flags := flag.NewFlagSet("materialize", flag.ContinueOnError) + flags.SetOutput(io.Discard) + planPath := flags.String("plan", "", "JSON materialization plan path") + outputRoot := flags.String("root", "", "output tree root") + if err := flags.Parse(args); err != nil { + return err + } + if *planPath == "" || *outputRoot == "" || flags.NArg() != 0 { + return fmt.Errorf("--plan and --root are required; positional arguments are unsupported") + } + data, err := os.ReadFile(*planPath) + if err != nil { + return err + } + plan, err := pipeline.DecodeMaterializationPlan(data) + if err != nil { + return err + } + return pipeline.Materialize(*outputRoot, plan, os.Getenv("POD_NAME")) +} diff --git a/cmd/materialize/main_test.go b/cmd/materialize/main_test.go new file mode 100644 index 00000000..1d8af0e3 --- /dev/null +++ b/cmd/materialize/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func commandPlan(t *testing.T) string { + t.Helper() + plan, err := pipeline.PrepareMaterialization([]framework.File{ + {Directory: "config", Path: "node.properties", Content: framework.KeyValues{ + Codec: framework.PropertiesCodec{}, Values: map[string]framework.PropertyValue{ + "node.id": framework.PodNameBinding{}, "literal": framework.Literal("${POD_NAME}; $(touch NEVER)"), + }, + }}, + {Directory: "config", Path: "catalog/custom", Content: framework.Text("connector.name=blackhole\n")}, + {Directory: "config", Path: "empty", Content: framework.Lines{}}, + }) + if err != nil { + t.Fatal(err) + } + data, err := pipeline.EncodeMaterializationPlan(plan) + if err != nil { + t.Fatal(err) + } + name := filepath.Join(t.TempDir(), "plan.json") + if err := os.WriteFile(name, data, 0600); err != nil { + t.Fatal(err) + } + return name +} + +func TestCommandMaterializesFilesAndReportsFailures(t *testing.T) { + workspace := t.TempDir() + binary := filepath.Join(workspace, "materialize") + build := exec.Command("go", "build", "-mod=readonly", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build materializer: %s: %v", output, err) + } + planPath := commandPlan(t) + outputRoot := filepath.Join(workspace, "output") + t.Setenv("POD_NAME", "cluster-workers-default-0") + for range 2 { + command := exec.Command(binary, "--plan="+planPath, "--root="+outputRoot) + command.Dir = workspace + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("execute materializer: %s: %v", output, err) + } + } + for name, want := range map[string]string{ + "node.properties": "literal=${POD_NAME}; $(touch NEVER)\nnode.id=cluster-workers-default-0\n", + "catalog/custom": "connector.name=blackhole\n", + "empty": "", + } { + got, err := os.ReadFile(filepath.Join(outputRoot, "config", name)) + if err != nil || string(got) != want { + t.Fatalf("file %q: got=%q want=%q error=%v", name, got, want, err) + } + } + if _, err := os.Stat(filepath.Join(workspace, "NEVER")); !os.IsNotExist(err) { + t.Fatalf("literal shell content executed: %v", err) + } + t.Setenv("POD_NAME", "invalid name") + failedRoot := filepath.Join(workspace, "failed") + command := exec.Command(binary, "--plan="+planPath, "--root="+failedRoot) + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "valid POD_NAME") { + t.Fatalf("failed binding did not produce a nonzero process exit: output=%q err=%v", output, err) + } + if _, err := os.Stat(failedRoot); !os.IsNotExist(err) { + t.Fatalf("failed binding wrote output: %v", err) + } +} + +func TestCommandRejectsIncompleteInputsBeforeWriting(t *testing.T) { + planPath := commandPlan(t) + for _, args := range [][]string{ + nil, {"--plan=" + planPath}, {"--root=unused"}, {"--unknown=true"}, + {"--plan=" + planPath, "--root=unused", "positional"}, + } { + if err := run(args); err == nil { + t.Fatalf("invalid command accepted: %v", args) + } + } + root := filepath.Join(t.TempDir(), "not-created") + badPlan := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(badPlan, []byte(`{"version":"v1","files":[],"unknown":true}`), 0600); err != nil { + t.Fatal(err) + } + for _, name := range []string{badPlan, filepath.Join(t.TempDir(), "missing.json")} { + if err := run([]string{"--plan=" + name, "--root=" + root}); err == nil { + t.Fatalf("invalid or missing plan accepted: %s", name) + } + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("invalid plan wrote output: %v", err) + } +} diff --git a/config/framework-data-executor/deployment.yaml b/config/framework-data-executor/deployment.yaml new file mode 100644 index 00000000..a93f53af --- /dev/null +++ b/config/framework-data-executor/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: framework-data-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: framework-data-executor +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: framework-data-executor +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: framework-data-executor +subjects: + - kind: ServiceAccount + name: framework-data-executor + namespace: framework-data-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: framework-data-executor +spec: + replicas: 1 + selector: + matchLabels: + app: framework-data-executor + template: + metadata: + labels: + app: framework-data-executor + spec: + serviceAccountName: framework-data-executor + containers: + - name: executor + image: operator-go-dataops:dev + imagePullPolicy: IfNotPresent + args: + - --worker-image=quay.io/zncdatadev/trino@sha256:6c7002a7e6d4f7a738f3e3066dabf06f433c32e57900d5697a7544187f40875f + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: '1' + memory: 256Mi diff --git a/config/framework-data-executor/kustomization.yaml b/config/framework-data-executor/kustomization.yaml new file mode 100644 index 00000000..bdb11ab3 --- /dev/null +++ b/config/framework-data-executor/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: framework-data-system +resources: + - ../framework-data + - deployment.yaml diff --git a/config/framework-data/bases/data.framework.kubedoop.dev_dataassets.yaml b/config/framework-data/bases/data.framework.kubedoop.dev_dataassets.yaml new file mode 100644 index 00000000..67a26ded --- /dev/null +++ b/config/framework-data/bases/data.framework.kubedoop.dev_dataassets.yaml @@ -0,0 +1,570 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: dataassets.data.framework.kubedoop.dev +spec: + group: data.framework.kubedoop.dev + names: + kind: DataAsset + listKind: DataAssetList + plural: dataassets + singular: dataasset + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: DataAsset is independent of a product CR's ownership and survives + its deletion. + 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: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + x-kubernetes-validations: + - message: initial data identity is immutable + rule: self == oldSelf + status: + properties: + current: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + destroyed: + type: boolean + history: + items: + properties: + action: + maxLength: 4096 + type: string + completed: + format: date-time + type: string + from: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + operationUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + to: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + verification: + maxLength: 4096 + type: string + required: + - action + - completed + - from + - operationUID + - verification + type: object + maxItems: 1000 + type: array + retiredCopies: + items: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + maxItems: 1000 + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/framework-data/bases/data.framework.kubedoop.dev_dataoperations.yaml b/config/framework-data/bases/data.framework.kubedoop.dev_dataoperations.yaml new file mode 100644 index 00000000..f0446ffa --- /dev/null +++ b/config/framework-data/bases/data.framework.kubedoop.dev_dataoperations.yaml @@ -0,0 +1,404 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: dataoperations.data.framework.kubedoop.dev +spec: + group: data.framework.kubedoop.dev + names: + kind: DataOperation + listKind: DataOperationList + plural: dataoperations + singular: dataoperation + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + DataOperation's approval is an exact digest of the immutable operation intent. + Creating operations is a separate RBAC capability from managing product CRs. + 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: + action: + maxLength: 4096 + type: string + approval: + maxLength: 4096 + type: string + assetName: + maxLength: 4096 + type: string + assetUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + source: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + sourceCluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + target: + properties: + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - claimName + - cluster + - source + type: object + workerIdentity: + properties: + gid: + format: int64 + type: integer + uid: + format: int64 + type: integer + required: + - gid + - uid + type: object + required: + - action + - approval + - assetName + - assetUID + - source + - sourceCluster + - workerIdentity + type: object + x-kubernetes-validations: + - message: operation intent is immutable + rule: self == oldSelf + status: + properties: + attempt: + format: int32 + type: integer + completed: + format: date-time + type: string + jobUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + message: + maxLength: 4096 + type: string + phase: + maxLength: 4096 + type: string + specDigest: + maxLength: 4096 + type: string + target: + properties: + binding: + properties: + pvUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + pvcUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + version: + type: integer + volumeName: + maxLength: 4096 + type: string + required: + - pvUID + - pvcUID + - version + - volumeName + type: object + claimName: + maxLength: 4096 + type: string + cluster: + properties: + apiVersion: + maxLength: 4096 + type: string + kind: + maxLength: 4096 + type: string + name: + maxLength: 4096 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + source: + properties: + capacity: + maxLength: 4096 + type: string + crUID: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 4096 + type: string + group: + maxLength: 4096 + type: string + role: + maxLength: 4096 + type: string + slot: + maxLength: 4096 + type: string + storageClass: + maxLength: 4096 + type: string + version: + type: integer + required: + - capacity + - crUID + - group + - role + - slot + - storageClass + - version + type: object + required: + - binding + - claimName + - cluster + - source + type: object + workerReceipt: + maxLength: 4096 + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/framework-data/kustomization.yaml b/config/framework-data/kustomization.yaml new file mode 100644 index 00000000..cd966e26 --- /dev/null +++ b/config/framework-data/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - bases/data.framework.kubedoop.dev_dataassets.yaml + - bases/data.framework.kubedoop.dev_dataoperations.yaml + - rbac.yaml diff --git a/config/framework-data/rbac.yaml b/config/framework-data/rbac.yaml new file mode 100644 index 00000000..8360d30d --- /dev/null +++ b/config/framework-data/rbac.yaml @@ -0,0 +1,45 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: framework-data-observer +rules: + - apiGroups: [data.framework.kubedoop.dev] + resources: [dataassets] + verbs: [get, list, watch, create] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: framework-data-executor +rules: + - apiGroups: [data.framework.kubedoop.dev] + resources: [dataassets, dataassets/status, dataoperations, dataoperations/status] + verbs: [get, list, watch, update, patch] + - apiGroups: [""] + resources: [persistentvolumeclaims, persistentvolumes, pods] + verbs: [get, list, watch, create, update, delete] + - apiGroups: [batch] + resources: [jobs] + verbs: [get, list, watch, create] + - apiGroups: [apps] + resources: [statefulsets] + verbs: [get, list, watch] + - apiGroups: [storage.k8s.io] + resources: [storageclasses] + verbs: [get] + # CR kinds are product-defined. This rule only reads the exact explicitly authorized source/target CR. + - apiGroups: ['*'] + resources: ['*'] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: framework-data-authorizer +rules: + - apiGroups: [data.framework.kubedoop.dev] + resources: [dataassets, dataoperations] + verbs: [get, list, watch] + - apiGroups: [data.framework.kubedoop.dev] + resources: [dataoperations] + verbs: [create, patch, update] diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..acf5fa85 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# 文档导航 + +此目录维护统一的架构、安全规范和 CRD 示例。 + +- [架构设计](architecture.md#framework-design):产品职责、输入继承、覆盖、装配、平台依赖、生命周期和数据操作;原有 GenericReconciler API 在同文单独标明适用范围。 +- [安全设计](security.md#framework-authentication):认证、Secret/CSI 与独立数据操作授权。 +- [Trino 开发示例](../examples/trino-operator/README.md):产品配置、原生消费和实际接入方式。 +- [交付与验证指南](../hack/framework-e2e/README.md):生成、构建、部署和可复用验收工具。 +- [CRD 示例](examples/):已有 SDK 的公共结构示例;新框架参考输入见 Trino 示例模块。 + +讨论、调研、迭代计划、临时原型与运行结果保存在 Git 忽略的 `.local/engineering-notes/`。 +它们用于工作上下文,不是正式文档的前置依赖,也不应加入版本库。 diff --git a/docs/architecture.md b/docs/architecture.md index 2f0415ef..943dd6d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,636 @@ -# Common Product Cluster Operator SDK Technical Architecture Document +# Operator-Go Architecture + +## Applicability and design authority + +This document is the design authority for both implemented API families, with explicit scope: + +- [Product-description framework](#framework-design): the public `pkg/framework` contracts used by the Trino reference. +- [Existing GenericReconciler SDK](#existing-generic-reconciler-sdk): the numbered English sections retained for the existing API. + +Use the applicable section; GenericReconciler hooks, admission defaults and merge semantics do not add requirements to the product-description framework. +[Security](security.md), the [Trino developer guide](../examples/trino-operator/README.md) and the +[delivery guide](../hack/framework-e2e/README.md) cover their respective responsibilities. +Discussion notes, iteration plans and runtime reports are Git-ignored local records, not another source of design authority. + + + +## Product-description framework + +产品声明业务输入和运行方式;框架负责继承、覆盖、资源装配和持续收敛。产品作者不编排这些阶段。 +以下规范适用于正式的 `pkg/framework` API,不是 SDK 版本或镜像发布声明。 + +领域细节:[存储](#framework-storage) · [平台依赖](#framework-platform) · +[认证](security.md#framework-authentication) · [S3](#framework-s3) · [日志](#framework-logging) · +[生命周期](#framework-lifecycle) · [数据操作](#framework-data-operations)。 + +### 1. 能力与支持边界 + +| 领域 | 实现范围与边界 | +| --- | --- | +| CR 输入 | 标准 image、独立 clusterConfig、显式角色与角色组、公共/产品 config、roleConfig/PDB、四通道 overrides;生成存在性输入与 CRD | +| 产品运行描述 | 一个主进程、有序初始化、原生 lifecycle/probe、文件、目录访问、端点和日志产出;物化器与可选 Vector;不提供任意 sidecar/资源注册表 | +| 文件 | KeyValues、Lines、Text;明确覆盖动作和受限运行期属性绑定;不按扩展名猜编码器 | +| 资源 | 每组 ConfigMap、StatefulSet、普通/Headless Service;每角色 PDB;显式共享 ConfigMap及同源撤回 | +| 外部事实与平台结果 | 精确只读 Get、按组结果、单轮引用快照和有界刷新;创建前来源解析与创建后 CSI/Listener 观察分离 | +| 日志 | enableVectorAgent、原生适配和实际文件声明;Vector 到 stdout JSON 或集中目的地;产品明确原生格式支持范围 | +| 保留存储 | [标准存储](architecture.md#framework-storage)绑定一个 Data 目录;RWO/Filesystem、Retain/Retain;[数据操作](architecture.md#framework-data-operations)独立授权 | +| 协调控制 | 暂停、停止、恢复、组退休、固定资源所有权、条件状态;逐 ordinal 缩容、停止优先级和持久进展预算;不承诺通用事务一致性 | +| 开发与交付 | 生成输入/schema/注册、参考 operator、物化 helper、独立数据 executor、CRD/RBAC/部署和可复用验收工具 | + +具体产品只接收有实际消费者的配置。旧 SDK 的能力不自动成为新框架的支持范围。 +运行/API 测试分别验证对应层次;发布制品必须满足[交付指南](../hack/framework-e2e/README.md)的检查要求。 + +### 2. 产品作者的三个对象 + +| 对象 | 构造者 | 内容和责任 | +| --- | --- | --- | +| `ProductDefinition[C,S,F]` | 产品 | 角色及默认配置、镜像默认、输入校验、组运行描述生成、可选共享 ConfigMap 生成和最终业务关系检查 | +| `EffectiveInput[C,S,F]` | 框架 | 当前组身份、完整有效配置、产品集群配置、标准 Platform 输入、有效镜像、该组事实和完整声明拓扑 | +| `RuntimeDescription` | 产品生成函数 | 主进程与有序初始化、原生生命周期/探针、协调策略、文件、目录访问、端点、日志和 Data/Secret/Listener 目录声明 | + +`C` 是组级产品配置,`S` 是集群级产品配置,`F` 是产品使用的事实数据;没有相应内容时用 `struct{}`。 +有效值不携带“用户是否填写”的指针。公共字段与产品字段在 Go 中分别处于 `Config.Common` 和 `Config.Product`, +在 CR 的 `config` 中仍是平铺字段;生成时拒绝字段重名。 + +定义按进程注册,CR 数据按协调轮次读取。默认数据、基础事实、平台选项在注册时复制; +每个回调取得隔离的数据快照。框架不承诺复制函数闭包捕获的可变状态,产品必须避免跨 CR 的隐式状态。 +生成和校验函数不得访问集群或写外部状态;需要读取的依赖由只读事实适配器提供。 + +普通产品通过函数字段提供语义,不需要继承基类、实现空钩子或构造 reconciler。 +可选 `GenerateCluster` 首批只生成共享 ConfigMap,并显式报告 Ready 或 Pending(第 6.1 节), +不扩为任意资源或事后修改 Pod 的入口。共享输出结果是辅助数据类型,不增加一个产品生命周期阶段。 +`ValidateFinal` 只返回检查结果;检查之后不存在能修改最终产物的产品钩子。 + +### 3. 输入领域与继承 + +#### 3.1 各领域拥有自己的作用域 + +| 输入 | 折叠来源,左低右高 | 最终消费位置 | +| --- | --- | --- | +| 组 `config` | 角色完整默认 → CR role.config → roleGroup.config | 公共装配和产品生成,共用一次确定的有效值 | +| 产品 `clusterConfig` | ClusterConfigDefaults → CR clusterConfig 中的产品字段 | 集群、事实和组生成;不向 role.config 隐式继承 | +| image | 产品镜像默认 → CR image,按镜像领域解析一次 | 有效镜像、主进程默认、pull policy/secrets | +| roleConfig | RoleDefinition 中的角色管理默认 → CR role.roleConfig | 角色资源;组不能声明此入口 | +| replicas | 框架默认 1 → role.replicas → roleGroup.replicas | 声明拓扑;停止时另外计算执行副本 | +| stopped / reconciliationPaused | clusterConfig 中的固定运行控制;省略为 false | 执行控制;不接受产品默认,不参与产品 S 的折叠 | + +组生成失败仍保留该组的声明身份;零组角色仍是一个角色。配置、事实和构建错误都不能把期望资源误判为退休对象。 +资源名、选择器和供产品生成地址使用的名称必须共用同一确定性命名规则。 +首批要求在构建写入前拒绝非法组合名称及碰撞;不要求通过静默截断接受所有合法单段名称的组合。 + +#### 3.2 普通配置的固定规则 + +普通产品对象按字段递归,字符串键 map 按键递归;带点号的键是字面键,不解释为路径。 +序列整体替换,不提供按字段选择 Append、Atomic 或自定义合并策略的接口。 + +| 更高层输入 | 语义 | +| --- | --- | +| 未填写 | 继承 | +| `false`、`0`、`""` | 明确的值,覆盖低层 | +| 对象或 map 的 `{}` | 没有新字段/键,继承;不是清空动作 | +| 序列 `[]` | 明确替换为空 | +| 普通配置中的 `null` | 本地解码拒绝;不是一套隐式删除语法 | + +先检查各输入层的结构、类型和歧义,再折叠,最后检查有效业务值。 +低层某个业务值无效但被高层修正,不应在折叠前阻断;无效类型和歧义则不能依靠覆盖掩盖。 +CRD 不写入继承默认,确保持久化不会把“未填写”变成“用户显式填写”。 + +原生领域单独定义语义:Affinity 的 node/pod/podAnti 三个分支分别处理,省略分支继承, +出现分支则整体替换,`nodeAffinity: {}` 清空该分支。Quantity 按数量值处理,Duration 是字符串, +最终 gracefulShutdownTimeout 必须是非负整秒,0 有效。CPU 有 min/max,内存 limit 同时成为 request 和 limit。 +这些是固定领域规则,不是允许产品注册任意反射策略的先例。 + +#### 3.3 可生成的 Go 类型与 admission 边界 + +首批 C/S 支持导出字段的普通结构体、标量、字符串键 map、slice,以及固定支持的 Quantity/Duration。 +不支持嵌入、指针、interface、`[]byte`、自定义 JSON/Text 编解码等任意 Go 模型。 +生成注册包引用的顶层 C/S 必须是可跨包引用的命名结构体或 `struct{}`;非空匿名结构体、未导出类型和 +暂不支持的泛型实例必须报错。生成器检查不替代生成包的真实编译。 + +presence 输入、CRD schema、投影和注册代码从同一描述生成,不手工维护多份业务字段清单。 +首批保留已验证的 schema 容量约束:普通配置集合上限 32,原生 Affinity 集合上限 16; +这不是 status 列表上限,也不保证任意深度/嵌套 schema 都能安装。扩大容量必须有 admission 成本和真实安装证据。 + +本地严格解码拒绝重复键、未知字段和普通配置 null。API server 可能裁剪未知字段, +因此“本地拒绝”不能冒充“所有 API 请求都会拒绝”;交付示例用严格字段校验,并测试真实持久化结果。 +JSON Merge Patch 的 null 可以删除已存储输入而恢复继承,与配置对象显式含 null 的含义不同。 + +### 4. 从有效输入到最终资源 + +#### 4.1 框架拥有固定执行顺序 + +1. 投影完整身份清单,折叠公共与产品输入,解析镜像,准备声明拓扑。 +2. 通过事实适配器取得组所需的外部值;只有有效且已就绪的组进入产品生成。 +3. 产品为有效组生成一次运行描述;框架根据实际 LogOutputs 解析集中日志目的地,并检查目录、产出归属、文件、端点和进程声明。 +4. 组合平台能力,处理文件/env/CLI 的角色层和组层覆盖,装配工作负载及配套资源。 +5. 对完整 PodTemplate 先应用 role.podOverrides,再应用 roleGroup.podOverrides。 +6. 独立检查最终产物之间的已知关系,形成可执行计划或带原因的失败;控制器负责应用。 +7. 应用后独立观察平台生产者及 CSI/Listener 结果,通过 RefreshClusterOutput 刷新共享输出;结果 Pending 保留旧输出。 + +Source、Prepared、Plan 是内部工作结构,不是要求产品作者依次调用的公共阶段。 +最终 Pod 改变不会反向重算有效配置、重新派生文件或触发一次隐藏的产品生成。 + +#### 4.2 四个覆盖通道 + +| 通道 | 目标和行为 | +| --- | --- | +| configOverrides | 产品显式 ConfigDirectory 下的相对文件路径;按角色、组依次执行文件动作 | +| envOverrides | 主进程 env,按名称覆盖;空字符串是值,不是删除 | +| cliOverrides | 主进程 Args 整体替换,保持 Command;`[]` 清空参数 | +| podOverrides | 完整 PodTemplate 的原生 Strategic Merge Patch;平台装配及其他覆盖全部完成后执行 | + +这里的优先级按通道顺序确定:**role.podOverrides 也高于 roleGroup.envOverrides/cliOverrides**。 +Pod 补丁中的 null、`$patch`、按 mountPath 等原生合并键保留其补丁语义,不套用普通配置规则。 +“最高优先级”意味着最终值生效,不意味着能绕过最终资源和已知消费关系的检查。 +可确定的冲突使该组不能应用;无法静态判断的产品消费关系必须保留 Unknown,不擅自修复用户输入。 + +文件有三种内容类型:`KeyValues{Codec,Values}`、`Lines`、`Text`。 +覆盖动作是 `properties.set/remove`、`properties.replace`、`lines`、`text`、`remove: true`; +replace 与 set/remove 互斥,同一键不能同时 set/remove,每个文件一次只选一种内容/删除动作。 +空内容与删除文件不同;后续空 patch 不会使已删除文件复活。 +properties 操作依赖产品原始结构化编码声明,不能从 `.xml` 或 `.properties` 后缀推断。 +覆盖语法由[输入契约](../pkg/framework/input/contract.go)定义,例如: + +```yaml +configOverrides: + config.properties: + properties: + set: + query.max-memory: "3GB" + remove: + - an.optional.property + jvm.config: + lines: + - "-Xmx1024m" + custom.conf: + text: "" + catalog/unused.properties: + remove: true +``` + +`properties.replace: {}`、`lines: []` 和 `text: ""` 分别产生对应类型的空文件;`remove: true` 删除文件。 + +#### 4.3 文件物化与运行期绑定 + +文件标识由目录与相对路径组成,ConfigMap 存储键是交付细节。框架验证越界、冲突、重复生产和目录访问, +通过版本化计划及 init helper 写入文件。主进程身份、共享组和 helper 身份显式提供,不根据镜像猜测权限。 + +属性值是 Literal 或明确的运行期绑定,不能同时存在;首批运行期绑定是 PodName。 +覆盖触及键/文件时取消对应旧绑定,运行期值作为编码数据写入,不拼进 shell 命令。 +纯生成时 PropertyCodec 可以由产品实现;跨进程物化只支持交付 helper 明确实现的 codec, +首批为 PropertiesCodec,不承诺任意 Go 编码器能在 Pod 中执行。 + +ConfigMap 更新不自动证明进程重载。文件配置送达由平台 restarter 合约完成,部署者通过 CR 标签选择启用; +框架传递 workload 标签并保留 restarter 的 PodTemplate 注解,不自行写 restarter stamp。 +env/CLI/Pod 覆盖改变模板后由 StatefulSet 控制器滚动。两种路径必须分别验收实际进程消费。 + +#### 4.4 日志是产品语法与平台采集的协作 + +产品从有效 logging 配置生成自己的原生格式,并声明确实会产生的日志文件。 +无法表达的阈值应返回明确错误,不能静默改成最接近的值。每个产品须明确其原生日志支持范围; +Trino 的 sink 和级别限制见[示例说明](../examples/trino-operator/README.md#native-trino-logging-and-process-identity)。 + +框架统一消费有效 `enableVectorAgent`:开启时采集已声明的日志文件,关闭时不装配采集器。 +产品只声明实际日志产出,LogOutput 不携带采集开关;该开关由框架统一消费。 +未指定集中目的地时,Vector 输出到 stdout JSON。当前 `clusterConfig.vectorAgentConfigMap` 已由框架 +通过精确只读 Get 解析同 namespace ConfigMap 的 ADDRESS;仅开启采集且有实际文件时读取该依赖。 +解析后的地址进入原生 Vector sink 和 Pod 模板,引用缺失为 Pending,地址更新触发模板收敛。 +最终文件、挂载和 Vector 路径仍独立检查,产品关系 Unknown 不得屏蔽确定的采集冲突。 + +### 5. 外部事实、来源与诊断 + +Facts resolver 位于产品适配包,使用框架提供的只读 `FactsReader.Get`,不得获得写客户端。 +它收到有效组配置、集群配置、基础 F 和声明拓扑,返回 resolved、pending、invalid 或 readError。 +未解析的组不生成/应用新计划;无关组、角色资源和明确退休仍可推进。 +共享输出能看到每组结果,但“已生成端点”不能当作“已经可访问”。 + +框架按完整 GVK/namespace/name 缓存本轮 Get,记录 UID/resourceVersion,保证本轮同引用的一致观察; +记录不包含外部对象内容。UID/RV 表示读取来源,不表示文件已送达或业务已加载。 +注册基础 F 与各组解析 F 分离,禁止把某个 CR 的事实存到进程级定义中。 + +使用定时刷新,不承诺动态依赖 watch。默认周期为 30 秒,包括没有产品 resolver 的配置; +pending 最早按 `min(刷新间隔, 2秒)` 重试,错误退避上限不得阻断该刷新。 +自定义间隔为正数,零使用默认。这些是调度上限,不是 API 阻塞、队列积压下的 wall-clock SLA。 + +最终关系检查 `Check` 固定三态:consistent、conflict、unknown。consistent 证明已知结构关系; +conflict 阻止对应组应用;unknown 保留诊断且不隐式改写结果,不代表业务健康。 +status 的条件类型、三态值和事实状态是机器契约;Subject/Reason/Message 供定位,未声明为枚举的文本不得用作稳定解析接口。 +诊断应带组/角色身份、输入通道或检查对象;不把外部 Secret 内容、整个 facts 或完整有效配置复制到 status。 +首批不承诺逐字段来源图或完整 explain/dry-run 公共 API。 + +### 6. 控制器拥有执行责任 + +#### 6.1 资源与所有权 + +框架自行构造直接 API 客户端,从当前观察执行判断、冲突重试和写入;manager 的缓存用于 watch 调度, +不能替代修改意图、所有权和保留卷的即时读取。多资源操作不是事务,已发出的请求无法取消。 + +固定资源槽由 CR owner UID、规范化槽位记录、名称及框架管理元数据共同识别,不能仅凭 label 接管或删除对象。 +apply 前检查身份、来源、不可变字段和存储约束;不可实现的声明报错,不能保留旧值后报告成功。 +更新保留 API 分配值与外部控制器管理的元数据,框架自己的字段按期望收敛。 +使用 server dry-run 规范化默认值后比较,稳态不发持久化的无变化更新;这不意味着零 API 请求。 + +角色 PDB 独立于组构建成功与否,产品显式选择启用;按全部声明副本计算 +`minAvailable = max(0, sum(replicas) - maxUnavailable)`,覆盖该角色全部组。 +停止或 facts pending 不减少声明预算;PDB 不提供缩容、直接删除或业务排空保证。 + +共享 ConfigMap 也需要可识别的完整期望集合与同源撤回清理,不能只 apply 新输出而永久遗留旧输出。 +GenerateCluster 必须返回有明确状态的共享输出结果:Ready 携带完整期望集合,空集合表示撤回全部; +Pending 携带原因并保留旧输出,不得同时提交部分输出;未声明状态无效。返回 error 同样保留旧输出并报告失败。 +没有注册可选 GenerateCluster 回调表示完整空集合,可回收此前可信的共享槽位。 +共享输出的状态必须显式表达,不能用 nil 切片同时表示等待与撤回。 + +#### 6.2 暂停、停止与恢复 + +暂停在完整投影、配置校验、facts 和资源读写前判断,仅允许报告 Paused 与当前顶层观察代次; +此前执行条件及组/角色观察保持原代次。稳定暂停不写 status、不轮询,不表示 Kubernetes GC 或其他控制器暂停。 + +停止独立扫描可信的 live 固定槽位,使已有 StatefulSet 降为 0,即使当前 image/产品配置/facts 错误。 +声明 replicas、拓扑和 PDB 不变,计划另持有执行副本 0;恢复使用最新 CR 声明。 +停止本身不删除资源,明确移除的组仍走退休。配置失败组没有新的执行计划,不能伪造已应用状态。 + +Stopped 需要当前 StatefulSet 代次已观察、status.replicas/readyReplicas/updatedReplicas 为零、 +实际 Pod 消失并复读 StatefulSet 的 UID/resourceVersion; +清单不完整则 Unknown,不能以空列表得出全停成功。Stopped 与 Applied 独立;停止期间 WorkloadsReady 不声称业务就绪。 +每轮发请求及冲突重试前重查 CR UID、generation、deletion 和运行控制;已发请求与检查后的竞争窗口不作原子保证。 +首批守卫不把任意 metadata-only 更新当作撤销整轮的事务屏障,元数据在后续协调收敛。 + +#### 6.3 退休、删除和保留数据 + +组退出期望清单后,控制器从 live 来源重建退休工作,不依赖 status 账本或进程内历史。 +按缩容到 0、确认控制器和实际 Pod 排空、逐项删除并确认固定槽位消失推进;不强杀 Pod 或移除 finalizer。 +排空中重加按最新声明恢复;旧对象已 Terminating 时等待删除完成再创建。 +CR 删除依赖 owner-reference GC;首批不提供产品 finalizer 清理协议,不能把组退休保证扩展到 CR 删除。 + +保留数据只支持一个明确的 RWO/Filesystem 槽位,StatefulSet 的 whenDeleted/whenScaled 均为 Retain, +StorageClass 及实际 PV 的 reclaimPolicy 也必须为 Retain。 +在 apply/重试/停止/退休前核对 StorageClass、claim、PV 绑定、来源记录和实际消费者,包括缩容后的高序号 claim。 +合法首次创建及未绑定 PVC 可以先创建消费者,以支持 WaitForFirstConsumer;实际 PV 出现后再核验绑定与策略, +不能把已有 PV 验证成功作为首次创建 Pod 的前提。 +产品协调器不删除 PVC/PV;同 CR UID 重加只能复用仍存活、可验证的原卷。 +E05 的独立 DataAsset 自动记录已确认绑定,在原 claim 丢失时拒绝当作首次创建。 +跨 CR 接管、容量/类迁移和销毁通过独立 DataOperation 与独立 executor 执行; +批准内容绑定实际数据和集群 UID,每个阶段复核来源、暂停与真实消费者排空。 +迁移必须实际复制并校验,销毁必须实际清空并等待后端 provisioner 回收,不能只删除 Retain PV 对象。 +数据身份、旧副本、授权、恢复阶段与历史的完整契约见 [数据操作协议](architecture.md#framework-data-operations)。 +这些文件系统操作不提供产品级一致性或备份恢复保证。 + +#### 6.4 状态与可验证程度 + +条件包括 Built、Applied、WorkloadsReady、PlatformReady、RoleResourcesApplied、Retired、Paused、Stopped; +组状态区分 declared desiredReplicas、可选 executionReplicas、readyReplicas、检查和事实观察。 +条件观察代次必须与产生它的执行轮次对应,暂停时不得把旧成功条件重新盖成当前代次。 +完整可信身份清单取得后,组级配置、facts、构建和应用失败不能阻断其他组; +身份非法、名称碰撞或完整清单不可取得属于整体输入失败,可以阻断依赖该清单的构建和回收。 +状态写入也需避免无变化循环。 +WorkloadsReady 描述工作负载观察,不替代产品服务健康或查询成功;Applied 也只证明资源应用阶段。 + +### 7. Go 包与生成代码的边界 + +使用现有 Go module,不增加 `/v2` 或独立 runtime module。公开与内部包按下表划分职责。 + +| 包 | 公开程度 | 拥有的责任 | +| --- | --- | --- | +| `pkg/framework` | 产品作者使用 | 三个对象、Config/运行描述领域值、FactsReader/FactInput/FactResult、Check/status 数据、平台装配选项 | +| `pkg/framework/input` | 生成代码契约 | presence 基础类型、原始 CR Projection、类型明确的绑定以及严格解码/复制辅助;不包含有效配置或资源计划 | +| `pkg/framework/operator` | 部署入口 | Options 和供生成 registration 调用的注册函数;隐藏可变 Reconciler | +| `pkg/framework/inputgen` | 开发工具库 | 输入/schema/投影/注册生成;工具依赖不进入运行期输入包 | +| `internal/framework/pipeline` | SDK 内部 | Source/Prepared/Plan、折叠、覆盖、平台组合、物化计划、装配、最终检查 | +| `internal/framework/controller` | SDK 内部 | API/facts 观察、apply/status、运行控制、退休、保留卷检查 | + +`framework` 是叶级领域契约,可引用 Kubernetes 数据类型与必要的只读接口,不依赖本表其他包或产品代码。 +`input` 只向 `framework` 依赖;pipeline 使用二者;controller 使用它们及 pipeline;operator 连接到 controller。 +inputgen 依赖领域与输入描述,不依赖 controller。`internal` 放在 module 根下,使仓库工具能合法使用内部构建/物化能力, +无需为了 render 或 helper 人为开放 Plan 公共 API。 + +产品生成的 API 包依赖 framework/input 和状态数据,不依赖 operator/controller。 +其独立 registration 子包引用生成 API、原始 C/S 产品类型和 framework/operator,固定 C/S,只留下 F 泛型。 +产品定义包不能反向导入生成 API/registration;产品事实适配器也不能放入通用 controller 包。 + +外部 operator 的生成代码无法导入本 module 的 internal,因此必须有薄的公开生成代码契约。 +Projection 只携带原始 image、clusterConfig、角色/组输入及完整身份清单,不含 F、有效配置或计划。 +正式投影保留嵌套 Roles/Groups,每层持有自己的配置/overrides 和可选 replicas,不提前折叠副本或复制角色层到每组。 +controller 将注册的基础 F 与 Projection 组合为内部 Source;公共 API 不暴露 SourceSnapshot 或构建阶段编排。 +固定运行控制通过独立 `Operation(cr)` 读取,不能为了统一投影而重新让暂停依赖完整 Snapshot 成功。 +生成绑定还提供对象构造、scheme 注册和 status 访问;任何需要外部生成包实现的函数/类型都必须可公开引用, +不能使用含内部返回类型或不可从外部实现的私有方法来“隐藏”它。 + +生成代码契约可导入,但不是普通产品手工接线接口。它和 inputgen 一起交付,具有显式的生成契约版本; +生成代码记录该版本,生成检查和注册校验拒绝不兼容版本。不同 SDK 发布版本可以共享兼容的生成契约版本, +不能声称仅凭编译通过就证明 SDK 版本完全相同。无公共合并引擎、阶段注册表或事后资源修改钩子。 + +### 8. 注册与交付 + +普通入口是生成的 `registration.Register(manager, definition, options)`,只返回 error。 +Options 包含基础 F、可选只读 resolver、AssemblyOptions 和刷新间隔;初始 AssemblyOptions 仅声明 +物化/Vector 镜像及 helper 身份,不构建尚无消费者的通用能力插件体系。 + +注册验证配置类型、完整角色清单和绑定,复制部署数据,加入生成 API scheme,并注册使用直接客户端的控制器。 +注册不执行产品生成或业务值校验,不读取 CR,不安装 CRD/RBAC,不启动 manager,也不是热更新接口。 +manager 配置和启动、CRD/RBAC/镜像部署属于 operator 的组合入口及交付清单。 + +正式 SDK 不得导入本地实验原型或具体产品代码;新框架的执行路径不得借旧 GenericReconciler +和 hook 机制恢复另一套配置语义。发布路径不得依赖本地过程记录。 +参考 operator 必须真实使用新包和生成注册,不以在示例旁新增未被启动的演示代码算作落地。 +helper 计划版本、镜像和 SDK 的兼容关系、生成一致性、RBAC 及部署说明一起交付。 + + + +## 标准数据存储 + +标准输入是 `config.resources.storage`,与 CPU、内存并列。产品运行描述用 `Directory.Data: true` +标记一个数据目录,并通过 `Main.Access` 声明产品实际使用的路径。产品无需重新构造 StorageClass、容量或 PVC。 +未声明 Data、Secret 或 Listener 来源的目录为临时目录。配置文件和日志必须使用独立临时目录,数据目录必须有主进程可写访问。 + +```yaml +workers: + config: + resources: + storage: + type: persistent + storageClassName: retained + capacity: 32Gi + roleGroups: + default: + config: + resources: + storage: + capacity: 64Gi +``` + +- 类型是 `ephemeral` 或 `persistent`;产品零值默认解析为 ephemeral,API 不填充默认值。 +- 默认值 → 角色 → 角色组。省略类型或保持同一类型时按字段继承;空对象继承。 +- 显式改变类型时先清除低层存储分支,再应用高层字段。切到 ephemeral 不携带旧 class/capacity。 +- 每层检查字段形状与互斥关系;最终持久类型必须有有效的显式 StorageClass 和正容量。 + 用户层 `type: ephemeral` 不能同时指定 class/capacity;null 不是删除或继承动作。 +- persistent 必须有 `Data` 消费者,不能接受输入后不生成卷。该单元支持一个 RWO/Filesystem 数据槽。 +- persistent 装配为 claim template,scale/delete 均 Retain。控制器继续要求实际 StorageClass 显式 Retain, + 验证来源、绑定、现有消费者;最终 podOverrides 不能挪走或遮蔽已声明的保留数据挂载。 +- **继承中的分支切换不迁移已有数据。** 现有 StatefulSet 的存储声明改变会报错;组删除后还有保留卷时, + 改成 ephemeral 也会被来源检查拒绝。跨身份迁移和销毁必须使用独立授权的 [DataOperation 流程](architecture.md#framework-data-operations)。 + +产品通过 Data 标记声明数据目录。卷身份和字节保持不等于业务数据恢复;产品须验证自己的恢复语义。 + + + + +## 平台依赖与运行结果 + +产品仍只声明输入、解析事实、生成运行描述。平台依赖按生命周期拆分:创建前已有的引用必须先解析;只有 Pod 启动后才出现的地址和 CSI 绑定,在应用工作负载之后观察。两者各自保留状态和刷新记录,不能用同一个 Pending 阻断所有阶段。 + +### 输入和调用链 + +生成的 `spec.clusterConfig` 平铺三部分:独立运行控制、`framework.ClusterConfig` 和产品 S。框架公共部分包含 `authentication` 与 `vectorAgentConfigMap`。字段冲突在定义和生成时拒绝;公共字段不会混入产品 S 的反序列化。`EffectiveInput.Platform` 和 `FactInput.Platform` 提供公共平台输入,产品字段继续通过 `ClusterConfig` 访问。 + +内部执行顺序: + +1. 严格输入、公共/产品配置继承和完整拓扑准备。 +2. 产品 `ResolveFacts` 通过精确只读 Get 解析已有外部对象;缺失/无效事实只保留对应组的旧资源。 +3. 每组产品生成一次运行描述;框架按真实 LogOutputs 决定是否解析集中日志目的地。 +4. 纯装配、四通道覆盖、最终消费关系检查。 +5. 对声明的 Secret/Listener 来源和最终 Pod 的 Secret 环境变量引用进行创建前核对,再应用资源。 +6. 从当前 StatefulSet 和 Pod 观察 CSI 绑定及 Listener 地址,刷新组的 `PlatformObservation`。 +7. 使用新的组观察生成共享输出。Pending 保留上次有效输出,不撤回生产结果的 Pod。 + +`GeneratePreparedGroups` / `AssemblePreparedGroups` / `RefreshClusterOutput` 属于内部管线。它们不是产品可编排的阶段接口,也没有新增事后任意资源修改钩子。 + +### 平台目录 + +`Directory.Secret` 和 `Directory.Listener` 声明目录的来源;同一目录不能同时是 Data、Secret、Listener。平台目录不是框架生成文件或日志的写入目标,必须有主进程或初始化进程的只读访问。 + +| 声明 | 实际装配 | 结果观察 | +| --- | --- | --- | +| `SecretVolume.SecretName` | 当前 namespace 的原生 Secret 卷,文件 mode 0440 | 当前生产者 Pod 就绪 | +| `SecretVolume.SecretClass` | `secrets.kubedoop.dev` 通用临时 PVC,原生 class/format/scope/Kerberos service 注解 | 当前 Pod 的 PVC/PV 身份与挂载就绪 | +| `ListenerVolume.Class` | `listeners.kubedoop.dev` 通用临时 PVC,class 注解 | CSI 创建的 Listener 属于当前 PV,读取其实际地址/端口 | +| `ListenerVolume.Name` | 同 namespace 现有 Listener 的通用临时 PVC,listenerName 注解 | 当前绑定、现有 Listener 的实际结果 | + +SecretName 与 SecretClass、Listener Class 与 Name 各自互斥。框架不把凭据字节写进生成 ConfigMap、状态或模板注解。 +SecretClass 的凭据生成、证书内容/有效期及 CSI 挂载属于平台组件;产品负责将挂载文件转换成自身原生认证配置。 + +最终 podOverrides 仍最后执行。它可以调整未受约束的原生字段;若移除、替换、使用 subPath 或嵌套挂载遮蔽已声明的平台目录,会得到明确的消费关系冲突,不能静默启动一个失去原配置来源的产品。 + +### 观察与刷新 + +`status.groups[].facts` 描述创建前产品事实及按实际日志声明解析的集中目的地事实;`status.groups[].platform` 描述平台准备或后置观察,包含 phase、diagnostic、观测对象 UID/RV 和 Listener 地址。`PlatformReady` 是单独条件,不能替代 WorkloadsReady 或业务查询成功。 + +后置读取使用新的一轮精确读取缓存。它检查 StatefulSet 当前 revision、Pod owner 和 Ready、通用临时 PVC 的 Pod owner、PV 的 claim UID,以及自动 Listener 的 PV owner。任何缺失结果为 Pending;身份冲突/API 错误保留错误诊断。未全部就绪时不发布局部地址清单,不把旧 PV 的 Listener 当作新实例的结果。 + +原生 Secret 的 UID/resourceVersion、SecretClass/ListenerClass/已指定 Listener 的 UID/generation 构成不含值的模板摘要。Secret 更新会使使用其文件或最终 SecretKeyRef/EnvFrom 的 Pod 替换;可选且缺失的 Secret 不阻止创建,后续出现会刷新。类或 Listener 的纯 status 更新不会导致无休止滚动。地址本身在下一次观察中刷新共享 ConfigMap,不要求 CR 编辑或 Pod 替换。 + +有无自定义 resolver 都默认每 30 秒刷新,Pending 使用较短的 2 秒周期;错误的单 key 退避同样有界。该间隔是队列调度上界,不是 API 故障或队列拥塞时的墙钟保证。暂停仍优先阻止执行;停止继续使用独立的工作负载排空路径。 + + + + +## S3 连接领域 + +S3 是一个有明确语义的连接领域。产品在自身配置中嵌入 `framework.S3Connection`,框架识别这个确定的类型、处理角色继承和分支切换,产品使用解析后的连接事实生成原生配置。不引入字段级 atomic 标签、可注册 union 引擎或“任何对象都是原子”的合并开关。 + +### 输入、继承与解析 + +`S3Connection` 的 type 为 `disabled`、`inline` 或 `reference`;产品零值默认等价于 disabled。inline 提供 host、port、tls、region、pathStyle 和 credentials;reference 指向同 namespace 的 `s3.kubedoop.dev/v1alpha1 S3Connection`。 + +默认值 → 角色 → 角色组。同一 type 或省略 type 时按字段继承;显式切换 type 清除全部旧分支,然后应用新分支。角色组可以只改变 pathStyle 并继承同分支其余字段;切换到 reference 时不携带旧 inline 端点和凭据。disabled 清除整条连接。每层拒绝跨分支字段,完整值在折叠结束后验证。该确定领域可嵌套于产品 struct/map,序列仍遵循整体替换规则。 + +引用缺失/删除中为 Pending,API 读取失败保留为读取错误,非法端点和不支持的 TLS 配置为 Invalid。通过 FactsReader 精确读取,框架记录 UID/resourceVersion 并刷新;事实包含 endpoint、region、pathStyle 和凭据**引用**,不读取或返回凭据字节。 + +host 必须是 DNS/IP;未指定 port 时 HTTP 为 80、HTTPS 为 443;未指定 region 时为 us-east-1。当前支持经过系统 CA 验证的 HTTPS,不接受组织 S3Connection 中 `verification.none` 或尚未有消费者的自定义 CA 声明。 + +### 凭据与运行期边界 + +inline.credentials 必须显式选择 native `secretName` 或 `secretClass`,只有后者可设置 scope。reference 读取组织 S3Connection 的 credentials.secretClass 及 node/pod/service/listener-volume scope。两条路径都声明包含 `ACCESS_KEY`、`SECRET_KEY` 的只读运行目录: + +- native Secret 使用 Kubernetes 原生卷; +- SecretClass 使用 generic ephemeral 卷,经 Pod 所属 PVC 绑定到真实 secret-operator CSI PV;创建前验证声明,创建后由平台观察挂载结果。 + +端点解析不等待 CSI 生成的秘密字节,因此不会形成“必须先有 Pod 的结果才能创建 Pod”的依赖环。secretName 与 secretClass 不同时选择,也不退回未声明的默认凭据链。 + +产品须在自己的进程中读取凭据文件并按原生配置机制消费;init 容器不能向主容器导出环境变量。 +固定启动命令与可覆盖的 Args 必须分离,避免环境准备过程破坏 CLI 覆盖契约。 +凭据不得写入 ConfigMap、状态、facts 或启动参数。Trino 的具体属性及 launcher 说明见[产品指南](../examples/trino-operator/README.md#platform-domains)。 + + + + +## 日志配置与集中送达 + +本领域保持两种责任:产品把有效 `logging` 翻译成其进程真正读取的原生配置,并声明实际产生的日志文件;框架决定是否装配 Vector、解析集中目的地并连接所有声明文件。产品不重复合并日志输入,也不构造 Vector 容器。 + +### 集中目的地契约 + +标准 `spec.clusterConfig.vectorAgentConfigMap` 是当前 CR namespace 中的 ConfigMap 名称。该 ConfigMap 的 `data.ADDRESS` 是单个 DNS/IP 与端口,例如: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: log-destination +data: + ADDRESS: vector-aggregator.observability.svc:6000 +--- +# Cluster CR 的 spec 片段 +clusterConfig: + vectorAgentConfigMap: log-destination +workers: + config: + logging: + enableVectorAgent: true +``` + +`ADDRESS` 不是任意 Vector YAML、URL 或凭据。框架验证 DNS/IP 和 1–65535 端口,JSON/YAML 编码仍由编码器负责。使用 [Vector 原生 sink](https://vector.dev/docs/reference/configuration/sinks/vector/) 连接对应的 Vector source。目的地未指定时保留文件到 stdout JSON 的消费路径。 + +`ResolveVectorDestination` 使用只读 `FactsReader` 精确读取。缺失引用为 `Pending / VectorDestinationMissing`;缺失或无效 ADDRESS 为 `Invalid / InvalidVectorDestination`;API 读取失败仍为读取错误。控制器负责记录实际 UID/resourceVersion、定期重新读取和按组隔离。已禁用 Vector 或没有实际 LogOutputs(例如 file OFF)的组不因该引用等待。产品生成只调用一次,控制器在拿到真实日志声明后解析目的地,再进入纯装配。解析成功仅表示地址配置已得到验证,不表示远端正在接收。 + +解析后的 `AssemblyOptions.VectorDestination` 只传入当前组装配。生成的 `vector.yaml` 使用该地址;同时地址进入框架 Vector 容器的 `FRAMEWORK_VECTOR_DESTINATION` 环境变量,作为 PodTemplate 的配置变更触发值。因此只更新引用 ConfigMap 的 ADDRESS,就会更新物化配置并替换运行中的采集器,无需修改 CR,也无需依赖另一个 restarter 才交付这个目的地变更。其他配置文件的变更仍遵循既有 restarter 契约。 + +文件覆盖与最终 Pod 覆盖仍执行既有规则。更改采集器命令或生成配置会失去框架的结构关系证明,不能把这种未知关系报告为送达保证。网络故障时的缓冲/重试由 Vector 负责;当前 Vector 数据目录为临时卷,本领域不提供日志的永久保存或恰好一次送达承诺。 + +### 有原生消费者的适配器 + +Trino 的原生日志约束见[产品指南](../examples/trino-operator/README.md#native-trino-logging-and-process-identity)。 + +`pkg/framework/logging.Python` 接收有效 `framework.ContainerLogging`,返回 Python 标准库 `logging.config.dictConfig` 可直接加载的 JSON: + +- console/file handler 各自消费阈值,不为了最低共同配置而强行令二者相等; +- ROOT 与命名 logger 使用 Python 自身的层级传播规则; +- TRACE/DEBUG/INFO/WARN/ERROR/FATAL 映射为 5/10/20/30/40/50,OFF 关闭对应 handler; +- file 使用标准库 RotatingFileHandler,10 MiB、3 份备份;产品提供实际绝对文件路径和可写目录; +- 产品显式加载 JSON,并只在 File.Level 非 OFF 时声明该文件为 LogOutput。 + +`TestPythonNativeConsumerThresholds` 启动实际 python3 进程加载生成文档,再观察 stdout 和实际日志文件。它验证文件 DEBUG、console WARN、命名 logger DEBUG 与 ROOT INFO 的共同作用,以及 file OFF 不创建文件。它不通过 Go 重实现 Python 过滤规则来模拟成功。 + +不列出只有编码函数、没有真实消费和执行证据的 Java 日志适配器支持矩阵。 + + +状态提交与 Pod 替换是不同的异步观察点。新 Pod Ready 不能替代同 generation 下新 ConfigMap UID/resourceVersion 的 resolved 事实记录。 + + + + +## 初始化与工作负载协调 + +### 产品声明与执行责任 + +`RuntimeDescription.Initializers` 声明有序的 `Process` 列表。有文件需要物化时,框架先装配物化器,再按声明顺序 +装配普通 init containers,最后启动主进程。每个初始化进程有自己的 image、command/args、env、 +身份和显式目录访问;没有隐式获得主容器所有挂载。未指定 image 时继承已解析的产品镜像。 +普通 init container 不能声明 lifecycle/probe。产品初始化必须幂等:kubelet 可以重新执行它。 +失败初始化不会启动主进程,也不能用一个历史完成标记绕过本次实际执行。 + +`Process.Lifecycle` 和 `StartupProbe`、`ReadinessProbe`、`LivenessProbe` 使用 Kubernetes 类型。 +框架将它们装配到主容器;产品选择真实协议和命令,kubelet 执行。它们不经过 file/env/CLI 覆盖; +最终 podOverrides 仍然最高。覆盖改变生命周期、初始化顺序、探针或退出预算时, +`assembly.lifecycle` 报告 Unknown,不能继续把原声明当成经过验证的消费前提。 + +### 可恢复的协调 + +`RuntimeDescription.Coordination` 包含 `ProgressDeadline` 和 `ShutdownPriority`。 +前者为 1 秒至 1 小时的无进展预算;后者越小越先退出,同优先级组独立推进。 +拥有初始化进程的工作负载必须声明协调预算。 + +- 框架显式使用 StatefulSet `OrderedReady` 和 `RollingUpdate`;Kubernetes 持久化并执行逐 Pod 滚动。 + 框架不再实现一套与 StatefulSet 竞争的副本控制器。 +- 普通 CR 缩容、停止和组退休共用 `nextScaleDown`。每次最多降低一个 ordinal;开始下一步前, + 必须直接读取实际 Pod,确认上一 ordinal 消失并核对当前 StatefulSet UID。 +- 全集群停止与多组退休按已验证 live 工作负载上的优先级执行。较低优先级仍 Pending/失败时, + 不开始较高优先级。停止时的普通 apply 不能跳过这个顺序,把所有 StatefulSet 一次设为零。 +- `framework.kubedoop.dev/workload-coordination` 保存当前产品策略;`workload-progress` 保存版本、 + StatefulSet UID、目标摘要、进展摘要和起始时间。控制器重启后从这些 live 信息继续,status 丢失不重置预算。 +- 进展只计副本/版本、当前 Pod UID/phase/终止状态、成功完成的初始化阶段。 + resourceVersion、失败重试计数和经过时间不构成进展。 +- 超时报告失败并保留工作负载;不强删 Pod、不删除数据、不把超时改写为业务退出成功。 + 实际进展或新的目标重新建立预算,已经恢复的工作负载清除过期进展记录。 + +停止完成仍要求目标副本为零、当前 StatefulSet 控制器观察和实际 Pod 全部消失。 +数据操作不把 Stopped 条件当作数据操作授权或静止证明;独立 executor 要求相关 StatefulSet 已退休、 +仍存在的相关 CR 已暂停,并直接读取源/目标 PVC 消费者。停止完成不等于产品事务提交或磁盘内容正确。 + +协调策略必须跨越最终资源克隆和 apply 边界。资源克隆先保留完整值,再深拷贝引用字段。 +即使状态计数已经为零,只要前序工作负载的实际 Pod 尚在,后续优先级仍须等待。 + +当前保证覆盖框架发起的缩容、stopped、组退休,以及原生 StatefulSet 滚动和 kubelet lifecycle。 +CR 删除仍由 Kubernetes owner-reference GC 执行,不保证跨角色停止顺序;管理员强删、节点断电、 +进程 OOM、跨组零中断、业务数据升级/回滚不在此协调协议的保证范围。 +产品原生退出、查询结果和主进程终态需要分别验证,具体 Trino 行为见[产品指南](../examples/trino-operator/README.md)。 + + + + +## 数据身份与显式数据操作 + +框架使用独立的 `DataAsset` 和 `DataOperation`,产品 CR 的退休或删除不再同时抹去数据历史。 +正常协调在确认 PVC/PV 双向绑定之后自动创建账本;停止和退休的共享检查只读取账本,不创建资源。 +账本记录原始 PVC/PV UID、实际源 CR 的 GVK/name/UID、角色/组/槽位、StorageClass 和规范容量。 +这些是实际观察到的身份,不能从名称或用户提供的标记推断出来。 + +### 责任与调用链 + +```text +产品正常协调 → 检查 Retain/来源/双向绑定 → 创建 DataAsset → 在 PVC 记录 asset 名称 +产品停止/退休/重新创建 → 读取账本 → 拒绝身份丢失、操作锁、已转移数据的隐式重建 + +授权者创建不可变 DataOperation → 独立 executor → 持久阶段/Job → 重新验证来源与绑定 + → 写入目标 retained-data / retained-binding → 写历史并解除 asset 锁 + → 产品正常协调消费已授权目标 PVC +``` + +`DataAsset` 没有产品 CR 的 owner reference;历史与当前身份留在独立 CR 中。 +账本是 namespaced 资源,生命周期独立于产品 CR,不承诺抵抗命名空间本身被删除。 +迁移的旧副本进入 `status.retiredCopies`,仍可通过准确身份发起独立销毁操作。 +迁移不会顺便删除源数据,销毁旧副本也不会删除当前数据。 + +部署分为两个权限域。产品 operator 只有账本 get/list/watch/create 和既有 PVC 收据权限; +独立 `cmd/dataops` executor 才有执行 Job、重绑和回收卷的权限。 +`framework-data-authorizer` 可以创建操作,没有修改执行状态、PV 或数据账本的权限。 +CRD/平台 RBAC 位于 `config/framework-data`,独立 executor 部署位于 `config/framework-data-executor`;CRD 和 DeepCopy 由根 Makefile 生成。 + +### 授权前置条件 + +操作输入、approval 与 RBAC 的区别、不可变请求、源/目标身份、暂停、退休及实际消费者检查, +统一见[数据操作安全契约](security.md#framework-data-authorization)。这些条件在开始和每个执行阶段都必须满足。 + +### 三条实际执行路径 + +| 动作 | 持久阶段与实际行为 | 完成证据 | +| --- | --- | --- | +| adopt | Locked → 创建目标名 PVC → ReleaseSource → Rebind → BindTarget → Record | 原 PV UID 保持;新 PVC UID、目标双向绑定与正式框架来源收据 | +| migrate | Locked → 创建目标 PVC → Copy → BindTarget → Record | 源复制前、源复制后、目标三个 SHA256 文件树一致;目标绑定完整;旧副本留账 | +| destroy | Locked → Erase → DeleteClaim → DeleteVolume → ReclaimVolume → Record | worker 确认目录为空;按 UID/RV 删除 PVC;PV 记录 operation UID 后 Retain→Delete;实际 provisioner 回收后端并删除 PV | + +复制 worker 在源只读挂载下复制普通文件、目录、符号链接,并核对文件内容、长度、权限和链接目标。 +迁移对象是卷根之下的数据树;卷挂载根目录仍由 provisioner 管理,worker 不复制或改变其权限、 +时间戳等元数据。执行保持审批绑定的非 root UID/GID,以 fsGroup 提供的数据写权限完成复制。 +特殊文件会报错;不会把复制成功扩张为 POSIX ACL、数据库日志恢复或存储快照一致性保证。 +目标由本操作新建,只有属于同一 operation UID 的半成品目录允许重试;已有未知数据的目标拒绝覆盖。 + +销毁是在已批准的文件系统上删除并核验文件,随后要求实际存储 provisioner 回收卷。 +它不承诺介质级擦除或清除供应商独立快照。改变 reclaimPolicy 前必须已有持久的擦空收据; +仅删除 Retain PV 对象会遗留后端存储,因此这里不把该动作当作销毁完成。 +只有已持久化 `ReclaimVolume` 阶段才接受 PV 消失作为回收完成;此前丢失 PV 保留锁并报告完成状态未知。 +这也覆盖写入 Delete 策略成功、但保存阶段失败且 PV 随即消失的窗口,避免猜测后端回收结果。 + +### 重启、失败和证据 + +每轮将 phase、Job UID、attempt、worker termination receipt 写入 DataOperation status。 +worker receipt 包含 operation UID、校验类型与迁移文件树摘要,先持久化再删除已完成的 worker Pod。 +Job 保留;controller 重启从已保存阶段继续,不重复创建已存在目标或凭空替代丢失身份。 +检查固定 worker 同时覆盖 Pod 与容器的执行身份和容器安全限制,防止容器配置覆盖批准的非 root UID/GID。 +完成记录以 operation UID 去重;账本已更新但 operation status 写失败时可以继续完成。 + +worker 的执行有 30 分钟上限。失败的 Job、Pod 和原因保留,asset 保持锁定。 +修复原因并检查/移除失败 Pod 后,授权者设置 `framework.kubedoop.dev/data-retry` 为下一整数尝试号。 +下一 Job 使用新 attempt 名称,旧失败 Job 保留,审批意图保持不变。 +身份变化、未知消费者或来源错误不会被重试参数绕过;删除正在执行的操作会暂停后续动作并保留锁, +不自动把“请求删除”解释成恢复源数据或继续销毁的授权。 + + + + +# Existing GenericReconciler SDK + +The following numbered sections describe the existing GenericReconciler API only. +They remain available for its maintenance and do not override the product-description framework above. # 1. Document Overview diff --git a/docs/security.md b/docs/security.md index 52389f9d..b904c9ce 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,7 +1,133 @@ # Operator-Go Security Architecture +The sections below define the security boundaries of `pkg/framework`. The numbered +sections beginning at **1. Overview** document the existing `GenericReconciler`, +provisioner and sidecar SDK APIs only; their API names, RBAC tables and automatic +workload identity rules do not describe the new framework. In particular, the old +provisioner's annotation-only SecretClass behavior does not prohibit the framework +from reading platform source objects before creating a workload. The complete +framework execution contract is in [the architecture](architecture.md#framework-design). + + +## Framework authentication and secret consumption + +Standard `clusterConfig.authentication` references cluster-scoped +AuthenticationClass objects. `framework.ResolveAuthenticationClass` uses exact, +read-only `Get`: missing or deleting objects are Pending, an invalid declaration is +Invalid, and an API read failure remains a read error. Exactly one provider branch +is accepted. Static, OIDC, TLS, LDAP and Kerberos are distinguishable typed inputs; +a product must explicitly implement the providers it accepts. A resolved reference +does not establish provider reachability, user authentication or product authorization. +Secret bytes do not enter the returned provider facts. + +The product owns native authentication configuration and the process that consumes +it. The framework owns the declared sources, read-only mount assembly and source +identity checks. `Directory.Secret` chooses exactly one native Secret in the CR's +namespace or cluster-scoped SecretClass. Native files have mode 0440; SecretClass files are produced +by the platform through a generic ephemeral PVC. The product must declare a real +main or initialization process with read-only access. A platform directory cannot +also be Data, Listener, generated configuration or writable log output. Final Pod +patches cannot remove, replace, redirect through subPath or shadow a declared mount +and still pass its consumption checks. + +Read-only fact adapters receive `FactsReader`, not a writing client. Framework +source checks may read native Secrets to validate required keys and observe their +identities, but secret contents must not be copied into facts, generated ConfigMaps, +status, diagnostic messages, command arguments or template annotations. Secret +references and runtime-mounted files are distinct from the credential values. The +framework uses exact direct reads for these dependencies; the cache-backed read +RBAC advice in the legacy sections must not be applied as its call contract. + +Native Secret UID/resourceVersion contributes to a value-free Pod template digest, +including final SecretKeyRef and EnvFrom consumers. Class and explicitly named +Listener sources use UID/generation so status-only changes do not cause endless +rollouts. Missing required inputs keep the previous workload and report Pending; +a partial set of resolved inputs cannot become the next execution facts. CSI +credential generation, certificate validity/rotation and actual mounts remain the +platform operators' responsibility. Source resolution is separate from subsequent +Pod/PVC/PV/Listener observation; see [platform identity and refresh](architecture.md#framework-platform). + +The Trino reference adapter accepts a single Static provider as native PASSWORD, +requires TLS and an internal shared identity, and rejects unsupported providers. +TLS and password files remain read-only sources; an initializer produces a private +0600 PEM file in an ephemeral directory. No insecure-HTTP authentication or trusted +forwarded-header setting is enabled implicitly. TLS can also be used independently. +With a Listener, certificate scope names `listener-volume=listener` so the published +endpoint can be covered by its SANs. These are the pinned Trino product's choices, +not universal authentication defaults. Configuration and native source references +are documented in the [Trino example](../examples/trino-operator/README.md#platform-domains). + +Client authentication does not grant worker shutdown permission. The product must +explicitly declare `shutdownUser` or `shutdownCredentialsSecret` and supply the +matching management authorization. The hook cannot treat a refused management +request as successful drain or silently enable insecure HTTP to make it work. +S3 credentials likewise use an explicit native Secret or SecretClass and a real +process consumer; no undeclared default credential chain is substituted. See the +[S3 contract](architecture.md#framework-s3) and +[lifecycle contract](architecture.md#framework-lifecycle). + + +## Framework data authorization + +Normal product reconciliation and destructive data execution are separate +permission domains. The product operator can observe/create DataAsset ledgers and +maintain its existing PVC receipts; it cannot execute data Jobs, rebind volumes or +reclaim their backends. The independently deployed `cmd/dataops` executor owns +those operations. The `framework-data-authorizer` role permits creation of +DataOperations, not mutation of execution status, PVs or data ledgers. CRD/RBAC and +executor installation are separate in `config/framework-data` and +`config/framework-data-executor`; installing the Trino operator does not deploy +the executor. + +An immutable DataOperation binds the action, asset name/UID, complete source data +identity, source cluster, target where applicable, and an explicit non-root worker +UID/GID. Source cluster identity must match the ledger; target cluster identity is +verified against its actual API UID and the claim name must match the declared +cluster/role/group/data-slot/ordinal. `dataops.Approval(spec)` hashes the stable Go +JSON encoding with approval cleared. **RBAC grants authorization; the digest only +binds the reviewed content.** CEL forbids spec edits and the executor additionally +retains `status.specDigest` to reject changed intent outside admission. + +At the start and at every execution stage, the executor requires: + +- The exact source CR UID is gone or its CR is explicitly paused. For adoption + or migration, the target CR exists with its approved UID and is explicitly paused. +- Relevant StatefulSets are retired and all actual source/target PVC consumer + Pods are absent. Pause or a Stopped condition alone proves neither requirement. +- Exact PVC/PV UIDs, mutual binding, provenance, safe owner references and Retain + policies still match the approved data. +- The asset lock still belongs to this operation UID. + +The worker runs fixed code, never a user-provided script; both Pod and container +security settings must preserve the approved non-root UID/GID. A retry cannot +bypass identity, provenance or consumer checks. Failed workers and their receipts +remain inspectable and the asset remains locked. After correcting the cause and +checking/removing the failed Pod, an authorized actor requests the next integer +`framework.kubedoop.dev/data-retry` attempt. Deleting an in-flight operation pauses +further actions and preserves the lock; it authorizes neither rollback nor +continued destruction. + +DataAsset has no product CR owner reference, so product deletion does not erase +its history; it remains namespaced and cannot survive namespace deletion by +contract. Platform Secret/Listener ephemeral PVCs are not retained product data: +only an exact final-volume match against typed runtime declarations can create a +controller-owned `platform-claims` receipt bound to CR UID/role/group. Products +and Pod overrides cannot forge it, and matching a StorageClass name alone grants +no exemption. DataOperation never adopts these platform volumes. + +Migration retains the source as a separate recorded copy. Destruction requires a +persisted erase receipt before changing reclaimPolicy and a persisted +`ReclaimVolume` stage before interpreting PV disappearance as completion. Missing +identity earlier leaves completion unknown and preserves the lock. Filesystem +copy verification is not database consistency or backup recovery, and deletion +is not media sanitization or removal of independent provider snapshots. The full +stage, receipt and recovery protocol belongs to +[data operations](architecture.md#framework-data-operations). + +--- + ## 1. Overview -This document outlines the security architecture integrated into the `operator-go` SDK. It adopts a defense-in-depth approach, split into two primary layers: +The following numbered sections describe the legacy `GenericReconciler` SDK security architecture. It adopts a defense-in-depth approach, split into two primary layers: 1. **Application Security**: Focused on safely injecting sensitive data (Secrets, Keys) into workloads. 2. **Infrastructure Security**: Focused on securing the Kubernetes execution environment (RBAC, Service Accounts, Pod Constraints). @@ -13,7 +139,7 @@ The core design philosophy is **"Zero-Touch Security"**. The Product Operator do ## 2.1 Core Concept: SecretClass -`SecretClass` is a resource managed by `secret-operator`. It defines "how" to obtain security artifacts, while the workload (Pod) simply declares "what" it needs by referencing a `SecretClass` **by name**. The CRD itself — its scope and schema — is owned by the `secret-operator`, not by this SDK; `operator-go` only emits the `secrets.kubedoop.dev/class: ` annotation and never reads the object. +`SecretClass` is a resource managed by `secret-operator`. It defines "how" to obtain security artifacts, while the workload (Pod) simply declares "what" it needs by referencing a `SecretClass` **by name**. The CRD itself — its scope and schema — is owned by the `secret-operator`, not by this SDK; the legacy `security.SecretProvisioner` only emits the `secrets.kubedoop.dev/class: ` annotation and never reads the object. The `pkg/framework` source-validation path described above separately reads the class. This mechanism is implemented using the **Kubernetes CSI (Container Storage Interface)**. The `secret-operator` provides a CSI driver that intercepts volume mount requests, generates or retrieves the required secrets on-the-fly, and injects them into the container file system as files. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 9e7f946d..6021f961 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,18 +1,7 @@ -# operator-go/examples - Example Operators +# Example operators -**Parent:** [../AGENTS.md](../AGENTS.md) -**Generated:** 2026-03-29 +Parent: [../AGENTS.md](../AGENTS.md). -Example operator implementations demonstrating framework usage patterns. +`trino-operator/` is a separate Go module with a local SDK replace. Its real executable uses the formal `pkg/framework` API: product C/S/F and Definition, generated input/CRD/registration, then public operator registration on a controller-runtime manager. See its [AGENTS.md](trino-operator/AGENTS.md) and [README.md](trino-operator/README.md). -## Key Directories - -| Directory | Purpose | -|-----------|---------| -| `trino-operator/` | Trino operator example | - -## Working Instructions - -1. **Creating a new example:** Add a new directory with complete operator implementation -2. **Structure:** Follow the pattern of `trino-operator/` with config, reconciler, and CRD definitions -3. **Documentation:** Include README with setup and usage instructions +Examples should demonstrate the public author path and complete deployable manifests. Product generation stays pure, external reads use FactsReader, and SDK pipeline/controller internals are not imported. Generated input artifacts and companions are checked by `make verify-generate`. Test-only runtime tools must remain separate from the product executable and must not be described as product query validation. diff --git a/examples/trino-operator/.github/workflows/lint.yml b/examples/trino-operator/.github/workflows/lint.yml deleted file mode 100644 index db57e64b..00000000 --- a/examples/trino-operator/.github/workflows/lint.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Lint - -on: - push: - pull_request: - -jobs: - lint: - name: Run on Ubuntu - runs-on: ubuntu-latest - steps: - - name: Clone the code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Run linter - uses: golangci/golangci-lint-action@v8 - with: - version: v2.8.0 diff --git a/examples/trino-operator/.github/workflows/test-e2e.yml b/examples/trino-operator/.github/workflows/test-e2e.yml deleted file mode 100644 index 4cdfb30e..00000000 --- a/examples/trino-operator/.github/workflows/test-e2e.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: E2E Tests - -on: - push: - pull_request: - -jobs: - test-e2e: - name: Run on Ubuntu - runs-on: ubuntu-latest - steps: - - name: Clone the code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install the latest version of kind - run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-$(go env GOARCH) - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind - - - name: Verify kind installation - run: kind version - - - name: Running Test e2e - run: | - go mod tidy - make test-e2e diff --git a/examples/trino-operator/.github/workflows/test.yml b/examples/trino-operator/.github/workflows/test.yml deleted file mode 100644 index fc2e80d3..00000000 --- a/examples/trino-operator/.github/workflows/test.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Tests - -on: - push: - pull_request: - -jobs: - test: - name: Run on Ubuntu - runs-on: ubuntu-latest - steps: - - name: Clone the code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Running Tests - run: | - go mod tidy - make test diff --git a/examples/trino-operator/.gitignore b/examples/trino-operator/.gitignore index 9f0f3a1c..691221cf 100644 --- a/examples/trino-operator/.gitignore +++ b/examples/trino-operator/.gitignore @@ -28,3 +28,5 @@ go.work # Kubeconfig might contain secrets *.kubeconfig + +dist/ diff --git a/examples/trino-operator/.golangci.yml b/examples/trino-operator/.golangci.yml index f966bb1e..a8e84c1f 100644 --- a/examples/trino-operator/.golangci.yml +++ b/examples/trino-operator/.golangci.yml @@ -22,12 +22,7 @@ linters: - unconvert - unparam - unused - - logcheck settings: - custom: - logcheck: - type: "module" - description: Checks Go logging calls for Kubernetes logging conventions. revive: rules: - name: comment-spacings diff --git a/examples/trino-operator/AGENTS.md b/examples/trino-operator/AGENTS.md index af57c2c4..9baea1f8 100644 --- a/examples/trino-operator/AGENTS.md +++ b/examples/trino-operator/AGENTS.md @@ -1,36 +1,54 @@ -# operator-go/examples/trino-operator - Trino Operator Example +# Trino reference operator -**Parent:** [../../AGENTS.md](../../AGENTS.md) -**Generated:** 2026-03-29 +Parent: [../AGENTS.md](../AGENTS.md). Design source: [../../docs/architecture.md#framework-design](../../docs/architecture.md#framework-design). -Complete Trino operator implementation demonstrating the operator-go framework with CRD definitions, reconciliation logic, and resource builders. +This nested Go module now uses the formal public `pkg/framework`, `pkg/framework/input`, `pkg/framework/inputgen`, and `pkg/framework/operator` packages. It has no legacy GenericReconciler/RoleProvider/extension/webhook path and must not import local discussion prototypes, SDK internal packages, or old execution packages. -## Key Directories +## Current files and responsibility -| Directory | Purpose | -|-----------|---------| -| `api/` | Trino CRD definitions | -| `cmd/` | Operator entrypoint (wires the handler as both `RoleGroupHandler` and `RoleProvider`, plus `RoleGroupResolver` and `ImageResolution`, into `GenericReconciler`) | -| `config/` | Kubernetes manifests and kustomize configs | -| `internal/controller/` | `TrinoRoleGroupHandler` — embeds the SDK `BaseRoleGroupHandler`; the framework owns resource orchestration | -| `internal/product/` | `RoleGroupResolver` (`product.ComputeConfig`): Trino's `config.properties` (role-branched, derived from the role group's effective config) returned as a `*reconciler.Contribution` | -| `internal/config/` | `JVMConfigBuilder` (non-key-value `jvm.config`) and `CatalogConfigBuilder` | -| `internal/extensions/` | Catalog validation + health + discovery ConfigMap (ClusterExtension / RoleExtension; discovery uses `reconciler.EnsureDiscoveryConfigMap`) | +- `internal/product/definition.go`: exported TrinoConfig/TrinoClusterConfig/TrinoFacts, Definition, pure validation/generation and discovery output. +- `internal/product/logging.go`: Trino 476 Airlift/JUL adapter; ROOT empty key, sink/logger clamp, explicit unsupported-level/unequal-sink errors. +- `internal/product/facts.go` and `data.go`: compose bounded catalog, authentication and S3 resolution; clone facts and preserve sanitized diagnostics. +- `internal/product/final.go` and `helpers.go`: product relationships checked under known final execution/file premises. +- `cmd/generate`: derives roles from Definition and emits API, CRD and registration. `-check` checks all three artifacts exactly. +- `api/v1alpha1/zz_generated.input.go`: presence-preserving generated API, DeepCopy, early Operation, raw Project and Binding. +- `api/v1alpha1/registration/zz_generated.register.go`: fixed C/S companion calling public operator.Register. +- `cmd/main.go`: actual manager startup, explicit helper images, base facts/resolver and generated registration. +- `config/default`, `manager`, `rbac`, `crd`, `samples`: complete minimal installer and actual workload sample. No webhook/cert-manager scaffolding remains. +- `test/runtime/storage-controller`: marker-only framework validation tool, separate from the Trino production executable; no Trino query claim. +- `test/runtime/logging-controller`: Python native logging/central Vector acceptance fixture using the generated API/registration; separate namespace and executable from production Trino. -## Architecture (declare → fold → derive) +## Current runtime boundaries -This example demonstrates the SDK's preferred division of labour: +Product defaults target organization Trino 476, launcher `/kubedoop/trino-server/bin/launcher`, UID/GID 1001. Native `node.id=${ENV:TRINO_NODE_ID}` consumes Pod UID through downward API. Config lives at `/etc/trino`; data defaults to ephemeral; real native JSON file output is under `/kubedoop/log/trino`. The framework assembles the materializer and selected Vector collector. It owns `enableVectorAgent`; LogOutput has no Collect field. -- **Framework owns the 90%.** `TrinoRoleGroupHandler` embeds `reconciler.BaseRoleGroupHandler`, so the ConfigMap, Services, StatefulSet (with sidecars + `podOverrides` applied), and PDB are built by the SDK. The handler itself carries only reconcile-invariant collaborators — `ConfigMountPath` (`/etc/trino`) and the `ConfigGenerator`. Everything role-shaped (primary container name `trino`, per-role ports, the Log4j2 log producer) is stated by `DeclareRoles`, which implements `reconciler.RoleProvider` and is called once per pass with the cr in hand. -- **Logging is fully framework-owned.** `RoleDeclaration.LogProducers` declares only the container + framework (no output file — the framework derives `//.`, e.g. `.log4j.xml` for log4j/logback). When a role group enables the Vector agent, the SDK's Vector provider is the single owner of the shared log volume (creates it, RW-mounts the producer, mounts it on the agent, which pre-creates the per-container log dirs before exec'ing vector), and — because `TrinoCluster` implements `reconciler.VectorAggregatorProvider` (`VectorAggregatorConfigMapName()` from `spec.clusterConfig`) — the framework also generates `vector.yaml` into the ConfigMap. The product writes no Vector wiring by hand. -- **Derived config flows as data through the merge pipeline.** `product.ComputeConfig` computes `config.properties` from the role group's **effective** config and returns a `*reconciler.Contribution`, wired via `GenericReconcilerConfig.RoleGroupResolver` — the lowest merge layer, so any user `configOverrides` in the CRD always win. This is config generation (recomputed every reconcile), not webhook defaulting. -- **Scheduling and shutdown are declarative.** The framework consumes the role group config's `affinity` (a raw `corev1.Affinity`) and `gracefulShutdownTimeout` (mapped to `terminationGracePeriodSeconds`); user `podOverrides` keep precedence over both. The sample CR demonstrates `gracefulShutdownTimeout`. -- **Discovery is a one-liner.** `extensions.DiscoveryExtension` (a `ClusterExtension` running PostReconcile) publishes the coordinator URI in a discovery ConfigMap named after the cluster via `reconciler.EnsureDiscoveryConfigMap` — the framework owns CreateOrUpdate + controller owner reference + canonical labels; the product only computes the data map. -- **Escape hatch for what the pipeline can't model.** `BuildResources` calls the base, then appends the CR-driven image, the non-key-value `jvm.config`, and coordinator-only catalog files. There is no hand-built `StatefulSet`. +Only one coordinator group with one replica is supported. Workers share the coordinator endpoint from complete desired topology. Default TPCH catalogs can be replaced by per-group same-namespace `catalogs.json` ConfigMaps through FactsReader. Source readiness is distinct from query readiness. PDB is role-scoped and stop preserves desired replica inventory. -## Working Instructions +All four overrides use the formal input actions, with final Pod patches taking precedence. Unknown final relationships are not proof of compatibility. Product file ConfigMap changes rely on the separately installed platform restarter; the sample opts in with a workload-propagated CR label. Central Vector destination changes update the Pod template directly. Disabling Vector in a descendant sample group also requires deleting its inherited resources-only vector Pod patch. -1. **Building:** Run `make build` to compile the operator -2. **Testing:** Run `make test` for unit tests -3. **Deploying:** Use `make deploy` with kustomize configs in `config/` -4. **Development:** Use `.devcontainer/` for consistent development environment +## Verification and builds + +Use `make generate`, `make verify-generate`, `make test`, `make lint`, `make build`, and `make build-installer`. `make test` uses explicit/root envtest assets and verifies the published sample through the real generated CRD. Unit tests alone do not prove a running Trino workload; U05 owns deployment evidence. + +`make docker-build` cross-compiles a static Linux manager into `bin/image` and builds the scratch Dockerfile from that bounded context. `TARGETARCH` chooses architecture; root `make materializer-image` builds the co-released helper. `make build-installer IMG=...` creates a temporary Kustomize overlay, writes `dist/install.yaml`, and leaves source manifests unchanged. Kustomize is supplied through PATH or `KUSTOMIZE`. + +Keep generated artifacts synchronized, and update this file when actual behavior changes. Do not add compatibility aliases, old startup paths, duplicate handwritten CRD projections, or product-specific framework bindings. + +The product marks its data directory with `Directory.Data`. Standard `resources.storage` +is folded and consumed by the framework; the product default remains ephemeral. +The marker fixture also declares a symbolic Data directory and supplies storage defaults. +Its runtime harness overrides storage through the generated CR input. + +`internal/product/lifecycle.go` declares ordered idempotent initialization and native startup, +readiness and liveness probes. Optional shutdownUser/shutdownCredentialsSecret enables the worker +SHUTTING_DOWN protocol with exact JVM identity and bounded waiting. Defaults grant no management +write. Workers stop before coordinator through declared coordination priority. Native probes moved +from the sample Pod patches into product intent. See docs/architecture.md#framework-lifecycle for limits. + +`authentication.go` consumes one Static AuthenticationClass as coordinator PASSWORD authentication. +TLS from tlsSecret/tlsSecretClass is assembled on the coordinator; internalSecret SecretKeyRef is consumed by all roles. Secrets are +references in facts; TLS PEM assembly runs in a separate initializer. With ListenerClass, +AutoTLS scopes include the Listener volume so the published endpoint is covered by certificate SANs. Unsupported provider +combinations fail visibly. `facts.go` composes catalog, authentication and S3 resolution. +The sample API test starts generated registration against envtest and inspects the actual +StatefulSet probes and ordered initializers; kubelet/process success remains runtime evidence. diff --git a/examples/trino-operator/Dockerfile b/examples/trino-operator/Dockerfile index a022882c..e4578539 100644 --- a/examples/trino-operator/Dockerfile +++ b/examples/trino-operator/Dockerfile @@ -1,31 +1,6 @@ -# Build the manager binary -FROM golang:1.25 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 . +# Build context is bin/image, populated by `make docker-build` from this checkout. +# The operator uses the explicit Kubernetes CA from in-cluster or REST configuration. +FROM scratch +COPY manager /manager USER 65532:65532 - ENTRYPOINT ["/manager"] diff --git a/examples/trino-operator/Makefile b/examples/trino-operator/Makefile index 29e4606e..6dfb2400 100644 --- a/examples/trino-operator/Makefile +++ b/examples/trino-operator/Makefile @@ -1,255 +1,68 @@ -# Image URL to use all building/pushing image targets -IMG ?= controller:latest - -# 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) +SHELL := /bin/bash +.SHELLFLAGS := -euo pipefail -c +SDK_ROOT := ../.. +GO ?= go +IMG ?= trino-operator:dev CONTAINER_TOOL ?= docker +TARGETOS ?= linux +TARGETARCH ?= $(shell $(GO) env GOARCH) +KUSTOMIZE ?= kustomize +GOLANGCI_LINT ?= $(SDK_ROOT)/bin/golangci-lint +KUBEBUILDER_ASSETS ?= $(abspath $(SDK_ROOT)/bin/k8s/1.35.0-$(shell $(GO) env GOOS)-$(shell $(GO) env GOARCH)) -# 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 +.PHONY: all generate manifests verify-generate fmt vet test lint build build-linux run docker-build docker-push build-installer install uninstall deploy undeploy 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\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:headerFile="hack/boilerplate.go.txt" 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 $$(go list ./... | grep -v /e2e) -coverprofile cover.out - -# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. -# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. -# CertManager is installed by default; skip with: -# - CERT_MANAGER_INSTALL_SKIP=true -KIND_CLUSTER ?= trino-operator-test-e2e - -.PHONY: setup-test-e2e -setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist - @command -v $(KIND) >/dev/null 2>&1 || { \ - echo "Kind is not installed. Please install Kind manually."; \ - exit 1; \ - } - @case "$$($(KIND) get clusters)" in \ - *"$(KIND_CLUSTER)"*) \ - echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \ - *) \ - echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ - $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ - esac - -.PHONY: test-e2e -test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. - KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v - $(MAKE) cleanup-test-e2e - -.PHONY: cleanup-test-e2e -cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests - @$(KIND) delete cluster --name $(KIND_CLUSTER) - -.PHONY: lint -lint: golangci-lint ## Run golangci-lint linter - "$(GOLANGCI_LINT)" run - -.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/ -.PHONY: docker-build -docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -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=> 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 trino-operator-builder - $(CONTAINER_TOOL) buildx use trino-operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - - $(CONTAINER_TOOL) buildx rm trino-operator-builder - rm Dockerfile.cross - -.PHONY: build-installer -build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. +generate manifests: + $(GO) run -mod=readonly ./cmd/generate + +verify-generate: + $(GO) run -mod=readonly ./cmd/generate -check + +fmt: + $(GO) fmt ./... + +vet: + $(GO) vet ./... + +test: verify-generate + KUBEBUILDER_ASSETS="$(KUBEBUILDER_ASSETS)" $(GO) test -mod=readonly ./... -count=1 + +lint: + $(GOLANGCI_LINT) run ./... + +build: verify-generate + $(GO) build -mod=readonly -trimpath -buildvcs=false -o bin/manager ./cmd + +build-linux: verify-generate + mkdir -p bin/image + CGO_ENABLED=0 GOOS=$(TARGETOS) GOARCH=$(TARGETARCH) $(GO) build -mod=readonly -trimpath -buildvcs=false -o bin/image/manager ./cmd + +run: verify-generate + $(GO) run -mod=readonly ./cmd $(ARGS) + +docker-build: build-linux + $(CONTAINER_TOOL) build --platform $(TARGETOS)/$(TARGETARCH) -f Dockerfile -t $(IMG) bin/image + +docker-push: + $(CONTAINER_TOOL) push $(IMG) + +build-installer: verify-generate 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 -KIND ?= kind -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.20.1 - -#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) -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" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/release-\1.\2/') - -#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.11.2 -.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 + @overlay=$$(mktemp -d config/.installer.XXXXXX); \ + trap 'rm -rf "$$overlay"' EXIT; \ + printf '%s\n' 'resources:' '- ../default' > "$$overlay/kustomization.yaml"; \ + (cd "$$overlay" && $(KUSTOMIZE) edit set image "trino-operator=$(IMG)"); \ + $(KUSTOMIZE) build "$$overlay" > dist/install.yaml + +install: verify-generate + $(KUSTOMIZE) build config/crd | kubectl apply --validate=strict -f - + +uninstall: + $(KUSTOMIZE) build config/crd | kubectl delete -f - + +deploy: build-installer + kubectl apply --validate=strict -f dist/install.yaml + +undeploy: + $(KUSTOMIZE) build config/default | kubectl delete -f - diff --git a/examples/trino-operator/PROJECT b/examples/trino-operator/PROJECT deleted file mode 100644 index 8fb50136..00000000 --- a/examples/trino-operator/PROJECT +++ /dev/null @@ -1,25 +0,0 @@ -# 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.12.0 -domain: kubedoop.dev -layout: -- go.kubebuilder.io/v4 -projectName: trino-operator -repo: github.com/zncdatadev/operator-go/examples/trino-operator -resources: -- api: - crdVersion: v1 - namespaced: true - controller: true - domain: kubedoop.dev - group: trino - kind: TrinoCluster - path: github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1 - version: v1alpha1 - webhooks: - defaulting: true - validation: true - webhookVersion: v1 -version: "3" diff --git a/examples/trino-operator/README.md b/examples/trino-operator/README.md index ffbcb999..148063af 100644 --- a/examples/trino-operator/README.md +++ b/examples/trino-operator/README.md @@ -1,419 +1,180 @@ -# Trino Operator Example - -This is an example operator built with [Kubebuilder](https://book.kubebuilder.io/) and the -[operator-go](../../) SDK. It demonstrates all core capabilities of the operator-go SDK. - -## Features Demonstrated - -- **GenericReconciler Template Method Pattern**: The reconciliation loop is owned by the SDK's - `GenericReconciler`, which calls product-specific seams at fixed points. -- **BaseRoleGroupHandler Delegation**: `TrinoRoleGroupHandler` embeds - `reconciler.BaseRoleGroupHandler`, so the framework builds the ConfigMap, Services, StatefulSet - and role PDB; the override only appends what the merge pipeline cannot express. -- **RoleGroupResolver**: `product.ComputeConfig` contributes Trino's `config.properties` as the - lowest-precedence merge layer, so any user `configOverrides` wins over it. -- **Typed Extension Registry**: `common.NewExtensionRegistry[*TrinoCluster]()` holds - `ClusterExtension` (Catalog, Discovery) and `RoleExtension` (Health) hooks, each declaring - `*TrinoCluster` in its signatures. -- **Admission Webhook**: a `CustomDefaulter` fills product defaults into the typed spec and a - `CustomValidator` rejects invalid clusters before they reach the reconciler. -- **Declarative Logging**: `RoleDeclaration.LogProducers` lets the framework render the Log4j2 config from - the CRD logging spec. - -## Project Structure - -```text -trino-operator/ -├── api/v1alpha1/ # CRD definitions -│ ├── trinocluster_types.go # TrinoCluster CRD (implements ClusterInterface) -│ ├── groupversion_info.go # Auto-generated -│ └── zz_generated.deepcopy.go # Auto-generated -├── cmd/ -│ └── main.go # Entry point: registry + GenericReconciler setup -├── config/ -│ ├── crd/ # CRD YAMLs (auto-generated) -│ ├── rbac/ # RBAC configuration (auto-generated) -│ ├── webhook/ # Webhook configuration (auto-generated) -│ ├── certmanager/ # Serving certificates for the webhook -│ ├── samples/ # Sample CRs -│ ├── manager/ # Manager configuration -│ └── default/ # Kustomize overlay wiring all of the above -├── internal/ -│ ├── controller/ -│ │ └── trino_handler.go # RoleGroupHandler (embeds BaseRoleGroupHandler) -│ ├── extensions/ -│ │ ├── catalog_extension.go # ClusterExtension example -│ │ ├── discovery_extension.go # ClusterExtension + discovery ConfigMap example -│ │ └── health_extension.go # RoleExtension example -│ ├── product/ -│ │ └── config.go # RoleGroupResolver and role name constants -│ ├── config/ -│ │ ├── trino_config.go # jvm.config generation -│ │ └── catalog_config.go # Catalog properties generation -│ ├── constants/ -│ │ └── constants.go # Image, port and container name constants -│ └── webhook/v1alpha1/ -│ └── trinocluster_webhook.go # Defaulter and validator -├── test/ -│ ├── e2e/ # E2E tests (build tag `e2e`) -│ └── utils/ # E2E helpers -├── Dockerfile # Container image -├── Makefile # Build targets -└── README.md # This file -``` - -## Quick Start +# Trino operator example -### Prerequisites +This module is the Trino reference product for the formal `operator-go/pkg/framework` API. The executable registers its generated API and `product.Definition()` with the public operator package. The SDK owns folding, resource assembly, apply, observation, stop/pause and retained-resource safety; the product owns Trino configuration and its read-only catalog, authentication and S3 resolvers. -- Go 1.25+ (see `go.mod`) -- Docker -- kubectl -- Access to a Kubernetes cluster -- [cert-manager](https://cert-manager.io/) in the cluster — the default overlay deploys the - admission webhook and takes its serving certificate from cert-manager +The reference image is `quay.io/zncdatadev/trino:476-kubedoop0.0.0-dev`. Its launcher is `/kubedoop/trino-server/bin/launcher`, running as UID/GID 1001. It uses one coordinator group with one replica and any number of worker groups. Default catalogs contain the bundled `tpch` connector. Catalog declarations do not establish plugin availability, credentials or external service readiness. -### Build and Run Locally +## Product and generated input -`main.go` always registers the admission webhook, so the manager needs a serving certificate even -when run from the host — the webhook server fails to start without one. Either point -`--webhook-cert-path` at a directory holding `tls.crt`/`tls.key`, or place them in -controller-runtime's default directory, `/k8s-webhook-server/serving-certs`. +The author-maintained product lives in `internal/product/`: -```bash -# Install CRDs into the cluster -make install +- `TrinoConfig`: role/group HTTP port, catalog reference, explicit shutdown identity and typed Hive/S3 configuration. +- `TrinoClusterConfig`: cluster-wide environment, ListenerClass, TLS source and internal shared-secret reference. +- `TrinoFacts`: resolved catalogs, authentication and S3 references, supplied independently for each group. +- `Definition()`: image and role defaults, product validation, runtime/files, final relationship checks and shared discovery output. +- `ResolveFacts`: combines referenced `catalogs.json`, authentication and S3 inputs for each group; missing/deleting required sources remain Pending. It does not write Kubernetes resources. -# Run the controller locally -make run +`cmd/generate` derives the API role list from the definition and generates the input types, CRD, binding and registration companion. Generated presence fields preserve `false`, zero, empty strings and empty collections. `Operation` reads fixed controls before product projection. The generated raw projection does not merge role layers or resolve facts. -# In another terminal, apply the sample CR -kubectl apply -f config/samples/trino_v1alpha1_trinocluster.yaml +```sh +make generate +make verify-generate +make test +make lint +make build ``` -### Build and Deploy +`make test` uses the root checkout's Kubernetes 1.35 envtest binaries, or an explicit `KUBEBUILDER_ASSETS`. The published sample is tested through YAML-to-JSON decoding and real API-server create/get/projection. These tests do not start Trino or prove query readiness. Runtime delivery validation is maintained separately by the repository's [framework acceptance harness](../../hack/framework-e2e/README.md). + +The module retains a local SDK `replace` to `../..`; generated files are checked into this module. There is no alternate legacy handler, extension registry, webhook or controller entrypoint. -```bash -# Build the Docker image -make docker-build IMG=trino-operator:latest +## Deploy the actual executable -# Push to registry -make docker-push IMG=trino-operator:latest +Build the materializer from the same SDK revision as the operator. The materializer and operator images contain statically compiled Linux executables, so no Go builder image is needed: -# Deploy to cluster -make deploy IMG=trino-operator:latest +```sh +make -C ../.. materializer-image +make docker-build IMG=trino-operator:dev +make build-installer IMG=trino-operator:dev +kubectl apply --validate=strict -f dist/install.yaml +kubectl apply --validate=strict -f config/samples/trino_v1alpha1_trinocluster.yaml ``` -### Run Tests +Images must be available to the target cluster: load locally built images into a local cluster, or tag/push them to a reachable registry. `TARGETARCH` controls the operator cross-compile and image platform; `MATERIALIZER_ARCH` controls the SDK helper. The installer uses the fixed Vector digest declared in `config/manager/manager.yaml`. Change image references in a deployment overlay for another platform/release. -```bash -# Run unit and envtest suites -make test +The installer creates namespace `trino-operator-system`, Deployment `trino-operator-controller-manager`, its ServiceAccount/RBAC, `trinoclusters.trino.kubedoop.dev` and independent DataAsset/DataOperation CRDs. It does not deploy the destructive data executor. It has no admission-webhook or cert-manager dependency. `make deploy` builds and applies the installer. The operator defaults to watching all namespaces; `--namespace` limits the manager watch. -# Run the e2e suite against a Kind cluster -make test-e2e -``` +For a local process, pass the same helper images explicitly: -## Architecture - -### GenericReconciler Flow - -```text -┌─────────────────────────────────────────────────────────────────┐ -│ GenericReconciler │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. Fetch CR, record observedGeneration │ -│ 2. ClusterOperation gate (reconciliationPaused returns here) │ -│ 3. Ensure workload ServiceAccount (always; name derived from CR)│ -│ 3b. Ensure workload RBAC (only when WorkloadRBACRules is set) │ -│ 4. Execute Cluster PreReconcile extensions │ -│ 5. Validate dependencies │ -│ 6. For each Role (sorted by name): │ -│ a. Execute Role PreReconcile extensions │ -│ b. For each RoleGroup: │ -│ - Execute RoleGroup PreReconcile extensions │ -│ - Build RoleGroupBuildContext (merged config + sidecars) │ -│ - Delegate to RoleGroupHandler.BuildResources() │ -│ - Apply CM → HeadlessSvc → Svc → extras → STS → PDB │ -│ - Execute RoleGroup PostReconcile extensions │ -│ c. Reconcile the role-level PodDisruptionBudget │ -│ d. Execute Role PostReconcile extensions │ -│ 7. Cleanup orphaned resources │ -│ 8. Update health status │ -│ 9. Execute Cluster PostReconcile extensions │ -│ 10. Write status and schedule the next wakeup │ -└─────────────────────────────────────────────────────────────────┘ +```sh +make run ARGS='--materializer-image=quay.io/zncdatadev/operator-go-materializer:0.0.0-dev --vector-image=quay.io/zncdatadev/vector@sha256:3b9a99d98905443924bee204bd76c2818ad2da7056388fd524b0ea000eb55682' ``` -A failure anywhere in this flow runs the `OnReconcileError` extensions and maps to the `Degraded` -condition on the CR. API-server rate limiting is the exception: it backs off and retries without -marking the cluster degraded. - -### Resource Building Split - -```text -GenericReconciler - │ - ├── per role group: TrinoRoleGroupHandler.BuildResources() - │ │ - │ ├── BaseRoleGroupHandler.BuildResources() # the framework's 90% - │ │ ├── ConfigMap (merged config + Log4j2 logging file) - │ │ ├── Headless Service + Service - │ │ └── StatefulSet (image, sidecars, podOverrides) - │ │ - │ └── product-specific additions - │ ├── jvm.config (both roles) - │ └── catalog/*.properties (coordinators only) - │ - └── per role: BaseRoleGroupHandler.BuildRolePodDisruptionBudget() -``` +Other options include `--fact-refresh-interval` (30s), `--health-probe-bind-address`, `--metrics-bind-address`, `--leader-elect` and `--leader-election-namespace`. Helper images are required at startup. The health endpoints report manager health; Trino `/v1/info` probes and the initialization process are declared by the product through typed framework process fields. + +## Standard CRD controls -The PDB is deliberately outside `BuildResources`: `roleConfig.podDisruptionBudget` covers all -pods of a role across every role group, so the framework builds exactly one per role instead of -one per group. +See `config/samples/trino_v1alpha1_trinocluster.yaml` for a complete coordinator/worker deployment. The standard shape is: -Both roles share one handler; the role is read from `buildCtx.RoleName` rather than routed to -separate handler types. +- `spec.image`: structured repo/productVersion/kubedoopVersion, or `custom` for an explicit reference/digest; independent pull policy and pull Secret. +- `spec.clusterConfig`: common authentication/Vector references, product platform settings, plus independent `stopped` and `reconciliationPaused`. +- `coordinators` / `workers`: optional role replicas, role-only PDB configuration, common/product `config`, four overrides, and named `roleGroups`. -## CRD Example +Common configuration controls CPU, memory, affinity, termination grace and structured logging. Product configuration defaults to HTTP port 8080. User workload configuration folds product defaults, role and group layers. PDB computation uses the complete declared replica inventory, including groups whose resources are waiting or invalid. Stop retains those declarations while scaling authenticated workloads to zero; pause prevents business reads and resource mutations until resumed. + +The four override channels are explicit. For example, within `workers.roleGroups.default`: ```yaml -apiVersion: trino.kubedoop.dev/v1alpha1 -kind: TrinoCluster -metadata: - name: demo-trino -spec: - image: - productVersion: "476" - kubedoopVersion: "0.0.0-dev" - - coordinators: - roleGroups: - default: - replicas: 1 - config: - gracefulShutdownTimeout: "30s" - resources: - cpu: - min: "500m" - max: "1" - memory: - limit: "2Gi" - - workers: - roleGroups: - default: - replicas: 3 - config: - resources: - cpu: - min: "1" - max: "2" - memory: - limit: "4Gi" - - catalogs: - - name: hive - type: hive - properties: - hive.metastore.uri: "thrift://hive-metastore:9083" - - name: tpch - type: tpch +configOverrides: + config.properties: + properties: + set: + query.max-memory-per-node: 256MB +envOverrides: + EXAMPLE_ENV: enabled +cliOverrides: + - --etc-dir=/etc/trino + - run +podOverrides: + metadata: + annotations: + example.kubedoop.dev/intent: explicit ``` -See `config/samples/trino_v1alpha1_trinocluster.yaml` for the full sample. - -## Key Integration Points - -### 1. Implementing ClusterInterface - -`ClusterInterface` has two methods. Everything else the SDK needs — metadata accessors, object -kind, `DeepCopyObject` — comes from the embedded `TypeMeta`/`ObjectMeta` and the generated -deep-copy code. - -```go -// GetSpec builds a GenericClusterSpec from the typed coordinators/workers fields, bridging the -// type-safe CRD structure to the SDK's generic Roles map without a redundant spec.roles field. -func (t *TrinoCluster) GetSpec() *commonsv1alpha1.GenericClusterSpec { - roles := make(map[string]commonsv1alpha1.RoleSpec) - if t.Spec.Coordinators != nil { - roles["coordinators"] = t.Spec.Coordinators.RoleSpec - } - if t.Spec.Workers != nil { - roles["workers"] = t.Spec.Workers.RoleSpec - } - return &commonsv1alpha1.GenericClusterSpec{ - Image: t.Spec.Image, - ClusterOperation: t.Spec.ClusterOperation, - Roles: roles, - } -} - -// GetStatus returns a pointer into the CR, so product-specific status fields survive a -// reconcile cycle untouched. There is no SetStatus: the framework mutates through this pointer. -func (t *TrinoCluster) GetStatus() *commonsv1alpha1.GenericClusterStatus { - return &t.Status.GenericClusterStatus -} -``` +File actions are `properties` (set/remove/replace), `lines`, `text`, or `remove`; the framework does not infer syntax from the file extension. CLI input replaces the full argument list, so the launcher arguments must remain present when intended. Role and group Pod patches are applied after file/env/CLI channels. A role Pod patch can therefore override a group env/CLI entry. Known final relationship conflicts block that group's apply; unknown relationships remain visibly unknown and do not establish runtime correctness. -Optional seams are separate interfaces the CR may also satisfy — `TrinoCluster` implements -`reconciler.VectorAggregatorProvider` so the framework owns `vector.yaml` generation. - -### 2. Implementing RoleGroupHandler - -```go -// TrinoRoleGroupHandler embeds the SDK handler, so the framework builds the bulk of the -// resources and the override only appends what the merge pipeline cannot express. -type TrinoRoleGroupHandler struct { - *reconciler.BaseRoleGroupHandler[*trinov1alpha1.TrinoCluster] -} - -func (h *TrinoRoleGroupHandler) BuildResources( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, - buildCtx *reconciler.RoleGroupBuildContext, -) (*reconciler.RoleGroupResources, error) { - resources, err := h.BaseRoleGroupHandler.BuildResources(ctx, k8sClient, cr, buildCtx) - if err != nil { - return nil, err - } - - if resources.ConfigMap != nil { - // setIfAbsent never clobbers a key the merge pipeline already produced (CRD always wins). - setIfAbsent(resources.ConfigMap.Data, "jvm.config", func() string { return jvmConfig(buildCtx.RoleName) }) - - if buildCtx.RoleName == product.RoleCoordinators { - // ... catalog/.properties, coordinator only - } - } - - return resources, nil -} -``` +The sample carries `restarter.kubedoop.dev/enable: "true"`. Product file ConfigMap changes need the platform's commons-operator restarter to reach existing processes; initial opt-in also causes one rollout. Env/CLI/Pod changes alter the Pod template directly. The SDK does not synthesize restarter stamps. Install the restarter when exercising file-only delivery. Central Vector destination changes are resolved into the Pod template and roll the collector through that template change. + +## Native Trino logging and process identity + +Trino 476 uses Airlift/JUL logging. This product generates `log.properties` and native `log.enable-console`, `log.path`, and JSON file logging properties; it does not emit Logback or Log4j configuration. `ROOT` maps to the empty native property key. Supported levels are TRACE, DEBUG, INFO, WARN, ERROR and OFF. FATAL is rejected because it has no faithful native mapping here. + +One active sink, or two active sinks with the same threshold, is supported. Each logger threshold is clamped by the active sink threshold. Different active console/file thresholds are explicitly rejected. The defaults are console OFF, file TRACE and ROOT INFO. Quote `"OFF"` in YAML to avoid YAML 1.1 boolean conversion. -`NewTrinoRoleGroupHandler(scheme)` configures only what a role cannot differ on -(`ConfigGenerator`, `ConfigMountPath`). Everything role-shaped — primary container name, container -and service ports, log producers — is stated by `DeclareRoles`, which implements -`reconciler.RoleProvider` and receives the cr, so a port that moves because the CR enabled TLS is -computed there rather than written into handler state the next cluster inherits. - -### 3. Deriving Config from the Effective Config - -```go -// ComputeConfig is merged as the LOWEST layer (product < role < role group), so a user's -// configOverrides always win over it. It runs once per role group, AFTER the typed config -// block has been folded — so it can read rg.EffectiveConfig() — and before anything is built. -// It is recomputed every reconcile and may derive from live cluster state; here, the discovery -// URI of the coordinator Service. -func ComputeConfig( - _ context.Context, _ client.Client, cr *trinov1alpha1.TrinoCluster, - rg *reconciler.RoleGroupBuildContext, -) (*reconciler.Contribution, error) { - port := CoordinatorPort(cr) - - props := map[string]string{ - "http-server.http.port": fmt.Sprintf("%d", port), - "discovery.uri": discoveryURI(cr, port), - } - switch rg.RoleName { - case RoleCoordinators: - props["coordinator"] = "true" - props["node-scheduler.include-coordinator"] = "false" - props["discovery-server.enabled"] = "true" - case RoleWorkers: - props["coordinator"] = "false" - } - return &reconciler.Contribution{ - ConfigOverrides: map[string]map[string]string{ - "config.properties": props, - }, - }, nil -} +Vector is assembled only when `config.logging.enableVectorAgent` is true and the product declares a real file output. File OFF withdraws the native file output; console logging alone is not a file source. The sample also supplies a `vector` resources patch. If disabling file collection in a descendant group, remove that inherited container patch explicitly: + +```yaml +config: + logging: + enableVectorAgent: false +podOverrides: + spec: + containers: + - name: vector + $patch: delete ``` -### 4. Registering Extensions +The product writes `node.id=${ENV:TRINO_NODE_ID}` and supplies the Pod UID via the downward API. A container restart in the same Pod keeps that identity; a replacement Pod has a new identity. Configuration materialization leaves the native environment expression literal for Trino to resolve. The product declares initialization, probes and ordered workload coordination. Initialization checks this Pod's required materialized files and data/log directory writability on each execution; it does not use a historical marker to skip initialization. Startup/readiness probes require `/v1/info` to report `starting=false`; liveness checks the native info endpoint. Workers have shutdown priority 0 and coordinators 100. -The registry is instantiated for the product's own CR type, which is what lets extensions -declare `*TrinoCluster` in their hooks instead of the SDK's wide `ClusterInterface`. There is no -process-global registry: a registry is handed to exactly one reconciler, and an operator that -manages several CR types builds one registry per type. +Worker management writes are disabled by default. Set `config.shutdownUser` or `config.shutdownCredentialsSecret` explicitly to enable preStop; the Secret supplies read-only `username` and `password` files. The hook constructs Basic Authorization at runtime when credentials are supplied, identifies the current JVM by PID and birth identity, sends native `SHUTTING_DOWN`, and waits for that JVM to exit. Coordinators do not call this worker protocol. The hook currently uses loopback HTTP: authentication must match that management endpoint, and PASSWORD/TLS configuration does not implicitly make coordinator HTTPS available on a worker or enable insecure HTTP authentication. -```go -// In main.go -func newExtensionRegistry(scheme *runtime.Scheme) *common.ExtensionRegistry[*trinov1alpha1.TrinoCluster] { - registry := common.NewExtensionRegistry[*trinov1alpha1.TrinoCluster]() +The hook budget is `config.gracefulShutdownTimeout` minus two seconds; enabling it with a configured budget below ten seconds is rejected. Final Pod overrides can change the actual kubelet budget but do not recompute hook arguments, so such a change reports an unknown lifecycle relationship. Request acceptance, original query completion, main JVM exit and hook exit are separate observations. Finite budgets can still end in SIGKILL. Cross-role ordering covers framework stop/retirement; CR deletion uses Kubernetes GC and does not preserve that guarantee. See [lifecycle coordination](../../docs/architecture.md#framework-lifecycle) for recovery and progress rules. - registry.RegisterClusterExtension(extensions.NewCatalogExtension()) - registry.RegisterRoleExtension(extensions.NewHealthExtension()) +## External catalogs and scope - // Priority (not registration order) is what keeps the discovery extension running after the - // catalog extension has refreshed the status. - registry.RegisterClusterExtension(extensions.NewDiscoveryExtension(scheme), common.WithPriority(common.PriorityLow)) +Set role/group `config.catalogConfigMapName` to consume a same-namespace ConfigMap: - return registry -} +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: trino-catalogs +data: + catalogs.json: | + {"tpch":{"connector.name":"tpch"}} ``` -### 5. Wiring the GenericReconciler - -The registry only runs when it reaches the reconciler through `ExtensionRegistry`; without that -field the hooks are never executed. - -```go -// In main.go -roleGroupHandler := trinocontroller.NewTrinoRoleGroupHandler(mgr.GetScheme()) - -reconcilerCfg := &reconciler.GenericReconcilerConfig[*trinov1alpha1.TrinoCluster]{ - Client: mgr.GetClient(), - // Uncached: refreshes the resourceVersion after a conflicting status write, which the - // informer cache is by definition too stale to serve. - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("trino-cluster-controller"), - RoleGroupHandler: roleGroupHandler, - // The same object declares this product's roles, once per pass with the cr in hand. - // Leaving it unset is legal and means the catalog is EMPTY: no role is rejected, but every - // role builds with a zero declaration — no ports, no container name, no Service, no log - // producers — and the reconcile reports success. Set it unless the handler builds everything. - RoleProvider: roleGroupHandler, - RoleGroupResolver: reconciler.RoleGroupResolverFunc[*trinov1alpha1.TrinoCluster](product.ComputeConfig), - // Read every reconcile, so an operator upgrade moves existing clusters onto the - // co-released product image — which a mutating webhook cannot do, since its defaults are - // persisted at admission and never recomputed. - ImageResolution: reconciler.ImageResolution{ - ProductName: constants.ProductName, - Defaults: constants.ImageDefaults(), - }, - HealthCheckInterval: 120 * time.Second, - HealthCheckTimeout: 300 * time.Second, - Prototype: &trinov1alpha1.TrinoCluster{}, - ExtensionRegistry: newExtensionRegistry(mgr.GetScheme()), -} - -trinoReconciler, err := reconciler.NewGenericReconciler(reconcilerCfg) -if err != nil { - setupLog.Error(err, "unable to create reconciler") - os.Exit(1) -} -if err := trinoReconciler.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "TrinoCluster") - os.Exit(1) -} -``` +A resolved source replaces that group's base catalog map. The framework records the observed source UID/resourceVersion; the product resolver returns catalog facts and diagnostics without copying catalog contents into diagnostic messages. Periodic refresh makes external changes observable; file delivery still requires the restarter. Empty reference means no external lookup and uses the base TPCH catalog. + +This reference defaults to ephemeral data directories. Set role/group `config.resources.storage` to +`{type: persistent, storageClassName: , capacity: 64Gi}` to back +the declared `/var/trino/data` directory with a retained RWO/Filesystem claim. Role groups +can override capacity while inheriting type/class. Existing storage changes require an +explicit migration process; changing this field does not migrate data. `test/runtime/storage-controller` is a separate marker-only validation executable for the framework's explicitly retained slot; the production Trino executable does not import it. Explicit cross-CR adoption, migration and destruction belong to the independently deployed SDK data executor. They do not happen through storage configuration changes and do not imply Trino database recovery. + -## License +## Platform domains -Copyright 2024 ZNCDataDev. +`clusterConfig.vectorAgentConfigMap` references a same-namespace ConfigMap with +`data.ADDRESS=host:port`, pointing to a Vector source. It is an address, not arbitrary Vector YAML or a URL. If no destination is specified, Vector emits stdout JSON. Only enabled collectors with actual file outputs consume +that dependency. Missing sources are Pending; invalid addresses are Invalid and retain the preceding valid workload. ADDRESS changes update the collector template; native file +configuration delivery otherwise continues to use the platform restarter. Collection configuration does not prove remote delivery; Vector buffering/retry applies and its data directory is ephemeral. -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 +`clusterConfig.authentication` accepts one static AuthenticationClass for PASSWORD +authentication. The class is cluster-scoped; its credentials Secret is in the Trino CR namespace and must contain nonempty `password.db` in native Trino hash format. Configure exactly +one of `tlsSecret` (keys `tls.crt`, `tls.key`) and `tlsSecretClass` (platform PEM +output), plus `internalSecret` (key `shared-secret`). HTTPS uses port 8443; the +product refuses other authentication providers until a concrete adapter exists. +Native Secret files are mounted read-only with mode 0440. An initialization process prepares a mode-0600 PEM in an ephemeral directory from read-only mounts; the internal shared identity reaches all roles through SecretKeyRef and native `${ENV:TRINO_INTERNAL_SHARED_SECRET}`. PASSWORD requires both TLS and the internal identity. TLS may also be enabled independently. Neither insecure HTTP authentication nor trust of forwarded headers is enabled implicitly. With a Listener, the TLS scope includes `listener-volume=listener` to cover its published address. - http://www.apache.org/licenses/LICENSE-2.0 +Secret bytes stay in volumes or SecretKeyRef, and native Secret revisions refresh consuming Pods. Missing required references retain the previous valid resources while waiting. AuthenticationClass resolution alone does not prove user authentication or grant worker shutdown permission. See the [authentication boundary](../../docs/security.md#framework-authentication) and the pinned Trino 476 [TLS](https://github.com/trinodb/trino/blob/476/docs/src/main/sphinx/security/tls.md), [password file](https://github.com/trinodb/trino/blob/476/docs/src/main/sphinx/security/password-file.md) and [internal communication](https://github.com/trinodb/trino/blob/476/docs/src/main/sphinx/security/internal-communication.md) contracts. + +`clusterConfig.listenerClass` declares the coordinator Listener CSI producer. +Discovery waits for the current Pod/PVC/PV/Listener result, then publishes its +observed address and named HTTP/HTTPS port, preferring HTTPS when enabled. With no Listener it uses the declared Service DNS. Pending observations retain the previous discovery output; a zero-replica group has no newly observed live address. Install the actual platform Secret and +Listener operators when selecting their CSI sources; the Trino installer grants +reference reads but does not install those platform components. + +For an actual Hive catalog, set role/group `config.hive.metastoreURI` and +`config.hive.s3`. S3 has explicit `disabled`, `inline`, `reference` variants; +changing the variant clears the inherited branch. Within the same variant, omitted fields inherit from role to group, so a group can change pathStyle while retaining its endpoint and credentials. An unspecified region becomes `us-east-1`; an unspecified port becomes 80 for HTTP or 443 for HTTPS, and host must be a DNS name or IP. Inline credentials select a +native Secret or SecretClass; reference uses an actual namespaced platform +S3Connection. The Trino launcher reads mounted ACCESS_KEY/SECRET_KEY files into +its process environment and native catalog expressions reference those variables. +No credential bytes are emitted into generated ConfigMaps. Empty or unreadable credential files fail startup; no undeclared credential chain is used. The fixed launcher remains in `Main.Command`, so inherited CLI overrides still replace only `Main.Args`, including an explicit empty argument list, and final Pod arguments retain their higher priority. + +The fixed `hive` catalog uses Trino 476's `fs.native-s3.enabled`, `s3.endpoint`, `s3.region`, `s3.path-style-access`, and `${ENV:TRINO_S3_ACCESS_KEY}` / `${ENV:TRINO_S3_SECRET_KEY}` credential values. A catalog ConfigMap that also defines `hive` conflicts with this typed input; final file overrides keep their existing priority. Only system-CA-verified S3 HTTPS is supported; verification bypass or an unconsumed custom CA is rejected. Connection endpoint/region/pathStyle changes also update `TRINO_S3_CONNECTION` in the Pod template so refreshed references reach a new process without editing the CR. This does not change Trino's default policy for writes to non-managed Hive tables. See the pinned [Trino 476 S3 filesystem configuration](https://github.com/trinodb/trino/blob/476/docs/src/main/sphinx/object-storage/file-system-s3.md). + +Shared contracts are maintained in [framework design](../../docs/architecture.md#framework-design), [platform inputs and observation](../../docs/architecture.md#framework-platform), [S3](../../docs/architecture.md#framework-s3), [logging](../../docs/architecture.md#framework-logging) and [data operations](../../docs/architecture.md#framework-data-operations). Product runtime and fixture validation use the [acceptance harness](../../hack/framework-e2e/README.md); framework marker/logging fixtures do not establish Trino database recovery or product query results. + +To package the independently authorized data executor, run from the SDK root: + +```sh +make dataops-image DATAOPS_IMAGE=operator-go-dataops:dev +kustomize build config/framework-data-executor > /tmp/data-executor.yaml +``` -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. +Load the image into the intended cluster and review/apply that explicit deployment +when data operations are needed. Its worker image contains Python 3 and is pinned +in the overlay. Creating an approved immutable DataOperation is a separate action; +ordinary product reconciliation never requests adoption, migration or destruction. The operation binds exact asset/source/target UIDs and an explicit non-root worker identity; RBAC grants authorization and the approval digest binds the reviewed input. Before execution, relevant StatefulSets must be retired, all actual data consumers absent, and any existing source CR paused. Adoption and migration also require the target CR to be paused. A Stopped condition or pause alone is insufficient. Migration retains the source copy; destruction is an independently approved action. See [data authorization](../../docs/security.md#framework-data-authorization) and [retained storage](../../docs/architecture.md#framework-storage) before preparing an operation. diff --git a/examples/trino-operator/api/v1alpha1/crd_default_guard_test.go b/examples/trino-operator/api/v1alpha1/crd_default_guard_test.go deleted file mode 100644 index ef50658f..00000000 --- a/examples/trino-operator/api/v1alpha1/crd_default_guard_test.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 v1alpha1_test - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/zncdatadev/operator-go/pkg/testutil" -) - -// This is the whole product-side cost of the guard, and it is what every operator built on this -// SDK should copy. No envtest, no CR fixture, no cluster — the defect is visible in the generated -// schema, so the check reads the output of `make manifests`. -// -// What it prevents: a `+kubebuilder:default` on a field inside a role or role group `config` block. -// That block is folded Role -> RoleGroup, and structural defaulting fills a leaf as soon as its -// enclosing object exists, so the default lands in every role group that declared the enclosing -// object for any reason — after which "the group did not set this" and "the group asked for the -// default" are the same bytes, and the role's value can never win. Defaults for these fields go at -// consumption time instead. -var _ = Describe("Generated CRDs", func() { - It("declare no default inside a role or role group config block", func() { - Expect("../../config/crd/bases/*.yaml").To(testutil.HaveNoInheritedConfigDefaults()) - }) -}) diff --git a/examples/trino-operator/api/v1alpha1/groupversion_info.go b/examples/trino-operator/api/v1alpha1/groupversion_info.go deleted file mode 100644 index 5108c13a..00000000 --- a/examples/trino-operator/api/v1alpha1/groupversion_info.go +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2026 ZNCDataDev. - -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 v1alpha1 contains API Schema definitions for the trino v1alpha1 API group. -// +kubebuilder:object:generate=true -// +groupName=trino.kubedoop.dev -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" -) - -var ( - // GroupVersion is group version used to register these objects. - GroupVersion = schema.GroupVersion{Group: "trino.kubedoop.dev", Version: "v1alpha1"} - - // SchemeBuilder is used to add go types to the GroupVersionKind scheme. - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} - - // AddToScheme adds the types in this group-version to the given scheme. - AddToScheme = SchemeBuilder.AddToScheme -) diff --git a/examples/trino-operator/api/v1alpha1/registration/zz_generated.register.go b/examples/trino-operator/api/v1alpha1/registration/zz_generated.register.go new file mode 100644 index 00000000..faccab02 --- /dev/null +++ b/examples/trino-operator/api/v1alpha1/registration/zz_generated.register.go @@ -0,0 +1,28 @@ +// Code generated by operator-go inputgen; DO NOT EDIT. +// Package registration connects the generated API to the framework operator. +package registration + +import ( + generated "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" + product0 "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + framework "github.com/zncdatadev/operator-go/pkg/framework" + input "github.com/zncdatadev/operator-go/pkg/framework/input" + operator "github.com/zncdatadev/operator-go/pkg/framework/operator" + ctrl "sigs.k8s.io/controller-runtime" +) + +const InputContractVersion = 1 + +// Options supplies product facts and platform settings without pipeline bindings. +type Options[F any] = operator.Options[product0.TrinoConfig, product0.TrinoClusterConfig, F] + +// Register fixes the generated API's C/S types; F is inferred from the definition +// and deployment options. It registers the controller but does not start manager. +func Register[F any](manager ctrl.Manager, + definition framework.ProductDefinition[product0.TrinoConfig, product0.TrinoClusterConfig, F], options Options[F], +) error { + if err := input.CheckVersion(InputContractVersion); err != nil { + return err + } + return operator.Register(manager, definition, options, generated.Binding()) +} diff --git a/examples/trino-operator/api/v1alpha1/sample_test.go b/examples/trino-operator/api/v1alpha1/sample_test.go new file mode 100644 index 00000000..b3973039 --- /dev/null +++ b/examples/trino-operator/api/v1alpha1/sample_test.go @@ -0,0 +1,180 @@ +package v1alpha1_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" + "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1/registration" + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/yaml" +) + +func sample(t *testing.T) *v1alpha1.TrinoCluster { + t.Helper() + data, err := os.ReadFile("../../config/samples/trino_v1alpha1_trinocluster.yaml") + if err != nil { + t.Fatal(err) + } + jsonData, err := yaml.YAMLToJSON(data) + if err != nil { + t.Fatal(err) + } + object, err := v1alpha1.Decode(jsonData) + if err != nil { + t.Fatal(err) + } + return object +} + +func checkSample(t *testing.T, object *v1alpha1.TrinoCluster) { + t.Helper() + projected, err := v1alpha1.Project(object) + if err != nil { + t.Fatal(err) + } + if len(projected.Roles) != 2 || len(projected.Roles[0].Groups) != 1 || len(projected.Roles[1].Groups) != 1 { + t.Fatalf("sample topology lost during projection: %+v", projected.Roles) + } + if !bytes.Contains(projected.Roles[1].Config, []byte(`"level":"OFF"`)) { + t.Fatalf("YAML OFF must remain a string through API projection: %s", projected.Roles[1].Config) + } + if bytes.Contains(projected.Roles[1].Overrides.PodOverrides, []byte(`"startupProbe"`)) || + projected.Roles[1].Replicas == nil || *projected.Roles[1].Replicas != 1 || + projected.Roles[1].Groups[0].Replicas != nil { + t.Fatal("sample reintroduced a duplicate probe patch or lost role/group replica presence") + } + if object.Spec.ClusterConfig.Stopped == nil || *object.Spec.ClusterConfig.Stopped || + object.Spec.ClusterConfig.ReconciliationPaused == nil || *object.Spec.ClusterConfig.ReconciliationPaused { + t.Fatal("explicit false operation controls did not survive") + } + if bytes.Contains(projected.ClusterConfig, []byte("stopped")) { + t.Fatal("product cluster configuration must exclude operation controls") + } +} + +func TestPublishedSampleDecodeAndProjection(t *testing.T) { + checkSample(t, sample(t)) +} + +func TestPublishedSampleAPIRoundtrip(t *testing.T) { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + t.Skip("set KUBEBUILDER_ASSETS to run the real API-server sample roundtrip") + } + for _, name := range []string{"etcd", "kube-apiserver"} { + if _, err := os.Stat(filepath.Join(assets, name)); err != nil { + t.Fatalf("explicit envtest assets are unavailable: %v", err) + } + } + environment := &envtest.Environment{BinaryAssetsDirectory: assets, + CRDDirectoryPaths: []string{"../../config/crd/bases", "../../../../config/framework-data/bases"}, ErrorIfCRDPathMissing: true} + configuration, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + apiClient, err := client.New(configuration, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + if err := apiClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "sample-test"}}); err != nil { + t.Fatal(err) + } + object := sample(t) + object.Namespace = "sample-test" + if err := apiClient.Create(ctx, object, client.FieldValidation("Strict")); err != nil { + t.Fatal(err) + } + observed := &v1alpha1.TrinoCluster{} + if err := apiClient.Get(ctx, types.NamespacedName{Name: object.Name, Namespace: object.Namespace}, observed); err != nil { + t.Fatal(err) + } + checkSample(t, observed) + checkSampleResources(t, ctx, configuration, scheme, apiClient, object) +} + +func checkSampleResources(t *testing.T, ctx context.Context, configuration *rest.Config, scheme *runtime.Scheme, + apiClient client.Client, object *v1alpha1.TrinoCluster, +) { + t.Helper() + ctx, cancel := context.WithCancel(ctx) + manager, err := ctrl.NewManager(configuration, ctrl.Options{Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0"}) + if err != nil { + t.Fatal(err) + } + if err := registration.Register(manager, product.Definition(), registration.Options[product.TrinoFacts]{ + Facts: product.BaseFacts(), ResolveFacts: product.ResolveFacts, + Assembly: framework.AssemblyOptions{MaterializerImage: "example.invalid/materializer:1", VectorImage: "example.invalid/vector:1"}, + }); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- manager.Start(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(10 * time.Second): + t.Error("manager did not stop") + } + }) + for { + set := &appsv1.StatefulSet{} + err := apiClient.Get(ctx, types.NamespacedName{Namespace: object.Namespace, Name: object.Name + "-workers-default"}, set) + if err == nil { + var main *corev1.Container + for i := range set.Spec.Template.Spec.Containers { + if set.Spec.Template.Spec.Containers[i].Name == "trino" { + main = &set.Spec.Template.Spec.Containers[i] + } + } + if main == nil || main.StartupProbe == nil || main.StartupProbe.Exec == nil || main.ReadinessProbe == nil || main.ReadinessProbe.Exec == nil { + t.Fatal("formal sample did not generate native product probes") + } + init := set.Spec.Template.Spec.InitContainers + if len(init) != 2 || init[0].Name != "prepare-files" || init[1].Name != "initialize-trino" || len(init[1].Command) == 0 || + set.Spec.PodManagementPolicy != appsv1.OrderedReadyPodManagement { + t.Fatal("formal sample lost ordered initialization") + } + break + } + select { + case <-ctx.Done(): + t.Fatal("formal sample did not produce a StatefulSet", err) + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/examples/trino-operator/api/v1alpha1/trinocluster_types.go b/examples/trino-operator/api/v1alpha1/trinocluster_types.go deleted file mode 100644 index a11234e2..00000000 --- a/examples/trino-operator/api/v1alpha1/trinocluster_types.go +++ /dev/null @@ -1,183 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" -) - -// TrinoClusterSpec defines the desired state of TrinoCluster -// +kubebuilder:subresource:status -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status" -// +kubebuilder:printcolumn:name="Workers",type="integer",JSONPath=".status.registeredWorkers" -// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" -type TrinoClusterSpec struct { - // ClusterOperation controls operator behavior at runtime. - // Allows pausing reconciliation or stopping the cluster gracefully. - // +kubebuilder:validation:Optional - ClusterOperation *commonsv1alpha1.ClusterOperationSpec `json:"clusterOperation,omitempty"` - - // Image specifies the Trino container image configuration. - // If not set, the webhook defaulter will provide product defaults. - // +kubebuilder:validation:Optional - Image *commonsv1alpha1.ImageSpec `json:"image,omitempty"` - - // ClusterConfig holds cluster-wide configuration shared by all roles. - // +kubebuilder:validation:Optional - ClusterConfig *ClusterConfigSpec `json:"clusterConfig,omitempty"` - - // Coordinators defines the Coordinators role configuration (plural naming) - // Coordinator is responsible for query coordination, metadata management, and client request handling - Coordinators *CoordinatorsSpec `json:"coordinators,omitempty"` - - // Workers defines the Workers role configuration (plural naming) - // Worker is responsible for executing query tasks and can be horizontally scaled - Workers *WorkersSpec `json:"workers,omitempty"` - - // Catalogs defines the data source Catalog configuration list - // Supports Hive, Iceberg, Kafka, MySQL, PostgreSQL, Delta, etc. - Catalogs []CatalogSpec `json:"catalogs,omitempty"` -} - -// ClusterConfigSpec holds cluster-wide configuration shared by all roles. -type ClusterConfigSpec struct { - // VectorAggregatorConfigMapName is the name of a ConfigMap carrying the Vector aggregator - // discovery address. When set and a role group enables the Vector agent, the operator-go - // framework resolves the address and generates vector.yaml into the role group ConfigMap - // (via the reconciler.VectorAggregatorProvider seam that TrinoCluster implements below). - // +kubebuilder:validation:Optional - VectorAggregatorConfigMapName *string `json:"vectorAggregatorConfigMapName,omitempty"` -} - -// CoordinatorsSpec defines the Coordinators role configuration -type CoordinatorsSpec struct { - // Embed generic role spec, including RoleGroups, Overrides, etc. - commonsv1alpha1.RoleSpec `json:",inline"` - - // DiscoveryEnabled indicates whether to enable Discovery service (for Worker discovery) - // +kubebuilder:default=true - DiscoveryEnabled bool `json:"discoveryEnabled,omitempty"` - - // HTTPPort is the HTTP API port - // +kubebuilder:default=8080 - HTTPPort int32 `json:"httpPort,omitempty"` -} - -// WorkersSpec defines the Workers role configuration -type WorkersSpec struct { - // Embed generic role spec, including RoleGroups, Overrides, etc. - commonsv1alpha1.RoleSpec `json:",inline"` - - // HTTPPort is the HTTP API port - // +kubebuilder:default=8080 - HTTPPort int32 `json:"httpPort,omitempty"` -} - -// CatalogSpec defines the data source Catalog configuration -type CatalogSpec struct { - // Name is the Catalog name (e.g., hive, iceberg, kafka) - Name string `json:"name"` - - // Type is the Catalog type - // +kubebuilder:validation:Enum=hive;iceberg;kafka;mysql;postgresql;delta;tpch;tpcds - Type string `json:"type"` - - // Properties are the Catalog configuration properties (key-value form) - Properties map[string]string `json:"properties,omitempty"` -} - -// TrinoClusterStatus defines the observed state of TrinoCluster -type TrinoClusterStatus struct { - // Embed generic cluster status, including Conditions, RoleGroups, etc. - commonsv1alpha1.GenericClusterStatus `json:",inline"` - - // ==================== Trino Specific Status ==================== - - // RegisteredWorkers is the number of registered Workers - RegisteredWorkers int32 `json:"registeredWorkers,omitempty"` - - // CatalogsReady is the list of ready Catalogs - CatalogsReady []string `json:"catalogsReady,omitempty"` -} - -// +kubebuilder:object:root=true - -// TrinoCluster is the CRD for Trino cluster -// TrinoClusterList contains a list of TrinoCluster -type TrinoCluster struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec TrinoClusterSpec `json:"spec,omitempty"` - Status TrinoClusterStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true - -// TrinoClusterList contains a list of TrinoCluster -type TrinoClusterList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []TrinoCluster `json:"items"` -} - -// ==================== ClusterInterface Implementation ==================== -// This is the key to using operator-go SDK: implement ClusterInterface. Everything else it -// requires — the metadata accessors, the object kind, DeepCopyObject, DeepCopy — comes from the -// embedded TypeMeta/ObjectMeta and controller-gen's generated deep-copy code. - -// GetSpec builds and returns a GenericClusterSpec from the typed role fields. -// This bridges the type-safe coordinators/workers fields to the SDK framework's -// generic Roles map, without exposing a redundant spec.roles field in the CRD. -func (t *TrinoCluster) GetSpec() *commonsv1alpha1.GenericClusterSpec { - roles := make(map[string]commonsv1alpha1.RoleSpec) - if t.Spec.Coordinators != nil { - roles["coordinators"] = t.Spec.Coordinators.RoleSpec - } - if t.Spec.Workers != nil { - roles["workers"] = t.Spec.Workers.RoleSpec - } - return &commonsv1alpha1.GenericClusterSpec{ - Image: t.Spec.Image, - ClusterOperation: t.Spec.ClusterOperation, - Roles: roles, - } -} - -// VectorAggregatorConfigMapName implements reconciler.VectorAggregatorProvider, letting the -// framework own vector.yaml generation. It returns "" when unset; when the Vector agent is active -// for a role group (enabled with a declared producer) that is a misconfiguration and the -// reconciler fails loudly, otherwise it is not consulted. -func (t *TrinoCluster) VectorAggregatorConfigMapName() string { - if t.Spec.ClusterConfig == nil || t.Spec.ClusterConfig.VectorAggregatorConfigMapName == nil { - return "" - } - return *t.Spec.ClusterConfig.VectorAggregatorConfigMapName -} - -// GetStatus returns the generic cluster status the framework writes conditions and role group -// state into. It is a pointer into the CR, so RegisteredWorkers and CatalogsReady survive a -// reconcile cycle untouched. -func (t *TrinoCluster) GetStatus() *commonsv1alpha1.GenericClusterStatus { - return &t.Status.GenericClusterStatus -} - -func init() { - SchemeBuilder.Register(&TrinoCluster{}, &TrinoClusterList{}) -} diff --git a/examples/trino-operator/api/v1alpha1/trinocluster_types_test.go b/examples/trino-operator/api/v1alpha1/trinocluster_types_test.go deleted file mode 100644 index 92ff9891..00000000 --- a/examples/trino-operator/api/v1alpha1/trinocluster_types_test.go +++ /dev/null @@ -1,239 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 v1alpha1_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/common" -) - -func TestAPITypes(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "API Types Suite") -} - -var _ = Describe("TrinoCluster", func() { - var cr *trinov1alpha1.TrinoCluster - - BeforeEach(func() { - cr = &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-trino", - Namespace: "default", - }, - } - }) - - Describe("GetSpec", func() { - Context("when coordinators and workers are nil", func() { - It("should return empty Roles map", func() { - spec := cr.GetSpec() - Expect(spec).NotTo(BeNil()) - Expect(spec.Roles).To(BeEmpty()) - }) - - It("should return nil ClusterOperation", func() { - spec := cr.GetSpec() - Expect(spec.ClusterOperation).To(BeNil()) - }) - }) - - Context("when coordinators is set", func() { - BeforeEach(func() { - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {Replicas: int32Ptr(1)}, - }, - }, - HTTPPort: 8080, - } - }) - - It("should include coordinators in Roles map", func() { - spec := cr.GetSpec() - Expect(spec.Roles).To(HaveKey("coordinators")) - }) - - It("should carry coordinators RoleGroups", func() { - spec := cr.GetSpec() - Expect(spec.Roles["coordinators"].RoleGroups).To(HaveKey("default")) - }) - - It("should not include workers when not set", func() { - spec := cr.GetSpec() - Expect(spec.Roles).NotTo(HaveKey("workers")) - }) - }) - - Context("when workers is set", func() { - BeforeEach(func() { - cr.Spec.Workers = &trinov1alpha1.WorkersSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {Replicas: int32Ptr(3)}, - }, - }, - } - }) - - It("should include workers in Roles map", func() { - spec := cr.GetSpec() - Expect(spec.Roles).To(HaveKey("workers")) - }) - - It("should carry workers RoleGroups", func() { - spec := cr.GetSpec() - Expect(spec.Roles["workers"].RoleGroups).To(HaveKey("default")) - }) - - It("should not include coordinators when not set", func() { - spec := cr.GetSpec() - Expect(spec.Roles).NotTo(HaveKey("coordinators")) - }) - }) - - Context("when both coordinators and workers are set", func() { - BeforeEach(func() { - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {Replicas: int32Ptr(1)}, - }, - }, - } - cr.Spec.Workers = &trinov1alpha1.WorkersSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {Replicas: int32Ptr(3)}, - }, - }, - } - }) - - It("should include both in Roles map", func() { - spec := cr.GetSpec() - Expect(spec.Roles).To(HaveLen(2)) - Expect(spec.Roles).To(HaveKey("coordinators")) - Expect(spec.Roles).To(HaveKey("workers")) - }) - }) - - Context("when ClusterOperation is set", func() { - BeforeEach(func() { - stopped := true - cr.Spec.ClusterOperation = &commonsv1alpha1.ClusterOperationSpec{ - Stopped: stopped, - } - }) - - It("should pass ClusterOperation through", func() { - spec := cr.GetSpec() - Expect(spec.ClusterOperation).NotTo(BeNil()) - Expect(spec.ClusterOperation.Stopped).To(BeTrue()) - }) - }) - - Context("returned spec is independent of CR", func() { - It("should return a new GenericClusterSpec each call", func() { - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{} - spec1 := cr.GetSpec() - spec2 := cr.GetSpec() - // Both point to different structs - Expect(spec1).NotTo(BeIdenticalTo(spec2)) - }) - }) - }) - - Describe("GetStatus", func() { - It("should return a pointer to the embedded status", func() { - status := cr.GetStatus() - Expect(status).NotTo(BeNil()) - Expect(status).To(BeIdenticalTo(&cr.Status.GenericClusterStatus)) - }) - - It("should let the framework write generic status without touching product fields", func() { - cr.Status.RegisteredWorkers = 3 - cr.Status.CatalogsReady = []string{"hive"} - - cr.GetStatus().ObservedGeneration = 42 - - Expect(cr.Status.ObservedGeneration).To(Equal(int64(42))) - Expect(cr.Status.RegisteredWorkers).To(Equal(int32(3))) - Expect(cr.Status.CatalogsReady).To(Equal([]string{"hive"})) - }) - }) - - Describe("ClusterInterface conformance", func() { - // The SDK reads a fetched CR straight into the object it deep-copied from the prototype, - // so the CR type itself is what has to be a client.Object and what DeepCopy has to - // return. Both come from the embedded ObjectMeta/TypeMeta and generated deep-copy code. - It("should satisfy the SDK contract without hand-written plumbing", func() { - var _ common.ClusterInterface = cr - var _ common.ClusterResource[*trinov1alpha1.TrinoCluster] = cr - }) - - It("should deep copy into its own concrete type", func() { - cr.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"} - - copied := cr.DeepCopy() - - Expect(copied).NotTo(BeIdenticalTo(cr)) - Expect(copied.GetName()).To(Equal(cr.GetName())) - Expect(copied.Spec.Image).To(Equal(cr.Spec.Image)) - Expect(copied.Spec.Image).NotTo(BeIdenticalTo(cr.Spec.Image)) - }) - }) - - Describe("TrinoClusterSpec structure", func() { - It("should not expose a top-level roles field in JSON", func() { - // Verify spec fields are the typed ones only - cr.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"} - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{} - cr.Spec.Workers = &trinov1alpha1.WorkersSpec{} - - // GetSpec() should build roles dynamically - never stored in spec - spec := cr.GetSpec() - Expect(spec.Roles).To(HaveLen(2)) - }) - - It("CoordinatorsSpec should carry role-level config through embedded RoleSpec", func() { - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - ConfigOverrides: map[string]map[string]string{ - "config.properties": {"key": "value"}, - }, - }, - HTTPPort: 9090, - } - spec := cr.GetSpec() - coordRole := spec.Roles["coordinators"] - Expect(coordRole.ConfigOverrides).To(HaveKey("config.properties")) - }) - }) -}) - -func int32Ptr(i int32) *int32 { - return &i -} diff --git a/examples/trino-operator/api/v1alpha1/zz_generated.deepcopy.go b/examples/trino-operator/api/v1alpha1/zz_generated.deepcopy.go deleted file mode 100644 index fdc835d3..00000000 --- a/examples/trino-operator/api/v1alpha1/zz_generated.deepcopy.go +++ /dev/null @@ -1,227 +0,0 @@ -//go:build !ignore_autogenerated - -/* -Copyright 2026 ZNCDataDev. - -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. -*/ - -// Code generated by controller-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CatalogSpec) DeepCopyInto(out *CatalogSpec) { - *out = *in - if in.Properties != nil { - in, out := &in.Properties, &out.Properties - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CatalogSpec. -func (in *CatalogSpec) DeepCopy() *CatalogSpec { - if in == nil { - return nil - } - out := new(CatalogSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterConfigSpec) DeepCopyInto(out *ClusterConfigSpec) { - *out = *in - if in.VectorAggregatorConfigMapName != nil { - in, out := &in.VectorAggregatorConfigMapName, &out.VectorAggregatorConfigMapName - *out = new(string) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterConfigSpec. -func (in *ClusterConfigSpec) DeepCopy() *ClusterConfigSpec { - if in == nil { - return nil - } - out := new(ClusterConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CoordinatorsSpec) DeepCopyInto(out *CoordinatorsSpec) { - *out = *in - in.RoleSpec.DeepCopyInto(&out.RoleSpec) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoordinatorsSpec. -func (in *CoordinatorsSpec) DeepCopy() *CoordinatorsSpec { - if in == nil { - return nil - } - out := new(CoordinatorsSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TrinoCluster) DeepCopyInto(out *TrinoCluster) { - *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 TrinoCluster. -func (in *TrinoCluster) DeepCopy() *TrinoCluster { - if in == nil { - return nil - } - out := new(TrinoCluster) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *TrinoCluster) 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 *TrinoClusterList) DeepCopyInto(out *TrinoClusterList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]TrinoCluster, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrinoClusterList. -func (in *TrinoClusterList) DeepCopy() *TrinoClusterList { - if in == nil { - return nil - } - out := new(TrinoClusterList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *TrinoClusterList) 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 *TrinoClusterSpec) DeepCopyInto(out *TrinoClusterSpec) { - *out = *in - if in.ClusterOperation != nil { - in, out := &in.ClusterOperation, &out.ClusterOperation - *out = new(commonsv1alpha1.ClusterOperationSpec) - **out = **in - } - if in.Image != nil { - in, out := &in.Image, &out.Image - *out = new(commonsv1alpha1.ImageSpec) - **out = **in - } - if in.ClusterConfig != nil { - in, out := &in.ClusterConfig, &out.ClusterConfig - *out = new(ClusterConfigSpec) - (*in).DeepCopyInto(*out) - } - if in.Coordinators != nil { - in, out := &in.Coordinators, &out.Coordinators - *out = new(CoordinatorsSpec) - (*in).DeepCopyInto(*out) - } - if in.Workers != nil { - in, out := &in.Workers, &out.Workers - *out = new(WorkersSpec) - (*in).DeepCopyInto(*out) - } - if in.Catalogs != nil { - in, out := &in.Catalogs, &out.Catalogs - *out = make([]CatalogSpec, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrinoClusterSpec. -func (in *TrinoClusterSpec) DeepCopy() *TrinoClusterSpec { - if in == nil { - return nil - } - out := new(TrinoClusterSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TrinoClusterStatus) DeepCopyInto(out *TrinoClusterStatus) { - *out = *in - in.GenericClusterStatus.DeepCopyInto(&out.GenericClusterStatus) - if in.CatalogsReady != nil { - in, out := &in.CatalogsReady, &out.CatalogsReady - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrinoClusterStatus. -func (in *TrinoClusterStatus) DeepCopy() *TrinoClusterStatus { - if in == nil { - return nil - } - out := new(TrinoClusterStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WorkersSpec) DeepCopyInto(out *WorkersSpec) { - *out = *in - in.RoleSpec.DeepCopyInto(&out.RoleSpec) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkersSpec. -func (in *WorkersSpec) DeepCopy() *WorkersSpec { - if in == nil { - return nil - } - out := new(WorkersSpec) - in.DeepCopyInto(out) - return out -} diff --git a/examples/trino-operator/api/v1alpha1/zz_generated.input.go b/examples/trino-operator/api/v1alpha1/zz_generated.input.go new file mode 100644 index 00000000..45dc7716 --- /dev/null +++ b/examples/trino-operator/api/v1alpha1/zz_generated.input.go @@ -0,0 +1,339 @@ +// Code generated by operator-go inputgen; DO NOT EDIT. +package v1alpha1 + +import ( + "encoding/json" + "fmt" + "sort" + + 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/runtime/schema" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const InputContractVersion = 1 + +var GroupVersion = schema.GroupVersion{Group: "trino.kubedoop.dev", Version: "v1alpha1"} +var SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(GroupVersion, &TrinoCluster{}, &TrinoClusterList{}) + metav1.AddToGroupVersion(scheme, GroupVersion) + return nil +}) +var AddToScheme = SchemeBuilder.AddToScheme + +type TrinoCluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec SpecInput `json:"spec"` + Status framework.ReconcileStatus `json:"status,omitempty"` +} + +type TrinoClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TrinoCluster `json:"items"` +} + +type SpecInput struct { + Image *input.ImageInput `json:"image,omitempty"` + ClusterConfig *ClusterConfigInput `json:"clusterConfig,omitempty"` + Coordinators *RoleInput `json:"coordinators,omitempty"` + Workers *RoleInput `json:"workers,omitempty"` +} + +type RoleInput struct { + RoleConfig *input.RoleConfigInput `json:"roleConfig,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` + Config *ConfigInput `json:"config,omitempty"` + ConfigOverrides *map[string]input.FileOverride `json:"configOverrides,omitempty"` + EnvOverrides *map[string]string `json:"envOverrides,omitempty"` + CLIOverrides *[]string `json:"cliOverrides,omitempty"` + PodOverrides json.RawMessage `json:"podOverrides,omitempty"` + RoleGroups map[string]RoleGroupInput `json:"roleGroups,omitempty"` +} + +type RoleGroupInput struct { + Replicas *int32 `json:"replicas,omitempty"` + Config *ConfigInput `json:"config,omitempty"` + ConfigOverrides *map[string]input.FileOverride `json:"configOverrides,omitempty"` + EnvOverrides *map[string]string `json:"envOverrides,omitempty"` + CLIOverrides *[]string `json:"cliOverrides,omitempty"` + PodOverrides json.RawMessage `json:"podOverrides,omitempty"` +} + +type ConfigInputResourcesCPU struct { + Min *resource.Quantity `json:"min,omitempty"` + Max *resource.Quantity `json:"max,omitempty"` +} + +type ConfigInputResourcesMemory struct { + Limit *resource.Quantity `json:"limit,omitempty"` +} + +type ConfigInputResourcesStorage struct { + Type *string `json:"type,omitempty"` + StorageClassName *string `json:"storageClassName,omitempty"` + Capacity *resource.Quantity `json:"capacity,omitempty"` +} + +type ConfigInputResources struct { + CPU *ConfigInputResourcesCPU `json:"cpu,omitempty"` + Memory *ConfigInputResourcesMemory `json:"memory,omitempty"` + Storage *ConfigInputResourcesStorage `json:"storage,omitempty"` +} + +type ConfigInputLoggingContainersValueConsole struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValueFile struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValueLoggersValue struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValue struct { + Console *ConfigInputLoggingContainersValueConsole `json:"console,omitempty"` + File *ConfigInputLoggingContainersValueFile `json:"file,omitempty"` + Loggers *map[string]ConfigInputLoggingContainersValueLoggersValue `json:"loggers,omitempty"` +} + +type ConfigInputLogging struct { + EnableVectorAgent *bool `json:"enableVectorAgent,omitempty"` + Containers *map[string]ConfigInputLoggingContainersValue `json:"containers,omitempty"` +} + +type ConfigInputHiveS3InlineCredentials struct { + SecretName *string `json:"secretName,omitempty"` + SecretClass *string `json:"secretClass,omitempty"` + Scope *[]string `json:"scope,omitempty"` +} + +type ConfigInputHiveS3Inline struct { + Host *string `json:"host,omitempty"` + Port *int32 `json:"port,omitempty"` + TLS *bool `json:"tls,omitempty"` + Region *string `json:"region,omitempty"` + PathStyle *bool `json:"pathStyle,omitempty"` + Credentials *ConfigInputHiveS3InlineCredentials `json:"credentials,omitempty"` +} + +type ConfigInputHiveS3 struct { + Type *string `json:"type,omitempty"` + Inline *ConfigInputHiveS3Inline `json:"inline,omitempty"` + Reference *string `json:"reference,omitempty"` +} + +type ConfigInputHive struct { + MetastoreURI *string `json:"metastoreURI,omitempty"` + S3 *ConfigInputHiveS3 `json:"s3,omitempty"` +} + +type ConfigInput struct { + Resources *ConfigInputResources `json:"resources,omitempty"` + Logging *ConfigInputLogging `json:"logging,omitempty"` + Affinity *corev1.Affinity `json:"affinity,omitempty"` + GracefulShutdownTimeout *metav1.Duration `json:"gracefulShutdownTimeout,omitempty"` + Hive *ConfigInputHive `json:"hive,omitempty"` + HTTPPort *int32 `json:"httpPort,omitempty"` + ShutdownUser *string `json:"shutdownUser,omitempty"` + ShutdownCredentialsSecret *string `json:"shutdownCredentialsSecret,omitempty"` + CatalogConfigMapName *string `json:"catalogConfigMapName,omitempty"` +} + +type ClusterConfigInputAuthenticationItemOIDC struct { + ClientCredentialsSecret *string `json:"clientCredentialsSecret,omitempty"` + ExtraScopes *[]string `json:"extraScopes,omitempty"` +} + +type ClusterConfigInputAuthenticationItem struct { + AuthenticationClass *string `json:"authenticationClass,omitempty"` + OIDC *ClusterConfigInputAuthenticationItemOIDC `json:"oidc,omitempty"` +} + +type ClusterConfigInput struct { + Stopped *bool `json:"stopped,omitempty"` + ReconciliationPaused *bool `json:"reconciliationPaused,omitempty"` + VectorAgentConfigMap *string `json:"vectorAgentConfigMap,omitempty"` + Authentication *[]ClusterConfigInputAuthenticationItem `json:"authentication,omitempty"` + ListenerClass *string `json:"listenerClass,omitempty"` + NodeEnvironment *string `json:"nodeEnvironment,omitempty"` + TLSSecret *string `json:"tlsSecret,omitempty"` + TLSSecretClass *string `json:"tlsSecretClass,omitempty"` + InternalSecret *string `json:"internalSecret,omitempty"` +} + +func (in *TrinoCluster) DeepCopyInto(out *TrinoCluster) { + *out = *in + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = input.Clone(in.Spec) + in.Status.DeepCopyInto(&out.Status) +} +func (in *TrinoCluster) DeepCopy() *TrinoCluster { + if in == nil { + return nil + } + out := new(TrinoCluster) + in.DeepCopyInto(out) + return out +} +func (in *TrinoCluster) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + return in.DeepCopy() +} +func (in *TrinoClusterList) DeepCopyInto(out *TrinoClusterList) { + *out = *in + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]TrinoCluster, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} +func (in *TrinoClusterList) DeepCopy() *TrinoClusterList { + if in == nil { + return nil + } + out := new(TrinoClusterList) + in.DeepCopyInto(out) + return out +} +func (in *TrinoClusterList) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + return in.DeepCopy() +} + +// Decode is the strict local JSON entry point. A typed API-server GET is the +// other supported input path; ordinary json.Unmarshal alone loses null values. +func Decode(data []byte) (*TrinoCluster, error) { + var out TrinoCluster + if err := input.DecodeJSON(data, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Operation reads only fixed controls, before projection or product validation. +// It neither marshals product fields nor changes the declared replica inventory. +func Operation(in *TrinoCluster) framework.ClusterOperation { + var out framework.ClusterOperation + if in == nil || in.Spec.ClusterConfig == nil { + return out + } + controls := in.Spec.ClusterConfig + if controls.Stopped != nil { + out.Stopped = *controls.Stopped + } + if controls.ReconciliationPaused != nil { + out.ReconciliationPaused = *controls.ReconciliationPaused + } + return out +} + +// Project preserves raw layer presence and the complete declared role inventory. +// It neither folds replicas/config nor carries facts or runtime control state. +func Project(in *TrinoCluster) (input.Projection, error) { + var out input.Projection + if in == nil { + return out, fmt.Errorf("input CR is required") + } + out.Cluster = framework.ClusterIdentity{ + Name: in.Name, Namespace: in.Namespace, Labels: input.Clone(in.Labels), + } + image, err := input.ConfigJSON(in.Spec.Image) + if err != nil { + return out, fmt.Errorf("image: %w", err) + } + out.Image = image + clusterConfig, err := input.ClusterConfigJSON(in.Spec.ClusterConfig) + if err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + out.ClusterConfig = clusterConfig + roles := []struct { + name string + value *RoleInput + }{ + {"coordinators", in.Spec.Coordinators}, + {"workers", in.Spec.Workers}, + } + for _, entry := range roles { + if entry.value == nil { + continue + } + role := entry.value + management, err := input.ConfigJSON(role.RoleConfig) + if err != nil { + return out, fmt.Errorf("%s.roleConfig: %w", entry.name, err) + } + config, err := input.ConfigJSON(role.Config) + if err != nil { + return out, fmt.Errorf("%s.config: %w", entry.name, err) + } + projected := input.Role{Name: entry.name, Replicas: input.Clone(role.Replicas), + Config: config, RoleConfig: management, + Overrides: inputOverrides(role.ConfigOverrides, role.EnvOverrides, role.CLIOverrides, role.PodOverrides), + } + groups := make([]string, 0, len(role.RoleGroups)) + for name := range role.RoleGroups { + groups = append(groups, name) + } + sort.Strings(groups) + for _, name := range groups { + group := role.RoleGroups[name] + config, err := input.ConfigJSON(group.Config) + if err != nil { + return out, fmt.Errorf("%s/%s.config: %w", entry.name, name, err) + } + projected.Groups = append(projected.Groups, input.Group{ + Name: name, Replicas: input.Clone(group.Replicas), Config: config, + Overrides: inputOverrides( + group.ConfigOverrides, group.EnvOverrides, group.CLIOverrides, group.PodOverrides), + }) + } + out.Roles = append(out.Roles, projected) + } + return out, nil +} + +// Binding is the versioned generated-code bridge, not a product controller. +func Binding() input.Binding[*TrinoCluster] { + return input.Binding[*TrinoCluster]{ + Version: InputContractVersion, + Roles: []string{"coordinators", "workers"}, + AddToScheme: AddToScheme, + NewObject: func() *TrinoCluster { return &TrinoCluster{} }, + Operation: Operation, Project: Project, + Status: func(in *TrinoCluster) *framework.ReconcileStatus { return &in.Status }, + } +} + +func inputOverrides( + files *map[string]input.FileOverride, env *map[string]string, cli *[]string, pod json.RawMessage, +) *input.Overrides { + if files == nil && env == nil && cli == nil && len(pod) == 0 { + return nil + } + out := &input.Overrides{ + CLIOverrides: input.Clone(cli), PodOverrides: input.Clone(pod), + } + if files != nil { + out.ConfigOverrides = input.Clone(*files) + } + if env != nil { + out.EnvOverrides = input.Clone(*env) + } + return out +} diff --git a/examples/trino-operator/cmd/generate/main.go b/examples/trino-operator/cmd/generate/main.go new file mode 100644 index 00000000..193822d2 --- /dev/null +++ b/examples/trino-operator/cmd/generate/main.go @@ -0,0 +1,55 @@ +// Command generate writes or checks the generated input, schema and registration. +package main + +import ( + "flag" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + "github.com/zncdatadev/operator-go/pkg/framework/inputgen" +) + +func main() { + check := flag.Bool("check", false, "check generated files without writing") + flag.Parse() + if err := run(*check); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(check bool) error { + artifacts, err := inputgen.Generate[product.TrinoConfig, product.TrinoClusterConfig](inputgen.Names{ + Package: "v1alpha1", Group: "trino.kubedoop.dev", Version: "v1alpha1", Kind: "TrinoCluster", Plural: "trinoclusters", + ImportPath: "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1", + }, slices.Sorted(maps.Keys(product.Definition().Roles))) + if err != nil { + return err + } + paths := []string{"api/v1alpha1/zz_generated.input.go", "config/crd/bases/trino.kubedoop.dev_trinoclusters.yaml", + "api/v1alpha1/registration/zz_generated.register.go"} + contents := [][]byte{artifacts.GoSource, artifacts.CRD, artifacts.RegistrationSource} + if check { + actual := make([][]byte, len(paths)) + for index, name := range paths { + actual[index], err = os.ReadFile(name) + if err != nil { + return err + } + } + return inputgen.Check(artifacts, actual[0], actual[1], actual[2]) + } + for index, name := range paths { + if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil { + return err + } + if err := os.WriteFile(name, contents[index], 0644); err != nil { + return err + } + } + return nil +} diff --git a/examples/trino-operator/cmd/main.go b/examples/trino-operator/cmd/main.go index d10ecb48..2d235335 100644 --- a/examples/trino-operator/cmd/main.go +++ b/examples/trino-operator/cmd/main.go @@ -1,307 +1,86 @@ -/* -Copyright 2024 ZNCDataDev. - -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. -*/ - +// Command manager runs the Trino reference operator through the generated binding. package main import ( - "crypto/tls" "flag" + "fmt" "os" - "path/filepath" - "slices" "time" - // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) - _ "k8s.io/client-go/plugin/pkg/client/auth" - + "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1/registration" + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" "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/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/cache" "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" - - // Import operator-go SDK - "github.com/zncdatadev/operator-go/pkg/common" - "github.com/zncdatadev/operator-go/pkg/reconciler" - - // Import Trino Operator API - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - - // Import Trino Operator internal implementation - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - trinocontroller "github.com/zncdatadev/operator-go/examples/trino-operator/internal/controller" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/extensions" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - webhookv1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/internal/webhook/v1alpha1" -) - -var ( - scheme = runtime.NewScheme() - setupLog = ctrl.Log.WithName("setup") ) -func init() { - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(trinov1alpha1.AddToScheme(scheme)) -} - -// managerFlags holds the command-line configuration of the manager process. -type managerFlags struct { - metricsAddr string - metricsCertPath string - metricsCertName string - metricsCertKey string - webhookCertPath string - webhookCertName string - webhookCertKey string - probeAddr string - enableLeaderElection bool - secureMetrics bool - enableHTTP2 bool -} - -// registerManagerFlags declares every flag the manager accepts on fs. -// -// The deployment manifests under config/ pass these flags to the container, and an undeclared -// flag aborts flag parsing before the manager ever starts — so this set must stay a superset of -// the args in config/manager/manager.yaml and the config/default patches. -func registerManagerFlags(fs *flag.FlagSet) *managerFlags { - f := &managerFlags{} - fs.StringVar(&f.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.") - fs.StringVar(&f.probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - fs.BoolVar(&f.enableLeaderElection, "leader-elect", false, - "Enable leader election for controller manager. "+ - "Enabling this will ensure there is only one active controller manager.") - fs.BoolVar(&f.secureMetrics, "metrics-secure", true, - "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") - fs.StringVar(&f.webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") - fs.StringVar(&f.webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") - fs.StringVar(&f.webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") - fs.StringVar(&f.metricsCertPath, "metrics-cert-path", "", - "The directory that contains the metrics server certificate.") - fs.StringVar(&f.metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") - fs.StringVar(&f.metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") - fs.BoolVar(&f.enableHTTP2, "enable-http2", false, - "If set, HTTP/2 will be enabled for the metrics and webhook servers") - return f -} - -// buildManagerOptions turns the parsed flags into ctrl.Options. The returned cert watchers must -// be added to the manager so rotated certificates are picked up without a restart. -func buildManagerOptions(f *managerFlags) (ctrl.Options, []*certwatcher.CertWatcher, error) { - // HTTP/2 is disabled by default: it is only needed for very specific use cases and keeping it - // off avoids CVE-2023-44487 and CVE-2023-39325 (Rapid Reset). - var tlsOpts []func(*tls.Config) - if !f.enableHTTP2 { - tlsOpts = append(tlsOpts, func(c *tls.Config) { - c.NextProtos = []string{"http/1.1"} - }) - } - - var watchers []*certwatcher.CertWatcher - - // Clone per server: the webhook and metrics servers each extend the shared base with their - // own GetCertificate. - webhookTLSOpts := slices.Clone(tlsOpts) - if f.webhookCertPath != "" { - webhookCertWatcher, err := certwatcher.New( - filepath.Join(f.webhookCertPath, f.webhookCertName), - filepath.Join(f.webhookCertPath, f.webhookCertKey), - ) - if err != nil { - return ctrl.Options{}, nil, err - } - watchers = append(watchers, webhookCertWatcher) - webhookTLSOpts = append(webhookTLSOpts, func(c *tls.Config) { - c.GetCertificate = webhookCertWatcher.GetCertificate - }) - } - - metricsOptions := metricsserver.Options{ - BindAddress: f.metricsAddr, - SecureServing: f.secureMetrics, - TLSOpts: slices.Clone(tlsOpts), - } - if f.secureMetrics { - // Protect the metrics endpoint with the Kubernetes token authn/authz the - // config/rbac/metrics_auth_role.yaml ClusterRole grants. - metricsOptions.FilterProvider = filters.WithAuthenticationAndAuthorization - } - if f.metricsCertPath != "" { - metricsCertWatcher, err := certwatcher.New( - filepath.Join(f.metricsCertPath, f.metricsCertName), - filepath.Join(f.metricsCertPath, f.metricsCertKey), - ) - if err != nil { - return ctrl.Options{}, nil, err - } - watchers = append(watchers, metricsCertWatcher) - metricsOptions.TLSOpts = append(metricsOptions.TLSOpts, func(c *tls.Config) { - c.GetCertificate = metricsCertWatcher.GetCertificate - }) - } - - return ctrl.Options{ - Scheme: scheme, - Metrics: metricsOptions, - WebhookServer: webhook.NewServer(webhook.Options{TLSOpts: webhookTLSOpts}), - HealthProbeBindAddress: f.probeAddr, - LeaderElection: f.enableLeaderElection, - LeaderElectionID: "a3f6b8c9.kubedoop.dev", - }, watchers, nil -} - -// newExtensionRegistry builds the extension registry for TrinoCluster reconciliation. -// -// The registry is instantiated for the product's own CR type, which is what lets the extensions -// declare *TrinoCluster in their hooks instead of the SDK's wide ClusterInterface. It is handed -// to exactly one reconciler (GenericReconcilerConfig.ExtensionRegistry); an operator that manages -// several CR types builds one registry per type. -func newExtensionRegistry(scheme *runtime.Scheme) *common.ExtensionRegistry[*trinov1alpha1.TrinoCluster] { - registry := common.NewExtensionRegistry[*trinov1alpha1.TrinoCluster]() - - // Register Catalog extension (demonstrates ClusterExtension) - registry.RegisterClusterExtension(extensions.NewCatalogExtension()) - - // Register Health extension (demonstrates RoleExtension) - registry.RegisterRoleExtension(extensions.NewHealthExtension()) - - // Register Discovery extension (demonstrates ClusterExtension PostReconcile + - // reconciler.EnsureDiscoveryConfigMap): publishes the coordinator URI in a discovery - // ConfigMap named after the cluster, the kubedoop pattern every product follows. It runs - // after the catalog extension has refreshed the status, so it is registered at a lower - // priority rather than relying on registration order alone. - registry.RegisterClusterExtension(extensions.NewDiscoveryExtension(scheme), common.WithPriority(common.PriorityLow)) - - return registry -} - func main() { - flags := registerManagerFlags(flag.CommandLine) - opts := zap.Options{ - Development: true, - } - opts.BindFlags(flag.CommandLine) + var materializerImage, vectorImage, namespace, metricsAddress, probeAddress, electionNamespace string + var leaderElection bool + var refreshInterval time.Duration + flag.StringVar(&materializerImage, "materializer-image", "", "co-released materialization helper image (required)") + flag.StringVar(&vectorImage, "vector-image", "", "Vector image for enabled file collection (required)") + flag.StringVar(&namespace, "namespace", "", "watch one namespace; empty watches all namespaces") + flag.StringVar(&metricsAddress, "metrics-bind-address", "0", "metrics address; 0 disables metrics") + flag.StringVar(&probeAddress, "health-probe-bind-address", ":8081", "manager health endpoint address") + flag.StringVar(&electionNamespace, "leader-election-namespace", os.Getenv("POD_NAMESPACE"), + "leader election namespace") + flag.BoolVar(&leaderElection, "leader-elect", false, "enable leader election") + flag.DurationVar(&refreshInterval, "fact-refresh-interval", 30*time.Second, "external fact refresh interval") + logging := zap.Options{} + logging.BindFlags(flag.CommandLine) flag.Parse() - - ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - - mgrOpts, certWatchers, err := buildManagerOptions(flags) - if err != nil { - setupLog.Error(err, "unable to build manager options") + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&logging))) + if err := start(materializerImage, vectorImage, namespace, metricsAddress, probeAddress, electionNamespace, + leaderElection, refreshInterval); err != nil { + ctrl.Log.Error(err, "Trino operator stopped") os.Exit(1) } +} - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) - if err != nil { - setupLog.Error(err, "unable to start manager") - os.Exit(1) +func start(materializerImage, vectorImage, namespace, metricsAddress, probeAddress, electionNamespace string, + leaderElection bool, refreshInterval time.Duration, +) error { + if materializerImage == "" || vectorImage == "" { + return fmt.Errorf("--materializer-image and --vector-image are required") } - - for _, watcher := range certWatchers { - if err := mgr.Add(watcher); err != nil { - setupLog.Error(err, "unable to add certificate watcher to manager") - os.Exit(1) - } + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return err } - - // ==================== Register Extensions ==================== - // This is the key to using operator-go SDK extension mechanism - - extensionRegistry := newExtensionRegistry(mgr.GetScheme()) - - // ==================== Create GenericReconciler ==================== - // Use operator-go SDK's GenericReconciler instead of traditional Controller - - // Create RoleGroupHandler (embeds the SDK BaseRoleGroupHandler; the framework owns - // resource orchestration). - roleGroupHandler := trinocontroller.NewTrinoRoleGroupHandler(mgr.GetScheme()) - - // Create GenericReconciler config - reconcilerCfg := &reconciler.GenericReconcilerConfig[*trinov1alpha1.TrinoCluster]{ - Client: mgr.GetClient(), - // Uncached: used to refresh the resourceVersion after a conflicting status write, which - // the informer cache is by definition too stale to serve. - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - //nolint:staticcheck // TODO: migrate to GetEventRecorder when SDK supports new events API - Recorder: mgr.GetEventRecorderFor("trino-cluster-controller"), - RoleGroupHandler: roleGroupHandler, - // The handler also declares this product's roles, once per reconcile pass with the cr in - // hand — ports, primary container name, log producers. - RoleProvider: roleGroupHandler, - // The product's derived config flows through the SDK merge pipeline as the lowest layer; - // any CRD configOverrides always win over it. - RoleGroupResolver: reconciler.RoleGroupResolverFunc[*trinov1alpha1.TrinoCluster]( - product.ComputeConfig), - // Read every reconcile, so an operator upgrade moves existing clusters onto the co-released - // product image. A mutating webhook cannot do this: its defaults are persisted at admission - // and never recomputed, freezing kubedoopVersion at whatever version first admitted the CR. - ImageResolution: reconciler.ImageResolution{ - ProductName: constants.ProductName, - Defaults: constants.ImageDefaults(), - }, - HealthCheckInterval: 120 * time.Second, - HealthCheckTimeout: 300 * time.Second, - Prototype: &trinov1alpha1.TrinoCluster{}, - // The reconciler owns its extensions; nothing outside this process-local registry can - // inject a hook into a TrinoCluster reconcile. - ExtensionRegistry: extensionRegistry, + options := ctrl.Options{Scheme: scheme, Metrics: metricsserver.Options{BindAddress: metricsAddress}, + HealthProbeBindAddress: probeAddress, LeaderElection: leaderElection, + LeaderElectionID: "trino.operator.kubedoop.dev", LeaderElectionNamespace: electionNamespace} + if namespace != "" { + options.Cache = cache.Options{DefaultNamespaces: map[string]cache.Config{namespace: {}}} } - - // Create GenericReconciler - trinoReconciler, err := reconciler.NewGenericReconciler(reconcilerCfg) + configuration, err := ctrl.GetConfig() if err != nil { - setupLog.Error(err, "unable to create reconciler") - os.Exit(1) - } - - // Use GenericReconciler's SetupWithManager to register Controller - if err := trinoReconciler.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "TrinoCluster") - os.Exit(1) + return err } - - // ==================== Register Webhooks ==================== - // Register TrinoCluster webhook for validation and defaulting - if err := webhookv1alpha1.SetupTrinoClusterWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "TrinoCluster") - os.Exit(1) + manager, err := ctrl.NewManager(configuration, options) + if err != nil { + return err } - - // ==================== Health Checks ==================== - - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up health check") - os.Exit(1) + uid, nonRoot := int64(1001), true + if err := registration.Register(manager, product.Definition(), registration.Options[product.TrinoFacts]{ + Facts: product.BaseFacts(), ResolveFacts: product.ResolveFacts, FactRefreshInterval: refreshInterval, + Assembly: framework.AssemblyOptions{MaterializerImage: materializerImage, VectorImage: vectorImage, + HelperIdentity: &corev1.SecurityContext{RunAsUser: &uid, RunAsGroup: &uid, RunAsNonRoot: &nonRoot}}, + }); err != nil { + return err } - if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up ready check") - os.Exit(1) + if err := manager.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return err } - - setupLog.Info("starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - setupLog.Error(err, "problem running manager") - os.Exit(1) + if err := manager.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return err } + return manager.Start(ctrl.SetupSignalHandler()) } diff --git a/examples/trino-operator/cmd/main_test.go b/examples/trino-operator/cmd/main_test.go index 21c03194..c2480917 100644 --- a/examples/trino-operator/cmd/main_test.go +++ b/examples/trino-operator/cmd/main_test.go @@ -1,152 +1,16 @@ -/* -Copyright 2026 ZNCDataDev. - -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 main import ( - "crypto/tls" - "flag" - "os" - "path/filepath" - "regexp" + "strings" "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "k8s.io/client-go/util/cert" + "time" ) -func TestManager(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Manager Suite") -} - -// manifestFlagPattern matches a command-line argument in a container args list -// ("- --leader-elect") or in a JSON 6902 patch ("value: --metrics-cert-path=..."), so prose in -// comments is not mistaken for a flag. -var manifestFlagPattern = regexp.MustCompile(`(?m)^\s*(?:-|value:)\s+--([a-zA-Z0-9-]+)`) - -// manifestFlags collects the flags the deployment manifests pass to the manager container. -func manifestFlags() []string { - var flags []string - for _, dir := range []string{ - filepath.Join("..", "config", "manager"), - filepath.Join("..", "config", "default"), - } { - entries, err := os.ReadDir(dir) - Expect(err).NotTo(HaveOccurred()) - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { - continue - } - content, err := os.ReadFile(filepath.Join(dir, entry.Name())) - Expect(err).NotTo(HaveOccurred()) - for _, match := range manifestFlagPattern.FindAllStringSubmatch(string(content), -1) { - flags = append(flags, match[1]) - } +func TestMissingHelperImagesFailBeforeClusterConnection(t *testing.T) { + for _, images := range [][2]string{{"", "vector"}, {"materializer", ""}, {"", ""}} { + err := start(images[0], images[1], "", "0", "0", "", false, time.Second) + if err == nil || !strings.Contains(err.Error(), "--materializer-image and --vector-image are required") { + t.Fatalf("missing helper image did not fail at startup: %v", err) } } - return flags } - -// writeSelfSignedCert drops a tls.crt/tls.key pair into dir; certwatcher.New loads the keypair -// eagerly, so the files have to be valid. -func writeSelfSignedCert(dir string) { - certPEM, keyPEM, err := cert.GenerateSelfSignedCertKey("localhost", nil, nil) - Expect(err).NotTo(HaveOccurred()) - Expect(os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o600)).To(Succeed()) -} - -var _ = Describe("Manager flags", func() { - It("declares every flag the deployment manifests pass", func() { - fs := flag.NewFlagSet("manager", flag.ContinueOnError) - registerManagerFlags(fs) - - flags := manifestFlags() - // The manifests always pass at least --leader-elect, --health-probe-bind-address, - // --metrics-bind-address and --webhook-cert-path; fewer means the scan broke. - Expect(len(flags)).To(BeNumerically(">=", 4)) - - for _, name := range flags { - Expect(fs.Lookup(name)).NotTo(BeNil(), "flag --%s is passed by the deployment but not declared", name) - } - }) -}) - -var _ = Describe("buildManagerOptions", func() { - It("serves metrics on the requested address with authn/authz", func() { - opts, watchers, err := buildManagerOptions(&managerFlags{ - metricsAddr: ":8443", - secureMetrics: true, - probeAddr: ":8081", - }) - Expect(err).NotTo(HaveOccurred()) - Expect(watchers).To(BeEmpty()) - - Expect(opts.Metrics.BindAddress).To(Equal(":8443")) - Expect(opts.Metrics.SecureServing).To(BeTrue()) - Expect(opts.Metrics.FilterProvider).NotTo(BeNil()) - Expect(opts.HealthProbeBindAddress).To(Equal(":8081")) - Expect(opts.WebhookServer).NotTo(BeNil()) - }) - - It("leaves the metrics endpoint unfiltered when it is served over plain HTTP", func() { - opts, _, err := buildManagerOptions(&managerFlags{ - metricsAddr: ":8080", - secureMetrics: false, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(opts.Metrics.SecureServing).To(BeFalse()) - Expect(opts.Metrics.FilterProvider).To(BeNil()) - }) - - It("watches the mounted certificates so rotation does not need a restart", func() { - certDir := GinkgoT().TempDir() - writeSelfSignedCert(certDir) - - opts, watchers, err := buildManagerOptions(&managerFlags{ - metricsAddr: ":8443", - secureMetrics: true, - metricsCertPath: certDir, - metricsCertName: "tls.crt", - metricsCertKey: "tls.key", - webhookCertPath: certDir, - webhookCertName: "tls.crt", - webhookCertKey: "tls.key", - }) - Expect(err).NotTo(HaveOccurred()) - Expect(watchers).To(HaveLen(2)) - Expect(opts.Metrics.TLSOpts).NotTo(BeEmpty()) - }) - - It("disables HTTP/2 unless it is explicitly enabled", func() { - opts, _, err := buildManagerOptions(&managerFlags{metricsAddr: "0"}) - Expect(err).NotTo(HaveOccurred()) - - cfg := &tls.Config{} - for _, apply := range opts.Metrics.TLSOpts { - apply(cfg) - } - Expect(cfg.NextProtos).To(Equal([]string{"http/1.1"})) - - opts, _, err = buildManagerOptions(&managerFlags{metricsAddr: "0", enableHTTP2: true}) - Expect(err).NotTo(HaveOccurred()) - Expect(opts.Metrics.TLSOpts).To(BeEmpty()) - }) -}) diff --git a/examples/trino-operator/config/certmanager/certificate-metrics.yaml b/examples/trino-operator/config/certmanager/certificate-metrics.yaml deleted file mode 100644 index 433297d4..00000000 --- a/examples/trino-operator/config/certmanager/certificate-metrics.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# The following manifests contain a self-signed issuer CR and a metrics certificate CR. -# More document can be found at https://docs.cert-manager.io -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: metrics-certs # this name should match the one appeared in kustomizeconfig.yaml - namespace: system -spec: - dnsNames: - # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize - # replacements in the config/default/kustomization.yaml file. - - SERVICE_NAME.SERVICE_NAMESPACE.svc - - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local - issuerRef: - kind: Issuer - name: selfsigned-issuer - secretName: metrics-server-cert diff --git a/examples/trino-operator/config/certmanager/certificate-webhook.yaml b/examples/trino-operator/config/certmanager/certificate-webhook.yaml deleted file mode 100644 index 498f7eea..00000000 --- a/examples/trino-operator/config/certmanager/certificate-webhook.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# The following manifests contain a self-signed issuer CR and a certificate CR. -# More document can be found at https://docs.cert-manager.io -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml - namespace: system -spec: - # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize - # replacements in the config/default/kustomization.yaml file. - dnsNames: - - SERVICE_NAME.SERVICE_NAMESPACE.svc - - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local - issuerRef: - kind: Issuer - name: selfsigned-issuer - secretName: webhook-server-cert diff --git a/examples/trino-operator/config/certmanager/issuer.yaml b/examples/trino-operator/config/certmanager/issuer.yaml deleted file mode 100644 index 843d6cab..00000000 --- a/examples/trino-operator/config/certmanager/issuer.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# The following manifest contains a self-signed issuer CR. -# More information can be found at https://docs.cert-manager.io -# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. -apiVersion: cert-manager.io/v1 -kind: Issuer -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: selfsigned-issuer - namespace: system -spec: - selfSigned: {} diff --git a/examples/trino-operator/config/certmanager/kustomization.yaml b/examples/trino-operator/config/certmanager/kustomization.yaml deleted file mode 100644 index fcb7498e..00000000 --- a/examples/trino-operator/config/certmanager/kustomization.yaml +++ /dev/null @@ -1,7 +0,0 @@ -resources: -- issuer.yaml -- certificate-webhook.yaml -- certificate-metrics.yaml - -configurations: -- kustomizeconfig.yaml diff --git a/examples/trino-operator/config/certmanager/kustomizeconfig.yaml b/examples/trino-operator/config/certmanager/kustomizeconfig.yaml deleted file mode 100644 index cf6f89e8..00000000 --- a/examples/trino-operator/config/certmanager/kustomizeconfig.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# This configuration is for teaching kustomize how to update name ref substitution -nameReference: -- kind: Issuer - group: cert-manager.io - fieldSpecs: - - kind: Certificate - group: cert-manager.io - path: spec/issuerRef/name diff --git a/examples/trino-operator/config/crd/bases/trino.kubedoop.dev_trinoclusters.yaml b/examples/trino-operator/config/crd/bases/trino.kubedoop.dev_trinoclusters.yaml index ceb6d0b6..74b8ba24 100644 --- a/examples/trino-operator/config/crd/bases/trino.kubedoop.dev_trinoclusters.yaml +++ b/examples/trino-operator/config/crd/bases/trino.kubedoop.dev_trinoclusters.yaml @@ -1,9 +1,6 @@ ---- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.20.1 name: trinoclusters.trino.kubedoop.dev spec: group: trino.kubedoop.dev @@ -11,1398 +8,5255 @@ spec: kind: TrinoCluster listKind: TrinoClusterList plural: trinoclusters - singular: trinocluster scope: Namespaced versions: - name: v1alpha1 schema: openAPIV3Schema: - description: |- - TrinoCluster is the CRD for Trino cluster - TrinoClusterList contains a list of TrinoCluster properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object spec: - description: TrinoClusterSpec defines the desired state of TrinoCluster + nullable: true properties: - catalogs: - description: |- - Catalogs defines the data source Catalog configuration list - Supports Hive, Iceberg, Kafka, MySQL, PostgreSQL, Delta, etc. - items: - description: CatalogSpec defines the data source Catalog configuration - properties: - name: - description: Name is the Catalog name (e.g., hive, iceberg, - kafka) - type: string - properties: - additionalProperties: - type: string - description: Properties are the Catalog configuration properties - (key-value form) - type: object - type: - description: Type is the Catalog type - enum: - - hive - - iceberg - - kafka - - mysql - - postgresql - - delta - - tpch - - tpcds - type: string - required: - - name - - type - type: object - type: array clusterConfig: - description: ClusterConfig holds cluster-wide configuration shared - by all roles. + nullable: true properties: - vectorAggregatorConfigMapName: - description: |- - VectorAggregatorConfigMapName is the name of a ConfigMap carrying the Vector aggregator - discovery address. When set and a role group enables the Vector agent, the operator-go - framework resolves the address and generates vector.yaml into the role group ConfigMap - (via the reconciler.VectorAggregatorProvider seam that TrinoCluster implements below). + authentication: + items: + nullable: true + properties: + authenticationClass: + nullable: true + type: string + oidc: + nullable: true + properties: + clientCredentialsSecret: + nullable: true + type: string + extraScopes: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.clientCredentialsSecret),has(self.extraScopes)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.authenticationClass),has(self.oidc)].filter(v,v).size() + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + internalSecret: + nullable: true + type: string + listenerClass: + nullable: true + type: string + nodeEnvironment: + nullable: true type: string - type: object - clusterOperation: - description: |- - ClusterOperation controls operator behavior at runtime. - Allows pausing reconciliation or stopping the cluster gracefully. - properties: reconciliationPaused: - default: false + nullable: true type: boolean stopped: - default: false + nullable: true type: boolean + tlsSecret: + nullable: true + type: string + tlsSecretClass: + nullable: true + type: string + vectorAgentConfigMap: + nullable: true + type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.authentication),has(self.internalSecret),has(self.listenerClass),has(self.nodeEnvironment),has(self.reconciliationPaused),has(self.stopped),has(self.tlsSecret),has(self.tlsSecretClass),has(self.vectorAgentConfigMap)].filter(v,v).size() coordinators: - description: |- - Coordinators defines the Coordinators role configuration (plural naming) - Coordinator is responsible for query coordination, metadata management, and client request handling + nullable: true properties: cliOverrides: - description: |- - CliOverrides allows customization of CLI arguments. - These overrides apply to all RoleGroups unless overridden. items: + nullable: true type: string + maxItems: 32 + nullable: true type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) config: - description: |- - Config contains workload runtime configuration defaults for all RoleGroups. - Each RoleGroup inherits these values and can selectively override them. - Key distinction from 'roleConfig': this is workload behavior (resources, affinity, logging) - that propagates to RoleGroups, while roleConfig is Kubernetes resource management. + nullable: true properties: affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() type: object - x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string gracefulShutdownTimeout: - description: |- - GracefulShutdownTimeout maps to the pod's terminationGracePeriodSeconds. Unset means - DefaultGracefulShutdownTimeout. - - It is a pointer and carries no `+kubebuilder:default` on purpose. Structural defaulting - fills a field as soon as its enclosing object exists, so with a CRD default every role group - that declared a config block for ANY reason — just `resources`, say — was stamped with "30s", - which is then indistinguishable from an explicit group value and wins the merge over the - role's setting. A role-level graceful shutdown could therefore only ever reach groups with no - config block at all. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|ms|s|m|h))+$ + maxLength: 128 + nullable: true type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + hive: + nullable: true + properties: + metastoreURI: + nullable: true + type: string + s3: + nullable: true + properties: + inline: + nullable: true + properties: + credentials: + nullable: true + properties: + scope: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + secretClass: + nullable: true + type: string + secretName: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.scope),has(self.secretClass),has(self.secretName)].filter(v,v).size() + host: + nullable: true + type: string + pathStyle: + nullable: true + type: boolean + port: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + region: + nullable: true + type: string + tls: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.credentials),has(self.host),has(self.pathStyle),has(self.port),has(self.region),has(self.tls)].filter(v,v).size() + reference: + nullable: true + type: string + type: + enum: + - disabled + - inline + - reference + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.inline),has(self.reference),has(self.type)].filter(v,v).size() + - message: fields must belong to the selected S3 connection + branch + rule: '!has(self.type) || (self.type == ''disabled'' + ? (!has(self.inline) && !has(self.reference)) : (self.type + == ''inline'' ? !has(self.reference) : !has(self.inline)))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.metastoreURI),has(self.s3)].filter(v,v).size() + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer logging: + nullable: true properties: containers: additionalProperties: + nullable: true properties: console: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() file: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() loggers: additionalProperties: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) enableVectorAgent: + nullable: true type: boolean type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() resources: + nullable: true properties: cpu: - description: |- - CPUResource bounds the container's CPU. Both fields are pointers so that "unset" is - representable: a bare resource.Quantity is a struct, which `omitempty` cannot omit and whose - MarshalJSON renders the zero value as "0", so a Go-constructed spec would transmit an explicit - zero and a role group would silently erase the role's value. + nullable: true properties: max: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) min: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() memory: - description: |- - MemoryResource bounds the container's memory. Limit is a pointer for the same reason as - CPUResource's fields. + nullable: true properties: limit: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() storage: - description: StorageResource describes the role group's - data PVC. + nullable: true properties: capacity: - anyOf: - - type: integer - - type: string - description: |- - Capacity of the data volume. Unset means DefaultStorageCapacity. - - It carries no `+kubebuilder:default`: the enclosing storage block exists as soon as a user - overrides ANY leaf of it (a storageClass, say), and a CRD default would then be stamped into - that block and win the role/roleGroup merge — turning a one-line storageClass override into - a silent downgrade of the role's capacity, baked into a StatefulSet volumeClaimTemplate that - Kubernetes will not let the operator change afterwards. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - storageClass: - description: |- - StorageClass names the StorageClass for the PVC. Unset means "use the cluster default", which - is what an absent `storageClassName` asks Kubernetes for. - - It is a POINTER because the empty string is not a synonym for unset here — Kubernetes reads - `storageClassName: ""` as "no class at all", i.e. bind a pre-provisioned PV and do no dynamic - provisioning. With a plain string a role group could never express that over a role that names - a class, because "" was how the merge spelled "inherit". This is the same reason - StorageResource.Capacity, CPUResource.Min/Max, MemoryResource.Limit and - RoleGroupConfigSpec.GracefulShutdownTimeout are pointers, and it carries the same rule: no - `+kubebuilder:default`, or structural defaulting would fill it as soon as the enclosing - object exists and the role's value could never win. + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + shutdownCredentialsSecret: + nullable: true + type: string + shutdownUser: + nullable: true + type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.hive),has(self.httpPort),has(self.logging),has(self.resources),has(self.shutdownCredentialsSecret),has(self.shutdownUser)].filter(v,v).size() configOverrides: additionalProperties: - additionalProperties: - type: string + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with set + or remove + rule: '!has(self.replace) || (!has(self.set) && !has(self.remove))' + - message: a property cannot be both set and removed in + one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string type: object - description: |- - ConfigOverrides allows customization of configuration files (e.g., XML, properties). - Map[FileName]Map[Key]Value. These overrides apply to all RoleGroups unless overridden. + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text or + remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true type: object - discoveryEnabled: - default: true - description: DiscoveryEnabled indicates whether to enable Discovery - service (for Worker discovery) - type: boolean + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) envOverrides: additionalProperties: + nullable: true type: string - description: |- - EnvOverrides allows customization of environment variables. - These overrides apply to all RoleGroups unless overridden. + maxProperties: 32 + nullable: true type: object - httpPort: - default: 8080 - description: HTTPPort is the HTTP API port - format: int32 - type: integer + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) podOverrides: - description: |- - PodOverrides allows customization of Pod template using Strategic Merge Patch. - These overrides apply to all RoleGroups unless overridden. + nullable: true type: object x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer roleConfig: - description: |- - RoleConfig contains Kubernetes-level role management controls. - These settings are role-scoped and NOT inherited or overridden by individual RoleGroups. - Examples: PodDisruptionBudget that covers all Pods across all RoleGroups. + nullable: true properties: podDisruptionBudget: - description: |- - This struct is used to configure: - 1. If PodDisruptionBudgets are created by the operator - 2. The allowed number of Pods to be unavailable (`maxUnavailable`) + nullable: true properties: enabled: - description: |- - Whether a PodDisruptionBudget should be written out for this role. - Disabling this enables you to specify your own - custom - one. - Defaults to true. - - A pointer, so that "unset" and "explicitly false" are distinguishable. As a bare bool with - a CRD default it read as false in every Go-constructed spec — the zero value — which - silently disabled the PDB for any caller that built the spec in code rather than YAML. + nullable: true type: boolean maxUnavailable: - description: |- - The number of Pods that are allowed to be down because of voluntary disruptions. - If you don't explicitly set this, the operator will use a sane default based - upon knowledge about the individual product. format: int32 + maximum: 2147483647 + minimum: 0 + nullable: true type: integer type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.enabled),has(self.maxUnavailable)].filter(v,v).size() type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podDisruptionBudget)].filter(v,v).size() roleGroups: additionalProperties: - description: |- - RoleGroupSpec defines the configuration for a role group. - Each RoleGroup maps directly to a Kubernetes StatefulSet and its associated resources. + nullable: true properties: cliOverrides: - description: |- - CliOverrides allows customization of CLI arguments. - RoleGroup overrides take precedence over Role overrides. items: + nullable: true type: string + maxItems: 32 + nullable: true type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) config: - description: |- - Config contains role group level configurations. - These include resource limits, affinity, and logging settings. + nullable: true properties: affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() type: object - x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string gracefulShutdownTimeout: - description: |- - GracefulShutdownTimeout maps to the pod's terminationGracePeriodSeconds. Unset means - DefaultGracefulShutdownTimeout. - - It is a pointer and carries no `+kubebuilder:default` on purpose. Structural defaulting - fills a field as soon as its enclosing object exists, so with a CRD default every role group - that declared a config block for ANY reason — just `resources`, say — was stamped with "30s", - which is then indistinguishable from an explicit group value and wins the merge over the - role's setting. A role-level graceful shutdown could therefore only ever reach groups with no - config block at all. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|ms|s|m|h))+$ + maxLength: 128 + nullable: true type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + hive: + nullable: true + properties: + metastoreURI: + nullable: true + type: string + s3: + nullable: true + properties: + inline: + nullable: true + properties: + credentials: + nullable: true + properties: + scope: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + secretClass: + nullable: true + type: string + secretName: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.scope),has(self.secretClass),has(self.secretName)].filter(v,v).size() + host: + nullable: true + type: string + pathStyle: + nullable: true + type: boolean + port: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + region: + nullable: true + type: string + tls: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.credentials),has(self.host),has(self.pathStyle),has(self.port),has(self.region),has(self.tls)].filter(v,v).size() + reference: + nullable: true + type: string + type: + enum: + - disabled + - inline + - reference + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.inline),has(self.reference),has(self.type)].filter(v,v).size() + - message: fields must belong to the selected S3 + connection branch + rule: '!has(self.type) || (self.type == ''disabled'' + ? (!has(self.inline) && !has(self.reference)) + : (self.type == ''inline'' ? !has(self.reference) + : !has(self.inline)))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.metastoreURI),has(self.s3)].filter(v,v).size() + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer logging: + nullable: true properties: containers: additionalProperties: + nullable: true properties: console: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() file: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() loggers: additionalProperties: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) enableVectorAgent: + nullable: true type: boolean type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() resources: + nullable: true properties: cpu: - description: |- - CPUResource bounds the container's CPU. Both fields are pointers so that "unset" is - representable: a bare resource.Quantity is a struct, which `omitempty` cannot omit and whose - MarshalJSON renders the zero value as "0", so a Go-constructed spec would transmit an explicit - zero and a role group would silently erase the role's value. + nullable: true properties: max: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) min: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() memory: - description: |- - MemoryResource bounds the container's memory. Limit is a pointer for the same reason as - CPUResource's fields. + nullable: true properties: limit: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() storage: - description: StorageResource describes the role - group's data PVC. + nullable: true properties: capacity: - anyOf: - - type: integer - - type: string - description: |- - Capacity of the data volume. Unset means DefaultStorageCapacity. - - It carries no `+kubebuilder:default`: the enclosing storage block exists as soon as a user - overrides ANY leaf of it (a storageClass, say), and a CRD default would then be stamped into - that block and win the role/roleGroup merge — turning a one-line storageClass override into - a silent downgrade of the role's capacity, baked into a StatefulSet volumeClaimTemplate that - Kubernetes will not let the operator change afterwards. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - storageClass: - description: |- - StorageClass names the StorageClass for the PVC. Unset means "use the cluster default", which - is what an absent `storageClassName` asks Kubernetes for. - - It is a POINTER because the empty string is not a synonym for unset here — Kubernetes reads - `storageClassName: ""` as "no class at all", i.e. bind a pre-provisioned PV and do no dynamic - provisioning. With a plain string a role group could never express that over a role that names - a class, because "" was how the merge spelled "inherit". This is the same reason - StorageResource.Capacity, CPUResource.Min/Max, MemoryResource.Limit and - RoleGroupConfigSpec.GracefulShutdownTimeout are pointers, and it carries the same rule: no - `+kubebuilder:default`, or structural defaulting would fill it as soon as the enclosing - object exists and the role's value could never win. + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + shutdownCredentialsSecret: + nullable: true + type: string + shutdownUser: + nullable: true + type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.hive),has(self.httpPort),has(self.logging),has(self.resources),has(self.shutdownCredentialsSecret),has(self.shutdownUser)].filter(v,v).size() configOverrides: additionalProperties: - additionalProperties: - type: string + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with + set or remove + rule: '!has(self.replace) || (!has(self.set) && + !has(self.remove))' + - message: a property cannot be both set and removed + in one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string type: object - description: |- - ConfigOverrides allows customization of configuration files (e.g., XML, properties). - Map[FileName]Map[Key]Value. RoleGroup overrides take precedence over Role overrides. + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text + or remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) envOverrides: additionalProperties: + nullable: true type: string - description: |- - EnvOverrides allows customization of environment variables. - RoleGroup overrides take precedence over Role overrides. + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) podOverrides: - description: |- - PodOverrides allows customization of Pod template using Strategic Merge Patch. - RoleGroup overrides take precedence over Role overrides. + nullable: true type: object x-kubernetes-preserve-unknown-fields: true replicas: - default: 1 - description: Replicas is the number of pod replicas for - this role group. format: int32 minimum: 0 + nullable: true type: integer type: object - description: |- - RoleGroups defines the role group configurations. - Each RoleGroup maps to a Kubernetes StatefulSet. - - Constrained for the same reason as the role name above: a role group name is a segment of - "--" and the value of the app.kubernetes.io/role-group label, so a name - that is not a lowercase RFC 1123 label yields resource names the API server refuses. - - MaxProperties bounds the CEL cost estimate, not the deployment — see the note on Roles. - 256 role groups in a single role is far past one per rack in a very large cluster. - maxProperties: 256 + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object x-kubernetes-validations: - - message: 'each role group name must be a lowercase RFC 1123 - label (lowercase alphanumerics and ''-'', starting and ending - with an alphanumeric, at most 63 characters): role group names - become part of the name and labels of every resource built - for the group' - rule: self.all(k, size(k) <= 63 && k.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')) + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas),has(self.roleConfig),has(self.roleGroups)].filter(v,v).size() image: - description: |- - Image specifies the Trino container image configuration. - If not set, the webhook defaulter will provide product defaults. + nullable: true properties: custom: - description: |- - Custom is a fully qualified image reference (e.g. "my-registry.com/ns/product:3.4.1"). - When set, Repo, ProductVersion and KubedoopVersion are ignored. + nullable: true type: string kubedoopVersion: - description: |- - KubedoopVersion is the version of the kubedoop operator stack (e.g. "0.2.0"). - Used only when Custom is not set. + nullable: true type: string productVersion: - description: |- - ProductVersion is the version of the product to deploy (e.g. "3.4.1"). - Used only when Custom is not set. + nullable: true type: string pullPolicy: - default: IfNotPresent - description: |- - PullPolicy defines the image pull policy for the container. - Defaults to IfNotPresent. enum: - Always - - Never - IfNotPresent + - Never + nullable: true type: string pullSecretName: - description: |- - PullSecretName names a docker-registry Secret in the cluster CR's namespace, added to - .spec.imagePullSecrets of every pod the framework builds for this cluster. - - It applies to whichever image path is taken, Custom included: a private registry is a - property of where the image lives, not of how its reference was assembled. It is also - deliberately NOT part of image resolution — a pull secret is still needed when the product - resolves its own images and the framework never builds a reference at all. - - One name rather than a list, matching the field the ten product CRDs already declare. A - deployment needing several credentials merges them into one Secret, or adds the rest through - podOverrides, which is applied after this and therefore wins. + nullable: true type: string repo: - description: |- - Repo is the image repository (e.g. "quay.io/kubedoop"). - Used only when Custom is not set. + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.custom),has(self.kubedoopVersion),has(self.productVersion),has(self.pullPolicy),has(self.pullSecretName),has(self.repo)].filter(v,v).size() workers: - description: |- - Workers defines the Workers role configuration (plural naming) - Worker is responsible for executing query tasks and can be horizontally scaled + nullable: true properties: cliOverrides: - description: |- - CliOverrides allows customization of CLI arguments. - These overrides apply to all RoleGroups unless overridden. items: + nullable: true type: string + maxItems: 32 + nullable: true type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) config: - description: |- - Config contains workload runtime configuration defaults for all RoleGroups. - Each RoleGroup inherits these values and can selectively override them. - Key distinction from 'roleConfig': this is workload behavior (resources, affinity, logging) - that propagates to RoleGroups, while roleConfig is Kubernetes resource management. + nullable: true properties: affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() type: object - x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string gracefulShutdownTimeout: - description: |- - GracefulShutdownTimeout maps to the pod's terminationGracePeriodSeconds. Unset means - DefaultGracefulShutdownTimeout. - - It is a pointer and carries no `+kubebuilder:default` on purpose. Structural defaulting - fills a field as soon as its enclosing object exists, so with a CRD default every role group - that declared a config block for ANY reason — just `resources`, say — was stamped with "30s", - which is then indistinguishable from an explicit group value and wins the merge over the - role's setting. A role-level graceful shutdown could therefore only ever reach groups with no - config block at all. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|ms|s|m|h))+$ + maxLength: 128 + nullable: true type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + hive: + nullable: true + properties: + metastoreURI: + nullable: true + type: string + s3: + nullable: true + properties: + inline: + nullable: true + properties: + credentials: + nullable: true + properties: + scope: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + secretClass: + nullable: true + type: string + secretName: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.scope),has(self.secretClass),has(self.secretName)].filter(v,v).size() + host: + nullable: true + type: string + pathStyle: + nullable: true + type: boolean + port: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + region: + nullable: true + type: string + tls: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.credentials),has(self.host),has(self.pathStyle),has(self.port),has(self.region),has(self.tls)].filter(v,v).size() + reference: + nullable: true + type: string + type: + enum: + - disabled + - inline + - reference + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.inline),has(self.reference),has(self.type)].filter(v,v).size() + - message: fields must belong to the selected S3 connection + branch + rule: '!has(self.type) || (self.type == ''disabled'' + ? (!has(self.inline) && !has(self.reference)) : (self.type + == ''inline'' ? !has(self.reference) : !has(self.inline)))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.metastoreURI),has(self.s3)].filter(v,v).size() + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer logging: + nullable: true properties: containers: additionalProperties: + nullable: true properties: console: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() file: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() loggers: additionalProperties: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) enableVectorAgent: + nullable: true type: boolean type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() resources: + nullable: true properties: cpu: - description: |- - CPUResource bounds the container's CPU. Both fields are pointers so that "unset" is - representable: a bare resource.Quantity is a struct, which `omitempty` cannot omit and whose - MarshalJSON renders the zero value as "0", so a Go-constructed spec would transmit an explicit - zero and a role group would silently erase the role's value. + nullable: true properties: max: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) min: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() memory: - description: |- - MemoryResource bounds the container's memory. Limit is a pointer for the same reason as - CPUResource's fields. + nullable: true properties: limit: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() storage: - description: StorageResource describes the role group's - data PVC. + nullable: true properties: capacity: - anyOf: - - type: integer - - type: string - description: |- - Capacity of the data volume. Unset means DefaultStorageCapacity. - - It carries no `+kubebuilder:default`: the enclosing storage block exists as soon as a user - overrides ANY leaf of it (a storageClass, say), and a CRD default would then be stamped into - that block and win the role/roleGroup merge — turning a one-line storageClass override into - a silent downgrade of the role's capacity, baked into a StatefulSet volumeClaimTemplate that - Kubernetes will not let the operator change afterwards. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - storageClass: - description: |- - StorageClass names the StorageClass for the PVC. Unset means "use the cluster default", which - is what an absent `storageClassName` asks Kubernetes for. - - It is a POINTER because the empty string is not a synonym for unset here — Kubernetes reads - `storageClassName: ""` as "no class at all", i.e. bind a pre-provisioned PV and do no dynamic - provisioning. With a plain string a role group could never express that over a role that names - a class, because "" was how the merge spelled "inherit". This is the same reason - StorageResource.Capacity, CPUResource.Min/Max, MemoryResource.Limit and - RoleGroupConfigSpec.GracefulShutdownTimeout are pointers, and it carries the same rule: no - `+kubebuilder:default`, or structural defaulting would fill it as soon as the enclosing - object exists and the role's value could never win. + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + shutdownCredentialsSecret: + nullable: true + type: string + shutdownUser: + nullable: true + type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.hive),has(self.httpPort),has(self.logging),has(self.resources),has(self.shutdownCredentialsSecret),has(self.shutdownUser)].filter(v,v).size() configOverrides: additionalProperties: - additionalProperties: - type: string + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with set + or remove + rule: '!has(self.replace) || (!has(self.set) && !has(self.remove))' + - message: a property cannot be both set and removed in + one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string type: object - description: |- - ConfigOverrides allows customization of configuration files (e.g., XML, properties). - Map[FileName]Map[Key]Value. These overrides apply to all RoleGroups unless overridden. + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text or + remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) envOverrides: additionalProperties: + nullable: true type: string - description: |- - EnvOverrides allows customization of environment variables. - These overrides apply to all RoleGroups unless overridden. + maxProperties: 32 + nullable: true type: object - httpPort: - default: 8080 - description: HTTPPort is the HTTP API port - format: int32 - type: integer + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) podOverrides: - description: |- - PodOverrides allows customization of Pod template using Strategic Merge Patch. - These overrides apply to all RoleGroups unless overridden. + nullable: true type: object x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer roleConfig: - description: |- - RoleConfig contains Kubernetes-level role management controls. - These settings are role-scoped and NOT inherited or overridden by individual RoleGroups. - Examples: PodDisruptionBudget that covers all Pods across all RoleGroups. + nullable: true properties: podDisruptionBudget: - description: |- - This struct is used to configure: - 1. If PodDisruptionBudgets are created by the operator - 2. The allowed number of Pods to be unavailable (`maxUnavailable`) + nullable: true properties: enabled: - description: |- - Whether a PodDisruptionBudget should be written out for this role. - Disabling this enables you to specify your own - custom - one. - Defaults to true. - - A pointer, so that "unset" and "explicitly false" are distinguishable. As a bare bool with - a CRD default it read as false in every Go-constructed spec — the zero value — which - silently disabled the PDB for any caller that built the spec in code rather than YAML. + nullable: true type: boolean maxUnavailable: - description: |- - The number of Pods that are allowed to be down because of voluntary disruptions. - If you don't explicitly set this, the operator will use a sane default based - upon knowledge about the individual product. format: int32 + maximum: 2147483647 + minimum: 0 + nullable: true type: integer type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.enabled),has(self.maxUnavailable)].filter(v,v).size() type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podDisruptionBudget)].filter(v,v).size() roleGroups: additionalProperties: - description: |- - RoleGroupSpec defines the configuration for a role group. - Each RoleGroup maps directly to a Kubernetes StatefulSet and its associated resources. + nullable: true properties: cliOverrides: - description: |- - CliOverrides allows customization of CLI arguments. - RoleGroup overrides take precedence over Role overrides. items: + nullable: true type: string + maxItems: 32 + nullable: true type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) config: - description: |- - Config contains role group level configurations. - These include resource limits, affinity, and logging settings. + nullable: true properties: affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() type: object - x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string gracefulShutdownTimeout: - description: |- - GracefulShutdownTimeout maps to the pod's terminationGracePeriodSeconds. Unset means - DefaultGracefulShutdownTimeout. - - It is a pointer and carries no `+kubebuilder:default` on purpose. Structural defaulting - fills a field as soon as its enclosing object exists, so with a CRD default every role group - that declared a config block for ANY reason — just `resources`, say — was stamped with "30s", - which is then indistinguishable from an explicit group value and wins the merge over the - role's setting. A role-level graceful shutdown could therefore only ever reach groups with no - config block at all. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|ms|s|m|h))+$ + maxLength: 128 + nullable: true type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + hive: + nullable: true + properties: + metastoreURI: + nullable: true + type: string + s3: + nullable: true + properties: + inline: + nullable: true + properties: + credentials: + nullable: true + properties: + scope: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + secretClass: + nullable: true + type: string + secretName: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.scope),has(self.secretClass),has(self.secretName)].filter(v,v).size() + host: + nullable: true + type: string + pathStyle: + nullable: true + type: boolean + port: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + region: + nullable: true + type: string + tls: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.credentials),has(self.host),has(self.pathStyle),has(self.port),has(self.region),has(self.tls)].filter(v,v).size() + reference: + nullable: true + type: string + type: + enum: + - disabled + - inline + - reference + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.inline),has(self.reference),has(self.type)].filter(v,v).size() + - message: fields must belong to the selected S3 + connection branch + rule: '!has(self.type) || (self.type == ''disabled'' + ? (!has(self.inline) && !has(self.reference)) + : (self.type == ''inline'' ? !has(self.reference) + : !has(self.inline)))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.metastoreURI),has(self.s3)].filter(v,v).size() + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer logging: + nullable: true properties: containers: additionalProperties: + nullable: true properties: console: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() file: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() loggers: additionalProperties: - description: |- - LogLevelSpec - level mapping if app log level is not standard - - FATAL -> CRITICAL - - ERROR -> ERROR - - WARN -> WARNING - - INFO -> INFO - - DEBUG -> DEBUG - - TRACE -> DEBUG - - The effective default is INFO, applied at consumption time by the renderers rather than by the - CRD — see Level below. + nullable: true properties: level: - description: |- - Level is the log threshold. Unset means "inherit": the role's value when a role group leaves - it out, and the product's own default (root logger INFO, no appender threshold) when nobody - sets it. - - It carries NO +kubebuilder:default, and must not gain one. This type sits inside `config`, - the block folded Role -> RoleGroup, where structural defaulting fills a leaf as soon as its - ENCLOSING OBJECT exists — so a default here lands in any role group that wrote `console: {}`, - making "unset here" indistinguishable from "explicitly INFO" and stopping the role's value - from ever winning. That is not hypothetical: with `default:="INFO"` a role asking for DEBUG - and a role group writing an empty `console: {}` produced INFO, and the guard in - mergeContainerLogging written to prevent exactly that could never fire, because the API - server had already filled the field before the merge saw it. - - Same rule and same reason as StorageResource.Capacity and RoleGroupConfigSpec's other folded - leaves; defaults for these live at consumption time. - enum: - - FATAL - - ERROR - - WARN - - INFO - - DEBUG - - TRACE + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) enableVectorAgent: + nullable: true type: boolean type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() resources: + nullable: true properties: cpu: - description: |- - CPUResource bounds the container's CPU. Both fields are pointers so that "unset" is - representable: a bare resource.Quantity is a struct, which `omitempty` cannot omit and whose - MarshalJSON renders the zero value as "0", so a Go-constructed spec would transmit an explicit - zero and a role group would silently erase the role's value. + nullable: true properties: max: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) min: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() memory: - description: |- - MemoryResource bounds the container's memory. Limit is a pointer for the same reason as - CPUResource's fields. + nullable: true properties: limit: - 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 + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() storage: - description: StorageResource describes the role - group's data PVC. + nullable: true properties: capacity: - anyOf: - - type: integer - - type: string - description: |- - Capacity of the data volume. Unset means DefaultStorageCapacity. - - It carries no `+kubebuilder:default`: the enclosing storage block exists as soon as a user - overrides ANY leaf of it (a storageClass, say), and a CRD default would then be stamped into - that block and win the role/roleGroup merge — turning a one-line storageClass override into - a silent downgrade of the role's capacity, baked into a StatefulSet volumeClaimTemplate that - Kubernetes will not let the operator change afterwards. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - storageClass: - description: |- - StorageClass names the StorageClass for the PVC. Unset means "use the cluster default", which - is what an absent `storageClassName` asks Kubernetes for. - - It is a POINTER because the empty string is not a synonym for unset here — Kubernetes reads - `storageClassName: ""` as "no class at all", i.e. bind a pre-provisioned PV and do no dynamic - provisioning. With a plain string a role group could never express that over a role that names - a class, because "" was how the merge spelled "inherit". This is the same reason - StorageResource.Capacity, CPUResource.Min/Max, MemoryResource.Limit and - RoleGroupConfigSpec.GracefulShutdownTimeout are pointers, and it carries the same rule: no - `+kubebuilder:default`, or structural defaulting would fill it as soon as the enclosing - object exists and the role's value could never win. + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + shutdownCredentialsSecret: + nullable: true + type: string + shutdownUser: + nullable: true + type: string type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.hive),has(self.httpPort),has(self.logging),has(self.resources),has(self.shutdownCredentialsSecret),has(self.shutdownUser)].filter(v,v).size() configOverrides: additionalProperties: - additionalProperties: - type: string + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with + set or remove + rule: '!has(self.replace) || (!has(self.set) && + !has(self.remove))' + - message: a property cannot be both set and removed + in one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string type: object - description: |- - ConfigOverrides allows customization of configuration files (e.g., XML, properties). - Map[FileName]Map[Key]Value. RoleGroup overrides take precedence over Role overrides. + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text + or remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) envOverrides: additionalProperties: + nullable: true type: string - description: |- - EnvOverrides allows customization of environment variables. - RoleGroup overrides take precedence over Role overrides. + maxProperties: 32 + nullable: true type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) podOverrides: - description: |- - PodOverrides allows customization of Pod template using Strategic Merge Patch. - RoleGroup overrides take precedence over Role overrides. + nullable: true type: object x-kubernetes-preserve-unknown-fields: true replicas: - default: 1 - description: Replicas is the number of pod replicas for - this role group. format: int32 minimum: 0 + nullable: true type: integer type: object - description: |- - RoleGroups defines the role group configurations. - Each RoleGroup maps to a Kubernetes StatefulSet. - - Constrained for the same reason as the role name above: a role group name is a segment of - "--" and the value of the app.kubernetes.io/role-group label, so a name - that is not a lowercase RFC 1123 label yields resource names the API server refuses. - - MaxProperties bounds the CEL cost estimate, not the deployment — see the note on Roles. - 256 role groups in a single role is far past one per rack in a very large cluster. - maxProperties: 256 + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas)].filter(v,v).size() + maxProperties: 32 + nullable: true type: object x-kubernetes-validations: - - message: 'each role group name must be a lowercase RFC 1123 - label (lowercase alphanumerics and ''-'', starting and ending - with an alphanumeric, at most 63 characters): role group names - become part of the name and labels of every resource built - for the group' - rule: self.all(k, size(k) <= 63 && k.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')) + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas),has(self.roleConfig),has(self.roleGroups)].filter(v,v).size() type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.clusterConfig),has(self.coordinators),has(self.image),has(self.workers)].filter(v,v).size() status: - description: TrinoClusterStatus defines the observed state of TrinoCluster properties: - catalogsReady: - description: CatalogsReady is the list of ready Catalogs - items: - type: string - type: array conditions: - description: Conditions represent the latest available observations - of the cluster state. 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 + - status + - reason + - message + - lastTransitionTime type: object type: array x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + groups: + items: + properties: + applied: + type: boolean + checks: + items: + properties: + reason: + type: string + state: + enum: + - consistent + - conflict + - unknown + type: string + subject: + type: string + required: + - subject + - state + type: object + type: array + desiredReplicas: + format: int32 + minimum: 0 + type: integer + executionReplicas: + format: int32 + minimum: 0 + type: integer + facts: + properties: + message: + type: string + observed: + items: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + resourceVersion: + type: string + uid: + type: string + required: + - apiVersion + - kind + - namespace + - name + type: object + type: array + reason: + type: string + state: + enum: + - resolved + - pending + - invalid + - readError + type: string + required: + - state + type: object + message: + type: string + name: + minLength: 1 + type: string + platform: + properties: + diagnostic: + properties: + message: + type: string + observed: + items: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + resourceVersion: + type: string + uid: + type: string + required: + - apiVersion + - kind + - namespace + - name + type: object + type: array + reason: + type: string + state: + enum: + - resolved + - pending + - invalid + - readError + type: string + required: + - state + type: object + listeners: + items: + properties: + address: + type: string + directory: + type: string + pod: + type: string + ports: + additionalProperties: + format: int32 + type: integer + type: object + required: + - pod + - directory + - address + - ports + type: object + type: array + phase: + type: string + required: + - phase + - diagnostic + type: object + readyReplicas: + format: int32 + minimum: 0 + type: integer + role: + minLength: 1 + type: string + required: + - role + - name + - desiredReplicas + - readyReplicas + - applied + type: object + type: array + x-kubernetes-list-map-keys: + - role + - name + x-kubernetes-list-type: map observedGeneration: - description: |- - ObservedGeneration is the most recent generation observed for this cluster. - It corresponds to the metadata generation of the CR. format: int64 + minimum: 0 type: integer - registeredWorkers: - description: RegisteredWorkers is the number of registered Workers - format: int32 - type: integer - roleGroups: - additionalProperties: - items: - type: string - type: array - description: |- - RoleGroups tracks the actual deployed role groups. - Map key is the role name, value is the list of role group names. - This is used for orphaned resource cleanup. - type: object + roles: + items: + properties: + applied: + type: boolean + message: + type: string + name: + minLength: 1 + type: string + required: + - name + - applied + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map type: object + required: + - spec type: object + x-kubernetes-validations: + - message: explicit null spec is not allowed + rule: size(dyn(self)) == [has(self.spec),has(self.status),has(self.apiVersion),has(self.kind),has(self.metadata)].filter(v,v).size() served: true storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/examples/trino-operator/config/crd/kustomization.yaml b/examples/trino-operator/config/crd/kustomization.yaml index 2682030b..5b351aab 100644 --- a/examples/trino-operator/config/crd/kustomization.yaml +++ b/examples/trino-operator/config/crd/kustomization.yaml @@ -1,16 +1,4 @@ -# 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 +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization resources: -- bases/trino.kubedoop.dev_trinoclusters.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 + - bases/trino.kubedoop.dev_trinoclusters.yaml diff --git a/examples/trino-operator/config/crd/kustomizeconfig.yaml b/examples/trino-operator/config/crd/kustomizeconfig.yaml deleted file mode 100644 index 61361ffd..00000000 --- a/examples/trino-operator/config/crd/kustomizeconfig.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# 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 diff --git a/examples/trino-operator/config/default/cert_metrics_manager_patch.yaml b/examples/trino-operator/config/default/cert_metrics_manager_patch.yaml deleted file mode 100644 index d9750155..00000000 --- a/examples/trino-operator/config/default/cert_metrics_manager_patch.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 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 diff --git a/examples/trino-operator/config/default/kustomization.yaml b/examples/trino-operator/config/default/kustomization.yaml index 48043d14..bde8b879 100644 --- a/examples/trino-operator/config/default/kustomization.yaml +++ b/examples/trino-operator/config/default/kustomization.yaml @@ -1,234 +1,10 @@ -# Adds namespace to all resources. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization namespace: trino-operator-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: trino-operator- - -# 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] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. -# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. -# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will -# be able to communicate with the Webhook Server. -#- ../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 + - namespace.yaml + - ../crd + - ../framework-data + - ../rbac + - ../manager diff --git a/examples/trino-operator/config/default/manager_metrics_patch.yaml b/examples/trino-operator/config/default/manager_metrics_patch.yaml deleted file mode 100644 index 2aaef653..00000000 --- a/examples/trino-operator/config/default/manager_metrics_patch.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# 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 diff --git a/examples/trino-operator/config/default/manager_webhook_patch.yaml b/examples/trino-operator/config/default/manager_webhook_patch.yaml deleted file mode 100644 index 963c8a4c..00000000 --- a/examples/trino-operator/config/default/manager_webhook_patch.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# This patch ensures the webhook certificates are properly mounted in the manager container. -# It configures the necessary arguments, volumes, volume mounts, and container ports. - -# Add the --webhook-cert-path argument for configuring the webhook certificate path -- op: add - path: /spec/template/spec/containers/0/args/- - value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs - -# Add the volumeMount for the webhook certificates -- op: add - path: /spec/template/spec/containers/0/volumeMounts/- - value: - mountPath: /tmp/k8s-webhook-server/serving-certs - name: webhook-certs - readOnly: true - -# Add the port configuration for the webhook server -- op: add - path: /spec/template/spec/containers/0/ports/- - value: - containerPort: 9443 - name: webhook-server - protocol: TCP - -# Add the volume configuration for the webhook certificates -- op: add - path: /spec/template/spec/volumes/- - value: - name: webhook-certs - secret: - secretName: webhook-server-cert diff --git a/examples/trino-operator/config/default/metrics_service.yaml b/examples/trino-operator/config/default/metrics_service.yaml deleted file mode 100644 index 06cda620..00000000 --- a/examples/trino-operator/config/default/metrics_service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator - 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: trino-operator diff --git a/examples/trino-operator/config/default/namespace.yaml b/examples/trino-operator/config/default/namespace.yaml new file mode 100644 index 00000000..1ab3a725 --- /dev/null +++ b/examples/trino-operator/config/default/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: system diff --git a/examples/trino-operator/config/framework-data/kustomization.yaml b/examples/trino-operator/config/framework-data/kustomization.yaml new file mode 100644 index 00000000..f8413f68 --- /dev/null +++ b/examples/trino-operator/config/framework-data/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../../../config/framework-data diff --git a/examples/trino-operator/config/manager/kustomization.yaml b/examples/trino-operator/config/manager/kustomization.yaml index 5c5f0b84..2926b52e 100644 --- a/examples/trino-operator/config/manager/kustomization.yaml +++ b/examples/trino-operator/config/manager/kustomization.yaml @@ -1,2 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization resources: -- manager.yaml + - manager.yaml diff --git a/examples/trino-operator/config/manager/manager.yaml b/examples/trino-operator/config/manager/manager.yaml index c32a2044..c20e3174 100644 --- a/examples/trino-operator/config/manager/manager.yaml +++ b/examples/trino-operator/config/manager/manager.yaml @@ -1,99 +1,64 @@ -apiVersion: v1 -kind: Namespace -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator - 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: trino-operator - app.kubernetes.io/managed-by: kustomize spec: + replicas: 1 selector: matchLabels: - control-plane: controller-manager app.kubernetes.io/name: trino-operator - replicas: 1 template: metadata: - annotations: - kubectl.kubernetes.io/default-container: manager labels: - control-plane: controller-manager app.kubernetes.io/name: trino-operator 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 + serviceAccountName: controller-manager 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 + runAsUser: 65532 + runAsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - - command: - - /manager - args: - - --leader-elect - - --health-probe-bind-address=:8081 - image: controller:latest - name: manager - ports: [] - 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 + - name: manager + image: trino-operator:dev + imagePullPolicy: IfNotPresent + args: + - --leader-elect + - --health-probe-bind-address=:8081 + - --materializer-image=quay.io/zncdatadev/operator-go-materializer:0.0.0-dev + - --vector-image=quay.io/zncdatadev/vector@sha256:3b9a99d98905443924bee204bd76c2818ad2da7056388fd524b0ea000eb55682 + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - name: health + containerPort: 8081 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: "1" + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] diff --git a/examples/trino-operator/config/network-policy/allow-metrics-traffic.yaml b/examples/trino-operator/config/network-policy/allow-metrics-traffic.yaml deleted file mode 100644 index c993f5b1..00000000 --- a/examples/trino-operator/config/network-policy/allow-metrics-traffic.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# This NetworkPolicy allows ingress traffic -# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those -# namespaces are able to gather data from the metrics endpoint. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: allow-metrics-traffic - namespace: system -spec: - podSelector: - matchLabels: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator - policyTypes: - - Ingress - ingress: - # This allows ingress traffic from any namespace with the label metrics: enabled - - from: - - namespaceSelector: - matchLabels: - metrics: enabled # Only from namespaces with this label - ports: - - port: 8443 - protocol: TCP diff --git a/examples/trino-operator/config/network-policy/allow-webhook-traffic.yaml b/examples/trino-operator/config/network-policy/allow-webhook-traffic.yaml deleted file mode 100644 index db4c0c66..00000000 --- a/examples/trino-operator/config/network-policy/allow-webhook-traffic.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# This NetworkPolicy allows ingress traffic to your webhook server running -# as part of the controller-manager from specific namespaces and pods. CR(s) which uses webhooks -# will only work when applied in namespaces labeled with 'webhook: enabled' -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: allow-webhook-traffic - namespace: system -spec: - podSelector: - matchLabels: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator - policyTypes: - - Ingress - ingress: - # This allows ingress traffic from any namespace with the label webhook: enabled - - from: - - namespaceSelector: - matchLabels: - webhook: enabled # Only from namespaces with this label - ports: - - port: 443 - protocol: TCP diff --git a/examples/trino-operator/config/network-policy/kustomization.yaml b/examples/trino-operator/config/network-policy/kustomization.yaml deleted file mode 100644 index 0872bee1..00000000 --- a/examples/trino-operator/config/network-policy/kustomization.yaml +++ /dev/null @@ -1,3 +0,0 @@ -resources: -- allow-webhook-traffic.yaml -- allow-metrics-traffic.yaml diff --git a/examples/trino-operator/config/prometheus/kustomization.yaml b/examples/trino-operator/config/prometheus/kustomization.yaml deleted file mode 100644 index fdc5481b..00000000 --- a/examples/trino-operator/config/prometheus/kustomization.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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 diff --git a/examples/trino-operator/config/prometheus/monitor.yaml b/examples/trino-operator/config/prometheus/monitor.yaml deleted file mode 100644 index f038c089..00000000 --- a/examples/trino-operator/config/prometheus/monitor.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Prometheus Monitor Service (Metrics) -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator - 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: trino-operator diff --git a/examples/trino-operator/config/prometheus/monitor_tls_patch.yaml b/examples/trino-operator/config/prometheus/monitor_tls_patch.yaml deleted file mode 100644 index 5bf84ce0..00000000 --- a/examples/trino-operator/config/prometheus/monitor_tls_patch.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# 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 diff --git a/examples/trino-operator/config/rbac/kustomization.yaml b/examples/trino-operator/config/rbac/kustomization.yaml index 7853788a..005c9885 100644 --- a/examples/trino-operator/config/rbac/kustomization.yaml +++ b/examples/trino-operator/config/rbac/kustomization.yaml @@ -1,28 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization 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 trino-operator itself. You can comment the following lines -# if you do not want those helpers be installed with your Project. -- trinocluster_admin_role.yaml -- trinocluster_editor_role.yaml -- trinocluster_viewer_role.yaml - + - service_account.yaml + - role.yaml + - role_binding.yaml + - leader_election_role.yaml + - leader_election_role_binding.yaml diff --git a/examples/trino-operator/config/rbac/leader_election_role.yaml b/examples/trino-operator/config/rbac/leader_election_role.yaml index 3f6f69d3..d169dc37 100644 --- a/examples/trino-operator/config/rbac/leader_election_role.yaml +++ b/examples/trino-operator/config/rbac/leader_election_role.yaml @@ -1,40 +1,8 @@ -# permissions to do leader election. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - labels: - app.kubernetes.io/name: trino-operator - 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 + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] diff --git a/examples/trino-operator/config/rbac/leader_election_role_binding.yaml b/examples/trino-operator/config/rbac/leader_election_role_binding.yaml index cce84213..f2ac37c4 100644 --- a/examples/trino-operator/config/rbac/leader_election_role_binding.yaml +++ b/examples/trino-operator/config/rbac/leader_election_role_binding.yaml @@ -1,15 +1,11 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - labels: - app.kubernetes.io/name: trino-operator - 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 + - kind: ServiceAccount + name: controller-manager diff --git a/examples/trino-operator/config/rbac/metrics_auth_role.yaml b/examples/trino-operator/config/rbac/metrics_auth_role.yaml deleted file mode 100644 index 32d2e4ec..00000000 --- a/examples/trino-operator/config/rbac/metrics_auth_role.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 diff --git a/examples/trino-operator/config/rbac/metrics_auth_role_binding.yaml b/examples/trino-operator/config/rbac/metrics_auth_role_binding.yaml deleted file mode 100644 index e775d67f..00000000 --- a/examples/trino-operator/config/rbac/metrics_auth_role_binding.yaml +++ /dev/null @@ -1,12 +0,0 @@ -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 diff --git a/examples/trino-operator/config/rbac/metrics_reader_role.yaml b/examples/trino-operator/config/rbac/metrics_reader_role.yaml deleted file mode 100644 index 51a75db4..00000000 --- a/examples/trino-operator/config/rbac/metrics_reader_role.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: metrics-reader -rules: -- nonResourceURLs: - - "/metrics" - verbs: - - get diff --git a/examples/trino-operator/config/rbac/role.yaml b/examples/trino-operator/config/rbac/role.yaml index af20fef5..7bb56ac8 100644 --- a/examples/trino-operator/config/rbac/role.yaml +++ b/examples/trino-operator/config/rbac/role.yaml @@ -1,100 +1,56 @@ ---- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: manager-role rules: -- apiGroups: - - "" - resources: - - configmaps - - services - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch -- apiGroups: - - "" - resources: - - persistentvolumeclaims - verbs: - - delete - - get - - list - - watch -- apiGroups: - - "" - resources: - - pods - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - serviceaccounts - verbs: - - create - - get - - list - - patch - - update - - watch -- apiGroups: - - apps - resources: - - statefulsets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - policy - resources: - - poddisruptionbudgets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters - verbs: - - get - - list - - watch -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters/finalizers - verbs: - - update -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters/status - verbs: - - get - - patch - - update + - apiGroups: [trino.kubedoop.dev] + resources: [trinoclusters] + verbs: [get, list, watch] + - apiGroups: [trino.kubedoop.dev] + resources: [trinoclusters/status] + verbs: [get, update, patch] + - apiGroups: [trino.kubedoop.dev] + resources: [trinoclusters/finalizers] + verbs: [update] + - apiGroups: [""] + resources: [configmaps, services] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [""] + resources: [pods] + verbs: [get, list, watch] + - apiGroups: [""] + resources: [persistentvolumeclaims] + verbs: [get, list, watch, update, patch] + - apiGroups: [""] + resources: [persistentvolumes] + verbs: [get] + - apiGroups: [storage.k8s.io] + resources: [storageclasses] + verbs: [get] + - apiGroups: [apps] + resources: [statefulsets] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [policy] + resources: [poddisruptionbudgets] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [""] + resources: [events] + verbs: [create, patch] + - apiGroups: [data.framework.kubedoop.dev] + resources: [dataassets] + verbs: [get, list, watch, create] + - apiGroups: [""] + resources: [secrets] + verbs: [get] + - apiGroups: [secrets.kubedoop.dev] + resources: [secretclasses] + verbs: [get] + - apiGroups: [listeners.kubedoop.dev] + resources: [listenerclasses, listeners] + verbs: [get] + - apiGroups: [authentication.kubedoop.dev] + resources: [authenticationclasses] + verbs: [get] + - apiGroups: [s3.kubedoop.dev] + resources: [s3connections] + verbs: [get] diff --git a/examples/trino-operator/config/rbac/role_binding.yaml b/examples/trino-operator/config/rbac/role_binding.yaml index 8a75a556..193631b9 100644 --- a/examples/trino-operator/config/rbac/role_binding.yaml +++ b/examples/trino-operator/config/rbac/role_binding.yaml @@ -1,15 +1,11 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - labels: - app.kubernetes.io/name: trino-operator - 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 + - kind: ServiceAccount + name: controller-manager diff --git a/examples/trino-operator/config/rbac/service_account.yaml b/examples/trino-operator/config/rbac/service_account.yaml index d105eb4c..69ece2e4 100644 --- a/examples/trino-operator/config/rbac/service_account.yaml +++ b/examples/trino-operator/config/rbac/service_account.yaml @@ -1,8 +1,4 @@ apiVersion: v1 kind: ServiceAccount metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize name: controller-manager - namespace: system diff --git a/examples/trino-operator/config/rbac/trinocluster_admin_role.yaml b/examples/trino-operator/config/rbac/trinocluster_admin_role.yaml deleted file mode 100644 index 61f25288..00000000 --- a/examples/trino-operator/config/rbac/trinocluster_admin_role.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# This rule is not used by the project trino-operator itself. -# It is provided to allow the cluster admin to help manage permissions for users. -# -# Grants full permissions ('*') over trino.kubedoop.dev. -# 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: trino-operator - app.kubernetes.io/managed-by: kustomize - name: trinocluster-admin-role -rules: -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters - verbs: - - '*' -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters/status - verbs: - - get diff --git a/examples/trino-operator/config/rbac/trinocluster_editor_role.yaml b/examples/trino-operator/config/rbac/trinocluster_editor_role.yaml deleted file mode 100644 index 97ed1313..00000000 --- a/examples/trino-operator/config/rbac/trinocluster_editor_role.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# This rule is not used by the project trino-operator 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 trino.kubedoop.dev. -# 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: trino-operator - app.kubernetes.io/managed-by: kustomize - name: trinocluster-editor-role -rules: -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters/status - verbs: - - get diff --git a/examples/trino-operator/config/rbac/trinocluster_viewer_role.yaml b/examples/trino-operator/config/rbac/trinocluster_viewer_role.yaml deleted file mode 100644 index 4188c874..00000000 --- a/examples/trino-operator/config/rbac/trinocluster_viewer_role.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This rule is not used by the project trino-operator itself. -# It is provided to allow the cluster admin to help manage permissions for users. -# -# Grants read-only access to trino.kubedoop.dev 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: trino-operator - app.kubernetes.io/managed-by: kustomize - name: trinocluster-viewer-role -rules: -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters - verbs: - - get - - list - - watch -- apiGroups: - - trino.kubedoop.dev - resources: - - trinoclusters/status - verbs: - - get diff --git a/examples/trino-operator/config/samples/kustomization.yaml b/examples/trino-operator/config/samples/kustomization.yaml index 25498785..0cd1bd77 100644 --- a/examples/trino-operator/config/samples/kustomization.yaml +++ b/examples/trino-operator/config/samples/kustomization.yaml @@ -1,4 +1,4 @@ -## Append samples of your project ## +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization resources: -- trino_v1alpha1_trinocluster.yaml -# +kubebuilder:scaffold:manifestskustomizesamples + - trino_v1alpha1_trinocluster.yaml diff --git a/examples/trino-operator/config/samples/trino_v1alpha1_trinocluster.yaml b/examples/trino-operator/config/samples/trino_v1alpha1_trinocluster.yaml index 26b7c97a..f59f06c8 100644 --- a/examples/trino-operator/config/samples/trino_v1alpha1_trinocluster.yaml +++ b/examples/trino-operator/config/samples/trino_v1alpha1_trinocluster.yaml @@ -3,49 +3,67 @@ kind: TrinoCluster metadata: name: demo-trino labels: - app.kubernetes.io/name: trino - app.kubernetes.io/instance: demo-trino - app.kubernetes.io/managed-by: trino-operator + # commons-operator restarter delivers ConfigMap changes to running processes. + # Enabling it for the first time also stamps the workload and triggers a rollout. + restarter.kubedoop.dev/enable: "true" spec: image: + repo: quay.io/zncdatadev productVersion: "476" - kubedoopVersion: "0.0.0-dev" - - # Coordinators role configuration (plural naming) + kubedoopVersion: 0.0.0-dev + pullPolicy: IfNotPresent + clusterConfig: + nodeEnvironment: kubedoop + stopped: false + reconciliationPaused: false coordinators: + roleConfig: + podDisruptionBudget: + enabled: true + maxUnavailable: 0 + config: + gracefulShutdownTimeout: 30s + resources: + cpu: {min: 500m, max: "2"} + memory: {limit: 1536Mi} + podOverrides: + spec: + automountServiceAccountToken: false + containers: + - name: vector + resources: + requests: {cpu: 50m, memory: 64Mi} + limits: {cpu: 200m, memory: 128Mi} roleGroups: default: replicas: 1 - config: - # Consumed by the framework (podOverrides take precedence when both are set): - # gracefulShutdownTimeout maps to the pod's terminationGracePeriodSeconds, and an - # `affinity:` block here (a full corev1.Affinity) lands on the pod spec as-is. - gracefulShutdownTimeout: "30s" - resources: - cpu: - min: "500m" - max: "1" - memory: - limit: "2Gi" - - # Workers role configuration (plural naming) workers: + replicas: 1 + roleConfig: + podDisruptionBudget: + enabled: true + maxUnavailable: 1 + config: + gracefulShutdownTimeout: 30s + resources: + cpu: {min: 500m, max: "2"} + memory: {limit: 1536Mi} + logging: + enableVectorAgent: true + containers: + trino: + console: {level: "OFF"} + file: {level: TRACE} + loggers: + ROOT: {level: INFO} + io.trino: {level: INFO} + podOverrides: + spec: + automountServiceAccountToken: false + containers: + - name: vector + resources: + requests: {cpu: 50m, memory: 64Mi} + limits: {cpu: 200m, memory: 128Mi} roleGroups: - default: - replicas: 3 - config: - resources: - cpu: - min: "1" - max: "2" - memory: - limit: "4Gi" - - # Catalog configuration - catalogs: - - name: hive - type: hive - properties: - "hive.metastore.uri": "thrift://hive-metastore:9083" - - name: tpch - type: tpch + default: {} diff --git a/examples/trino-operator/config/webhook/kustomization.yaml b/examples/trino-operator/config/webhook/kustomization.yaml deleted file mode 100644 index 36d4cc6e..00000000 --- a/examples/trino-operator/config/webhook/kustomization.yaml +++ /dev/null @@ -1,3 +0,0 @@ -resources: -- manifests.yaml -- service.yaml diff --git a/examples/trino-operator/config/webhook/manifests.yaml b/examples/trino-operator/config/webhook/manifests.yaml deleted file mode 100644 index 5f071c4b..00000000 --- a/examples/trino-operator/config/webhook/manifests.yaml +++ /dev/null @@ -1,52 +0,0 @@ ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: MutatingWebhookConfiguration -metadata: - name: mutating-webhook-configuration -webhooks: -- admissionReviewVersions: - - v1 - clientConfig: - service: - name: webhook-service - namespace: system - path: /mutate-trino-kubedoop-dev-v1alpha1-trinocluster - failurePolicy: Fail - name: mtrinocluster-v1alpha1.kb.io - rules: - - apiGroups: - - trino.kubedoop.dev - apiVersions: - - v1alpha1 - operations: - - CREATE - - UPDATE - resources: - - trinoclusters - sideEffects: None ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingWebhookConfiguration -metadata: - name: validating-webhook-configuration -webhooks: -- admissionReviewVersions: - - v1 - clientConfig: - service: - name: webhook-service - namespace: system - path: /validate-trino-kubedoop-dev-v1alpha1-trinocluster - failurePolicy: Fail - name: vtrinocluster-v1alpha1.kb.io - rules: - - apiGroups: - - trino.kubedoop.dev - apiVersions: - - v1alpha1 - operations: - - CREATE - - UPDATE - resources: - - trinoclusters - sideEffects: None diff --git a/examples/trino-operator/config/webhook/service.yaml b/examples/trino-operator/config/webhook/service.yaml deleted file mode 100644 index 68dda538..00000000 --- a/examples/trino-operator/config/webhook/service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app.kubernetes.io/name: trino-operator - app.kubernetes.io/managed-by: kustomize - name: webhook-service - namespace: system -spec: - ports: - - port: 443 - protocol: TCP - targetPort: 9443 - selector: - control-plane: controller-manager - app.kubernetes.io/name: trino-operator diff --git a/examples/trino-operator/go.mod b/examples/trino-operator/go.mod index b74c7b40..4e0678c0 100644 --- a/examples/trino-operator/go.mod +++ b/examples/trino-operator/go.mod @@ -3,78 +3,49 @@ module github.com/zncdatadev/operator-go/examples/trino-operator go 1.25.3 require ( - github.com/onsi/ginkgo/v2 v2.32.0 - github.com/onsi/gomega v1.40.0 github.com/zncdatadev/operator-go v0.0.0-00010101000000-000000000000 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 - k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 sigs.k8s.io/yaml v1.6.0 ) require ( - cel.dev/expr v0.25.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // 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.12.2 // 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.0 // 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 v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.29.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/moby/spdystream v0.5.1 // 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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/cobra v1.10.0 // indirect github.com/spf13/pflag v1.0.9 // 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.61.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.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.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect @@ -82,22 +53,15 @@ require ( golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.44.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.83.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.4 // indirect - k8s.io/apiserver v0.35.4 // indirect - k8s.io/component-base v0.35.4 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) diff --git a/examples/trino-operator/go.sum b/examples/trino-operator/go.sum index 39306a64..43edc025 100644 --- a/examples/trino-operator/go.sum +++ b/examples/trino-operator/go.sum @@ -1,20 +1,9 @@ -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 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/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -26,23 +15,12 @@ github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 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.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-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 v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -55,14 +33,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= -github.com/google/cel-go v0.29.0/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= @@ -74,16 +46,8 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= -github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -99,12 +63,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= -github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= -github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= 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= @@ -113,8 +71,6 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd 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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= @@ -132,10 +88,6 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= -github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= -github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -149,36 +101,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -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.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -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.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= 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= @@ -189,8 +113,6 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -211,14 +133,6 @@ golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -237,20 +151,14 @@ k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TC k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c= k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= -k8s.io/apiserver v0.35.4 h1:vtuFqNFmF9bPRdHDL2lpK6qCTPWDreZJL4LRPwVM6ho= -k8s.io/apiserver v0.35.4/go.mod h1:JnBcb+J8kFXKpZkgcbcUnPBBHi4qgBii1I7dLxFY/oo= k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= -k8s.io/component-base v0.35.4 h1:6n1tNJ87johN0Hif0Fs8K2GMthsaUwMqCebUDLYyv7U= -k8s.io/component-base v0.35.4/go.mod h1:qaDJgz5c1KYKla9occFmlJEfPpkuA55s90G509R+PeY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/examples/trino-operator/internal/config/catalog_config.go b/examples/trino-operator/internal/config/catalog_config.go deleted file mode 100644 index 14116faa..00000000 --- a/examples/trino-operator/internal/config/catalog_config.go +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 config - -import ( - "fmt" - "strings" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" -) - -// CatalogConfigBuilder builds Trino catalog configuration -type CatalogConfigBuilder struct { - catalogs []trinov1alpha1.CatalogSpec -} - -// NewCatalogConfigBuilder creates a new CatalogConfigBuilder -func NewCatalogConfigBuilder() *CatalogConfigBuilder { - return &CatalogConfigBuilder{} -} - -// WithCatalogs sets the catalogs to configure -func (b *CatalogConfigBuilder) WithCatalogs(catalogs []trinov1alpha1.CatalogSpec) *CatalogConfigBuilder { - b.catalogs = catalogs - return b -} - -// Build generates the catalog configurations as a map -func (b *CatalogConfigBuilder) Build() map[string]string { - result := make(map[string]string) - - for _, catalog := range b.catalogs { - result[catalog.Name] = b.buildCatalogProperties(catalog) - } - - return result -} - -// buildCatalogProperties builds the properties string for a catalog -func (b *CatalogConfigBuilder) buildCatalogProperties(catalog trinov1alpha1.CatalogSpec) string { - lines := make([]string, 0, 1+len(catalog.Properties)) - - // Add connector.name based on catalog type - connectorName := b.getConnectorName(catalog.Type) - lines = append(lines, fmt.Sprintf("connector.name=%s", connectorName)) - - // Add custom properties - for key, value := range catalog.Properties { - lines = append(lines, fmt.Sprintf("%s=%s", key, value)) - } - - return strings.Join(lines, "\n") -} - -// getConnectorName returns the connector name for a catalog type -func (b *CatalogConfigBuilder) getConnectorName(catalogType string) string { - connectors := map[string]string{ - "hive": "hive", - "iceberg": "iceberg", - "kafka": "kafka", - "mysql": "mysql", - "postgresql": "postgresql", - "delta": "delta", - "tpch": "tpch", - "tpcds": "tpcds", - } - - if connector, ok := connectors[catalogType]; ok { - return connector - } - return catalogType -} diff --git a/examples/trino-operator/internal/config/catalog_config_test.go b/examples/trino-operator/internal/config/catalog_config_test.go deleted file mode 100644 index 791b1408..00000000 --- a/examples/trino-operator/internal/config/catalog_config_test.go +++ /dev/null @@ -1,239 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 config - -import ( - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" -) - -var _ = Describe("CatalogConfigBuilder", func() { - Describe("NewCatalogConfigBuilder", func() { - It("should create a new builder with empty catalogs", func() { - builder := NewCatalogConfigBuilder() - Expect(builder).NotTo(BeNil()) - Expect(builder.catalogs).To(BeEmpty()) - }) - }) - - Describe("WithCatalogs", func() { - It("should set catalogs on the builder", func() { - builder := NewCatalogConfigBuilder() - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: "hive", Type: "hive"}, - } - result := builder.WithCatalogs(catalogs) - Expect(result).To(Equal(builder)) - Expect(builder.catalogs).To(HaveLen(1)) - Expect(builder.catalogs[0].Name).To(Equal("hive")) - }) - - It("should allow method chaining", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: "iceberg", Type: "iceberg"}, - } - builder := NewCatalogConfigBuilder().WithCatalogs(catalogs) - Expect(builder).NotTo(BeNil()) - Expect(builder.catalogs).To(HaveLen(1)) - }) - }) - - Describe("Build", func() { - Context("with empty catalog list", func() { - It("should return an empty map", func() { - result := NewCatalogConfigBuilder().WithCatalogs([]trinov1alpha1.CatalogSpec{}).Build() - Expect(result).To(BeEmpty()) - }) - - It("should return an empty map when no catalogs are set", func() { - result := NewCatalogConfigBuilder().Build() - Expect(result).To(BeEmpty()) - }) - }) - - Context("with single catalog", func() { - DescribeTable("should set connector.name correctly for each catalog type", - func(catalogType string, expectedConnector string) { - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: catalogType, Type: catalogType}, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result).To(HaveKey(catalogType)) - Expect(result[catalogType]).To(ContainSubstring("connector.name=" + expectedConnector)) - }, - Entry("hive catalog", "hive", "hive"), - Entry("iceberg catalog", "iceberg", "iceberg"), - Entry("kafka catalog", "kafka", "kafka"), - Entry("mysql catalog", "mysql", "mysql"), - Entry("postgresql catalog", "postgresql", "postgresql"), - Entry("delta catalog", "delta", "delta"), - Entry("tpch catalog", "tpch", "tpch"), - Entry("tpcds catalog", "tpcds", "tpcds"), - ) - - It("should use catalog type as connector name for unknown types", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: "custom", Type: "custom-connector"}, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["custom"]).To(ContainSubstring("connector.name=custom-connector")) - }) - - It("should include custom properties in the output", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "hive", - Type: "hive", - Properties: map[string]string{ - "hive.metastore.uri": "thrift://metastore:9083", - }, - }, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["hive"]).To(ContainSubstring("connector.name=hive")) - Expect(result["hive"]).To(ContainSubstring("hive.metastore.uri=thrift://metastore:9083")) - }) - - It("should include multiple custom properties", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "iceberg", - Type: "iceberg", - Properties: map[string]string{ - "iceberg.catalog.type": "hadoop", - "iceberg.warehouse.location": "/warehouse/iceberg", - }, - }, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["iceberg"]).To(ContainSubstring("connector.name=iceberg")) - Expect(result["iceberg"]).To(ContainSubstring("iceberg.catalog.type=hadoop")) - Expect(result["iceberg"]).To(ContainSubstring("iceberg.warehouse.location=/warehouse/iceberg")) - }) - }) - - Context("with multiple catalogs", func() { - It("should return a map with all catalog names as keys", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: "hive", Type: "hive"}, - {Name: "iceberg", Type: "iceberg"}, - {Name: "kafka", Type: "kafka"}, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKey("hive")) - Expect(result).To(HaveKey("iceberg")) - Expect(result).To(HaveKey("kafka")) - }) - - It("should set correct connector.name for each catalog", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - {Name: "my-hive", Type: "hive"}, - {Name: "my-iceberg", Type: "iceberg"}, - {Name: "my-kafka", Type: "kafka"}, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["my-hive"]).To(ContainSubstring("connector.name=hive")) - Expect(result["my-iceberg"]).To(ContainSubstring("connector.name=iceberg")) - Expect(result["my-kafka"]).To(ContainSubstring("connector.name=kafka")) - }) - - It("should handle catalogs with and without properties", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "hive", - Type: "hive", - Properties: map[string]string{ - "hive.metastore.uri": "thrift://metastore:9083", - }, - }, - {Name: "tpch", Type: "tpch"}, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["hive"]).To(ContainSubstring("connector.name=hive")) - Expect(result["hive"]).To(ContainSubstring("hive.metastore.uri=thrift://metastore:9083")) - Expect(result["tpch"]).To(ContainSubstring("connector.name=tpch")) - Expect(strings.Count(result["tpch"], "\n")).To(BeZero()) // Only connector.name line - }) - - It("should handle multiple catalogs of the same type", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "hive-prod", - Type: "hive", - Properties: map[string]string{ - "hive.metastore.uri": "thrift://prod-metastore:9083", - }, - }, - { - Name: "hive-dev", - Type: "hive", - Properties: map[string]string{ - "hive.metastore.uri": "thrift://dev-metastore:9083", - }, - }, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result).To(HaveLen(2)) - Expect(result["hive-prod"]).To(ContainSubstring("connector.name=hive")) - Expect(result["hive-prod"]).To(ContainSubstring("hive.metastore.uri=thrift://prod-metastore:9083")) - Expect(result["hive-dev"]).To(ContainSubstring("connector.name=hive")) - Expect(result["hive-dev"]).To(ContainSubstring("hive.metastore.uri=thrift://dev-metastore:9083")) - }) - }) - - Context("output format", func() { - It("should format properties as key=value with newlines", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "test", - Type: "hive", - Properties: map[string]string{ - "property1": "value1", - "property2": "value2", - }, - }, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - lines := strings.Split(result["test"], "\n") - Expect(lines).To(ContainElements( - "connector.name=hive", - "property1=value1", - "property2=value2", - )) - }) - - It("should start with connector.name as the first property", func() { - catalogs := []trinov1alpha1.CatalogSpec{ - { - Name: "test", - Type: "postgresql", - Properties: map[string]string{ - "connection-url": "jdbc:postgresql://localhost:5432/db", - }, - }, - } - result := NewCatalogConfigBuilder().WithCatalogs(catalogs).Build() - Expect(result["test"]).To(HavePrefix("connector.name=postgresql")) - }) - }) - }) -}) diff --git a/examples/trino-operator/internal/config/suite_test.go b/examples/trino-operator/internal/config/suite_test.go deleted file mode 100644 index d75aea89..00000000 --- a/examples/trino-operator/internal/config/suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 config - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestConfig(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Config Suite") -} diff --git a/examples/trino-operator/internal/config/trino_config.go b/examples/trino-operator/internal/config/trino_config.go deleted file mode 100644 index 7266274c..00000000 --- a/examples/trino-operator/internal/config/trino_config.go +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 config - -import ( - "fmt" - "strings" - - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" -) - -// JVMConfigBuilder builds Trino's jvm.config. Unlike config.properties (which is key=value and -// flows through the SDK merge pipeline via product.ComputeConfig), jvm.config is a -// newline-delimited list of JVM flags, so it is generated here and appended to the ConfigMap -// by the handler. -type JVMConfigBuilder struct { - maxMemory string - gcOptions []string - extraOpts []string -} - -// NewJVMConfigBuilder creates a new JVMConfigBuilder -func NewJVMConfigBuilder() *JVMConfigBuilder { - return &JVMConfigBuilder{ - gcOptions: []string{ - "-XX:+UseG1GC", - "-XX:G1HeapRegionSize=32M", - "-XX:+ExplicitGCInvokesConcurrent", - "-XX:+ExitOnOutOfMemoryError", - }, - extraOpts: []string{ - "-Djdk.attach.allowAttachSelf=true", - }, - } -} - -// ForCoordinator configures the builder for Coordinator role -func (b *JVMConfigBuilder) ForCoordinator() *JVMConfigBuilder { - b.maxMemory = constants.DefaultCoordinatorMaxMemory - return b -} - -// ForWorker configures the builder for Worker role -func (b *JVMConfigBuilder) ForWorker() *JVMConfigBuilder { - b.maxMemory = constants.DefaultWorkerMaxMemory - return b -} - -// WithMaxMemory sets the maximum heap memory -func (b *JVMConfigBuilder) WithMaxMemory(memory string) *JVMConfigBuilder { - b.maxMemory = memory - return b -} - -// Build generates the JVM configuration as a string -func (b *JVMConfigBuilder) Build() string { - lines := make([]string, 0, 1+len(b.gcOptions)+len(b.extraOpts)) - - // Memory settings - lines = append(lines, fmt.Sprintf("-Xmx%s", b.maxMemory)) - - // GC options - lines = append(lines, b.gcOptions...) - - // Extra options - lines = append(lines, b.extraOpts...) - - return strings.Join(lines, "\n") -} diff --git a/examples/trino-operator/internal/config/trino_config_test.go b/examples/trino-operator/internal/config/trino_config_test.go deleted file mode 100644 index 206a2081..00000000 --- a/examples/trino-operator/internal/config/trino_config_test.go +++ /dev/null @@ -1,123 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 config - -import ( - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" -) - -var _ = Describe("JVMConfigBuilder", func() { - var builder *JVMConfigBuilder - - BeforeEach(func() { - builder = NewJVMConfigBuilder() - }) - - Context("NewJVMConfigBuilder", func() { - It("should create builder with default GC options", func() { - Expect(builder).NotTo(BeNil()) - // Verify GC options are included in build output - result := builder.WithMaxMemory("4G").Build() - Expect(result).To(ContainSubstring("-XX:+UseG1GC")) - Expect(result).To(ContainSubstring("-XX:G1HeapRegionSize=32M")) - Expect(result).To(ContainSubstring("-XX:+ExplicitGCInvokesConcurrent")) - Expect(result).To(ContainSubstring("-XX:+ExitOnOutOfMemoryError")) - }) - - It("should create builder with default extra options", func() { - Expect(builder).NotTo(BeNil()) - // Verify extra options are included in build output - result := builder.WithMaxMemory("4G").Build() - Expect(result).To(ContainSubstring("-Djdk.attach.allowAttachSelf=true")) - }) - }) - - Context("ForCoordinator", func() { - It("should set coordinator max memory", func() { - result := builder.ForCoordinator() - Expect(result).To(Equal(builder)) - Expect(builder.maxMemory).To(Equal(constants.DefaultCoordinatorMaxMemory)) - }) - }) - - Context("ForWorker", func() { - It("should set worker max memory", func() { - result := builder.ForWorker() - Expect(result).To(Equal(builder)) - Expect(builder.maxMemory).To(Equal(constants.DefaultWorkerMaxMemory)) - }) - }) - - Context("WithMaxMemory", func() { - It("should set custom max memory", func() { - result := builder.WithMaxMemory("8G") - Expect(result).To(Equal(builder)) - Expect(builder.maxMemory).To(Equal("8G")) - }) - - It("should override previous memory setting", func() { - builder.ForCoordinator() - builder.WithMaxMemory("16G") - Expect(builder.maxMemory).To(Equal("16G")) - }) - }) - - Context("Build", func() { - It("should start with -Xmx memory setting", func() { - builder.WithMaxMemory("4G") - result := builder.Build() - Expect(result).To(HavePrefix("-Xmx4G")) - }) - - It("should include all GC options", func() { - builder.WithMaxMemory("4G") - result := builder.Build() - Expect(result).To(ContainSubstring("-XX:+UseG1GC")) - Expect(result).To(ContainSubstring("-XX:G1HeapRegionSize=32M")) - Expect(result).To(ContainSubstring("-XX:+ExplicitGCInvokesConcurrent")) - Expect(result).To(ContainSubstring("-XX:+ExitOnOutOfMemoryError")) - }) - - It("should include extra options", func() { - builder.WithMaxMemory("4G") - result := builder.Build() - Expect(result).To(ContainSubstring("-Djdk.attach.allowAttachSelf=true")) - }) - - It("should separate options with newlines", func() { - builder.WithMaxMemory("4G") - result := builder.Build() - lines := strings.Split(result, "\n") - Expect(len(lines)).To(BeNumerically(">", 5)) - }) - - It("should build coordinator JVM config correctly", func() { - result := builder.ForCoordinator().Build() - Expect(result).To(ContainSubstring("-Xmx" + constants.DefaultCoordinatorMaxMemory)) - }) - - It("should build worker JVM config correctly", func() { - result := builder.ForWorker().Build() - Expect(result).To(ContainSubstring("-Xmx" + constants.DefaultWorkerMaxMemory)) - }) - }) -}) diff --git a/examples/trino-operator/internal/constants/constants.go b/examples/trino-operator/internal/constants/constants.go deleted file mode 100644 index c3617e49..00000000 --- a/examples/trino-operator/internal/constants/constants.go +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 constants - -import ( - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" -) - -// Image constants -const ( - // DefaultImageRepo is the default container image repository - DefaultImageRepo = "quay.io/zncdatadev" - // DefaultImageProductVersion is the default Trino product version - DefaultImageProductVersion = "476" - // DefaultImageKubedoopVersion is the default kubedoop stack version - DefaultImageKubedoopVersion = "0.0.0-dev" - // ProductName is the product name used for image resolution - ProductName = "trino" - // MainContainerName is the name of the main Trino container. It is also the key used to - // look up per-container logging config (logging.containers.). - MainContainerName = "trino" -) - -// RoleGroup constants -const ( - // DefaultRoleGroupName is the default role group name - DefaultRoleGroupName = "default" -) - -// Port constants -const ( - // DefaultHTTPPort is the default HTTP API port for Trino - DefaultHTTPPort int32 = 8080 -) - -// Replica constants -const ( - // DefaultCoordinatorReplicas is the default number of coordinator replicas - DefaultCoordinatorReplicas int32 = 1 - // DefaultWorkerReplicas is the default number of worker replicas - DefaultWorkerReplicas int32 = 2 -) - -// Resource constants -const ( - // DefaultCoordinatorCPURequest is the default CPU request for coordinators - DefaultCoordinatorCPURequest = "500m" - // DefaultCoordinatorCPULimit is the default CPU limit for coordinators - DefaultCoordinatorCPULimit = "1" - // DefaultCoordinatorMemoryRequest is the default memory request for coordinators - DefaultCoordinatorMemoryRequest = "1Gi" - // DefaultCoordinatorMemoryLimit is the default memory limit for coordinators - DefaultCoordinatorMemoryLimit = "2Gi" - - // DefaultWorkerCPURequest is the default CPU request for workers - DefaultWorkerCPURequest = "500m" - // DefaultWorkerCPULimit is the default CPU limit for workers - DefaultWorkerCPULimit = "2" - // DefaultWorkerMemoryRequest is the default memory request for workers - DefaultWorkerMemoryRequest = "2Gi" - // DefaultWorkerMemoryLimit is the default memory limit for workers - DefaultWorkerMemoryLimit = "4Gi" -) - -// JVM constants -const ( - // DefaultCoordinatorMaxMemory is the default max heap memory for coordinators - DefaultCoordinatorMaxMemory = "2G" - // DefaultWorkerMaxMemory is the default max heap memory for workers - DefaultWorkerMaxMemory = "4G" -) - -// ImageDefaults is what spec.image leaves empty, and the single source both the handler and the -// validating webhook read — so the validator can never reject a spec the handler would resolve. -// -// It is a function rather than a var because KubedoopVersion is the operator's own build version in -// a real operator (here a build-time constant), and it must be read at reconcile time: a webhook -// that wrote it into the spec would freeze every cluster on the operator version that admitted it. -func ImageDefaults() commonsv1alpha1.ImageSpec { - return commonsv1alpha1.ImageSpec{ - Repo: DefaultImageRepo, - ProductVersion: DefaultImageProductVersion, - KubedoopVersion: DefaultImageKubedoopVersion, - } -} diff --git a/examples/trino-operator/internal/controller/rbac_test.go b/examples/trino-operator/internal/controller/rbac_test.go deleted file mode 100644 index 6ceebd87..00000000 --- a/examples/trino-operator/internal/controller/rbac_test.go +++ /dev/null @@ -1,140 +0,0 @@ -/* -Copyright 2026 ZNCDataDev. - -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 controller - -import ( - "fmt" - "os" - "path/filepath" - "slices" - "sort" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - rbacv1 "k8s.io/api/rbac/v1" - "sigs.k8s.io/yaml" -) - -// grantSet flattens a ClusterRole into the set of "group/resource:verb" triples it allows. -// -// Expanding rather than matching is what makes the comparison work in BOTH directions. The previous -// shape asked "does the role allow X?" per required triple, which is only half a check: it passes -// for a role that also grants ten things nobody needs, and — because a wildcard matches anything — -// it passes for `apiGroups: ["*"], resources: ["*"], verbs: ["*"]`. A wildcard is therefore recorded -// verbatim here, so it compares unequal to the enumerated set instead of satisfying it. -func grantSet(role *rbacv1.ClusterRole) []string { - var grants []string - for _, rule := range role.Rules { - for _, group := range rule.APIGroups { - for _, resource := range rule.Resources { - for _, verb := range rule.Verbs { - grants = append(grants, fmt.Sprintf("%s/%s:%s", group, resource, verb)) - } - } - } - } - sort.Strings(grants) - return slices.Compact(grants) -} - -// The generated manager ClusterRole is what the operator actually runs with. It is also the set -// downstream operators copy — docs/security.md §3.3 publishes it as the canonical operator-side -// permission set — so this spec pins it EXACTLY rather than as a lower bound. -// -// Two failure directions, both real: -// - missing a grant: the GenericReconciler cannot start its informers or cannot write what it -// owns. Most of those fail loudly at boot; `events` and `pods` do not (§3.3.3), which is -// precisely why they need a test rather than a first deployment to catch. -// - granting extra: the published minimum stops being minimal, and every adopter copying it -// inherits privileges the framework never asked for. -var _ = Describe("Manager ClusterRole", func() { - var role *rbacv1.ClusterRole - - BeforeEach(func() { - content, err := os.ReadFile(filepath.Join("..", "..", "config", "rbac", "role.yaml")) - Expect(err).NotTo(HaveOccurred()) - - role = &rbacv1.ClusterRole{} - Expect(yaml.Unmarshal(content, role)).To(Succeed()) - Expect(role.Name).To(Equal("manager-role")) - }) - - It("grants exactly what the framework consumes, and nothing more", func() { - // Derived from the framework's call sites, not from the markers this file checks — see - // docs/security.md §3.3 for the evidence behind each verb. - // - // Only two verbs are withheld, and each withholding removes a real capability: - // - // - no `delete` on serviceaccounts: nothing deletes one; owner-reference GC reclaims it, - // and `delete` is not reachable through any other verb. - // - no `update`/`patch` on the CR body: the framework writes only Status().Update, and an - // operator that can rewrite its users' spec is a different trust proposition. - // - // `patch` on the owned kinds stays even though the framework only ever Updates them: next - // to `update` it grants no additional capability, and the SDK exports K8sUtil.Patch, which a - // product may legitimately call. `get` on pods is there for the exported ExecUtil.PodIsReady - // for the same reason — the framework itself only ever Lists them. - expected := []string{ - // The CR, its status, and its finalizers. The last is not about SDK finalizers — there - // are none — but about SetControllerReference stamping blockOwnerDeletion, which the - // OwnerReferencesPermissionEnforcement admission plugin gates on the owner's - // finalizers subresource. - "trino.kubedoop.dev/trinoclusters:get", - "trino.kubedoop.dev/trinoclusters:list", - "trino.kubedoop.dev/trinoclusters:watch", - "trino.kubedoop.dev/trinoclusters/status:get", - "trino.kubedoop.dev/trinoclusters/status:update", - "trino.kubedoop.dev/trinoclusters/status:patch", - "trino.kubedoop.dev/trinoclusters/finalizers:update", - - // The workload the framework builds and reclaims. - "apps/statefulsets:get", "apps/statefulsets:list", "apps/statefulsets:watch", - "apps/statefulsets:create", "apps/statefulsets:update", "apps/statefulsets:patch", - "apps/statefulsets:delete", - "/configmaps:get", "/configmaps:list", "/configmaps:watch", - "/configmaps:create", "/configmaps:update", "/configmaps:patch", "/configmaps:delete", - "/services:get", "/services:list", "/services:watch", - "/services:create", "/services:update", "/services:patch", "/services:delete", - "policy/poddisruptionbudgets:get", "policy/poddisruptionbudgets:list", - "policy/poddisruptionbudgets:watch", "policy/poddisruptionbudgets:create", - "policy/poddisruptionbudgets:update", "policy/poddisruptionbudgets:patch", - "policy/poddisruptionbudgets:delete", - - // The workload identity, which every cluster gets and NOTHING ever deletes. - "/serviceaccounts:get", "/serviceaccounts:list", "/serviceaccounts:watch", - "/serviceaccounts:create", "/serviceaccounts:update", "/serviceaccounts:patch", - - // Orphaned PVCs, when the delete-pvcs annotation is set on the CR at runtime. - "/persistentvolumeclaims:get", "/persistentvolumeclaims:list", - "/persistentvolumeclaims:watch", "/persistentvolumeclaims:delete", - - // Health evaluation. Without this, Degraded cannot be computed and a failed List is - // deliberately not reported as the cluster's fault — so it goes quiet, not loud. - "/pods:get", "/pods:list", "/pods:watch", - - // Events. Without this, client-go discards every one with no error and no retry. - "/events:create", "/events:patch", - } - sort.Strings(expected) - - Expect(grantSet(role)).To(Equal(expected), - "the generated ClusterRole drifted from the set docs/security.md §3.3 publishes; "+ - "regenerate with `make manifests` after editing the markers, and update both if the "+ - "framework's API usage genuinely changed") - }) -}) diff --git a/examples/trino-operator/internal/controller/suite_test.go b/examples/trino-operator/internal/controller/suite_test.go deleted file mode 100644 index a82e53d1..00000000 --- a/examples/trino-operator/internal/controller/suite_test.go +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright 2026 ZNCDataDev. - -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 controller - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - // +kubebuilder:scaffold:imports -) - -// These tests use Ginkgo (BDD-style Go testing framework). Refer to -// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. - -var ( - ctx context.Context - cancel context.CancelFunc - testEnv *envtest.Environment - cfg *rest.Config - k8sClient client.Client -) - -func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Controller Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - ctx, cancel = context.WithCancel(context.TODO()) - - var err error - err = trinov1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - // +kubebuilder:scaffold:scheme - - By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - } - - // Retrieve the first found binary directory to allow running tests from IDEs - if getFirstFoundEnvTestBinaryDir() != "" { - testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() - } - - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) -}) - -var _ = AfterSuite(func() { - By("tearing down the test environment") - cancel() - Eventually(func() error { - return testEnv.Stop() - }, time.Minute, time.Second).Should(Succeed()) -}) - -// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. -// ENVTEST-based tests depend on specific binaries, usually located in paths set by -// controller-runtime. When running tests directly (e.g., via an IDE) without using -// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. -// -// This function streamlines the process by finding the required binaries, similar to -// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are -// properly set up, run 'make setup-envtest' beforehand. -func getFirstFoundEnvTestBinaryDir() string { - basePath := filepath.Join("..", "..", "bin", "k8s") - entries, err := os.ReadDir(basePath) - if err != nil { - logf.Log.Error(err, "Failed to read directory", "path", basePath) - return "" - } - for _, entry := range entries { - if entry.IsDir() { - return filepath.Join(basePath, entry.Name()) - } - } - return "" -} diff --git a/examples/trino-operator/internal/controller/trino_handler.go b/examples/trino-operator/internal/controller/trino_handler.go deleted file mode 100644 index f6f7d4f7..00000000 --- a/examples/trino-operator/internal/controller/trino_handler.go +++ /dev/null @@ -1,193 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 controller - -import ( - "context" - "fmt" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - trinoconfig "github.com/zncdatadev/operator-go/examples/trino-operator/internal/config" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - "github.com/zncdatadev/operator-go/pkg/config" - "github.com/zncdatadev/operator-go/pkg/productlogging" - "github.com/zncdatadev/operator-go/pkg/reconciler" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// Compile-time proof that TrinoCluster wires framework-owned vector.yaml generation: when a role -// group enables the Vector agent, the GenericReconciler reads this ConfigMap name, resolves the -// aggregator address, and generates vector.yaml into the role group ConfigMap. -var _ reconciler.VectorAggregatorProvider = (*trinov1alpha1.TrinoCluster)(nil) - -// RBAC for the resources the SDK GenericReconciler consumes on behalf of a TrinoCluster. This is -// the OPERATOR's own ClusterRole, not the workload's (that is GenericReconcilerConfig's -// WorkloadRBACRules, which this example does not use) — the canonical set, with the reason for each -// grant, is docs/security.md §3.3. Keep this block in step with it: the SDK cannot declare these -// itself, because controller-gen never walks a dependency's packages, so this file is what every -// adopter copies. Regenerate config/rbac/role.yaml with `make manifests` after editing. -// -// Two verbs are deliberately absent, and each absence removes a capability this operator does not -// need — which is the test docs/security.md §3.3.1 applies, rather than "the framework never calls -// it". `patch` alongside `update` grants nothing extra (a PATCH is reachable through a -// read-modify-write PUT), and the SDK exports helpers that need it, so it stays: -// - no `delete` on serviceaccounts — nothing deletes one; it is reclaimed by owner-reference GC -// - no `update`/`patch` on the CR body — the framework writes only Status().Update, and an -// operator that can rewrite its users' spec is a different trust proposition. Add it back if -// this operator ever registers a finalizer. -// -// Three things do not announce themselves when missing (§3.3.3): `events` is discarded by client-go -// with no error; `pods` stops the Degraded condition being computed; and the cleanup path swallows -// its errors, so a 403 on a teardown delete — the persistentvolumeclaims grant, say — leaves the -// pass reporting success. Everything else fails loudly on the apply path — a forbidden informer -// takes manager.Start down with it. -// -// +kubebuilder:rbac:groups=trino.kubedoop.dev,resources=trinoclusters,verbs=get;list;watch -// +kubebuilder:rbac:groups=trino.kubedoop.dev,resources=trinoclusters/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=trino.kubedoop.dev,resources=trinoclusters/finalizers,verbs=update -// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=core,resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch -// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=core,resources=persistentvolumeclaims,verbs=get;list;watch;delete -// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch -// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch - -// TrinoRoleGroupHandler builds Trino role group resources. It embeds the SDK's -// BaseRoleGroupHandler so the framework owns the bulk of resource orchestration — ConfigMap -// (rendered from the merged config, including the product config computed by -// product.ComputeConfig), Services, the StatefulSet (with sidecars and podOverrides applied -// by the framework), and the PDB. The override below only adds the product-specific bits the -// merge pipeline cannot model declaratively. -type TrinoRoleGroupHandler struct { - *reconciler.BaseRoleGroupHandler[*trinov1alpha1.TrinoCluster] -} - -// NewTrinoRoleGroupHandler creates the handler. It now carries only reconcile-invariant -// collaborators: everything a ROLE is made of is declared per reconcile by DeclareRoles below, -// with the cr in hand. -func NewTrinoRoleGroupHandler(scheme *runtime.Scheme) *TrinoRoleGroupHandler { - base := reconciler.NewBaseRoleGroupHandler[*trinov1alpha1.TrinoCluster](scheme) - - // config.properties (provided as a map by the role group resolver / CRD overrides) is - // rendered with the properties format adapter. - base.ConfigGenerator = config.NewMultiFormatConfigGenerator() - base.ConfigGenerator.RegisterDefaultFormats() - - // Trino reads config from /etc/trino. - base.ConfigMountPath = "/etc/trino" - - return &TrinoRoleGroupHandler{BaseRoleGroupHandler: base} -} - -// DeclareRoles implements reconciler.RoleProvider: one statement per role, produced once per -// reconcile pass with the cr in hand. -// -// It replaces seven role-keyed maps on the handler plus their per-call twins on the build context. -// Taking the cr is what lets it be static data — a port that moves because the CR enabled TLS is -// computed here, from THIS cr, rather than assigned into process-wide handler state that the next -// cluster would inherit. -func (h *TrinoRoleGroupHandler) DeclareRoles( - _ context.Context, _ client.Client, _ *trinov1alpha1.TrinoCluster, -) (reconciler.RoleCatalog, error) { - // Ports are the same for both roles. Ports[0] backs the generated TCP readiness probe. - shared := reconciler.RoleDeclaration{ - MainContainerName: constants.MainContainerName, - ContainerPorts: []corev1.ContainerPort{ - {Name: "http", ContainerPort: constants.DefaultHTTPPort, Protocol: corev1.ProtocolTCP}, - }, - ServicePorts: []corev1.ServicePort{ - {Name: "http", Port: constants.DefaultHTTPPort, Protocol: corev1.ProtocolTCP}, - }, - // Declarative logging: the framework renders the Log4j2 config file into the ConfigMap - // from the folded CRD logging spec. The container named here must be the pod's primary - // container, which MainContainerName pins. - LogProducers: []productlogging.ContainerLogging{ - {Container: constants.MainContainerName, Framework: productlogging.LoggingFrameworkLog4j2}, - }, - } - return reconciler.RoleCatalog{ - product.RoleCoordinators: shared, - product.RoleWorkers: shared, - }, nil -} - -// BuildResources delegates the 90% to the framework, then appends the product-specific pieces -// the merge pipeline cannot express: -// - the CR-driven container image (resolved with the product name), -// - jvm.config (a newline-delimited flag list, not key=value), -// - the coordinator-only catalog files. -func (h *TrinoRoleGroupHandler) BuildResources( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, - buildCtx *reconciler.RoleGroupBuildContext, -) (*reconciler.RoleGroupResources, error) { - resources, err := h.BaseRoleGroupHandler.BuildResources(ctx, k8sClient, cr, buildCtx) - if err != nil { - return nil, err - } - - if resources.ConfigMap != nil { - if resources.ConfigMap.Data == nil { - resources.ConfigMap.Data = make(map[string]string) - } - - // jvm.config is a flag list, not key=value, so it is generated here as a whole file - // (like the logging file) rather than flowing through the merge pipeline. setIfAbsent - // only avoids clobbering a jvm.config the pipeline already placed under this key; note - // that configOverrides renders as key=value, so it is NOT a suitable channel for tuning - // JVM flags — a product needing user-tunable JVM options would expose a typed field/env. - setIfAbsent(resources.ConfigMap.Data, "jvm.config", func() string { return jvmConfig(buildCtx.RoleName) }) - - // Catalog connector files live only on the coordinator. - if buildCtx.RoleName == product.RoleCoordinators { - catalogs := trinoconfig.NewCatalogConfigBuilder().WithCatalogs(cr.Spec.Catalogs).Build() - for name, content := range catalogs { - key := fmt.Sprintf("catalog/%s.properties", name) - setIfAbsent(resources.ConfigMap.Data, key, func() string { return content }) - } - } - } - - return resources, nil -} - -// setIfAbsent writes value() into data[key] only when the key is not already present, so -// product config never overwrites config the merge pipeline produced (CRD always wins). -func setIfAbsent(data map[string]string, key string, value func() string) { - if _, exists := data[key]; !exists { - data[key] = value() - } -} - -// jvmConfig renders the role-specific JVM options. -func jvmConfig(roleName string) string { - b := trinoconfig.NewJVMConfigBuilder() - if roleName == product.RoleWorkers { - b.ForWorker() - } else { - b.ForCoordinator() - } - return b.Build() -} - -// Ensure interface implementation. -var _ reconciler.RoleGroupHandler[*trinov1alpha1.TrinoCluster] = &TrinoRoleGroupHandler{} diff --git a/examples/trino-operator/internal/controller/trino_handler_test.go b/examples/trino-operator/internal/controller/trino_handler_test.go deleted file mode 100644 index d153ec8e..00000000 --- a/examples/trino-operator/internal/controller/trino_handler_test.go +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 controller - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/config" - "github.com/zncdatadev/operator-go/pkg/reconciler" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/utils/ptr" -) - -// buildCtxFor assembles a RoleGroupBuildContext the way the GenericReconciler would: it merges -// the product defaults (lowest layer) with the given CRD overrides (highest layer) through the -// SDK ConfigMerger, so the handler sees exactly what it would at runtime. -func buildCtxFor(cr *trinov1alpha1.TrinoCluster, role string, crdOverrides *commonsv1alpha1.OverridesSpec) *reconciler.RoleGroupBuildContext { - const group = "default" - buildCtx := &reconciler.RoleGroupBuildContext{ - ClusterName: cr.Name, - ClusterNamespace: "default", - ClusterLabels: map[string]string{"app": "trino"}, - ClusterSpec: cr.GetSpec(), - RoleName: role, - RoleSpec: &commonsv1alpha1.RoleSpec{}, - RoleGroupName: group, - RoleGroupSpec: commonsv1alpha1.RoleGroupSpec{Replicas: ptr.To(int32(1))}, - ResourceName: reconciler.RoleGroupResourceName(cr.Name, role, group), - ResolvedImage: reconciler.ResolvedImage{Reference: "trinodb/trino:435"}, - } - - // The reconciler declares the roles once per pass and folds the derived contribution beneath - // the CRD's overrides; do the same here so the handler sees exactly what it would at runtime. - catalog, err := NewTrinoRoleGroupHandler(nil).DeclareRoles(context.Background(), nil, cr) - Expect(err).NotTo(HaveOccurred()) - buildCtx.Declaration = catalog[role] - - derived, err := product.ComputeConfig(context.Background(), nil, cr, buildCtx) - Expect(err).NotTo(HaveOccurred()) - buildCtx.MergedConfig = config.NewConfigMerger().Merge( - &commonsv1alpha1.OverridesSpec{ConfigOverrides: derived.ConfigOverrides, - EnvOverrides: derived.EnvVars}, - crdOverrides) - - return buildCtx -} - -func newTrinoCR() *trinov1alpha1.TrinoCluster { - cr := &trinov1alpha1.TrinoCluster{} - cr.Name = "test-trino" - cr.Namespace = "default" - cr.Spec = trinov1alpha1.TrinoClusterSpec{ - Image: &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"}, - Coordinators: &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{"default": {}}, - }, - }, - Workers: &trinov1alpha1.WorkersSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{"default": {}}, - }, - }, - Catalogs: []trinov1alpha1.CatalogSpec{ - {Name: "tpch", Type: "tpch"}, - }, - } - return cr -} - -var _ = Describe("TrinoRoleGroupHandler", func() { - var handler *TrinoRoleGroupHandler - - BeforeEach(func() { - handler = NewTrinoRoleGroupHandler(scheme.Scheme) - }) - - It("implements the SDK RoleGroupHandler interface", func() { - var _ reconciler.RoleGroupHandler[*trinov1alpha1.TrinoCluster] = handler - Expect(handler).NotTo(BeNil()) - }) - - Describe("BuildResources (framework owns orchestration)", func() { - It("builds the coordinator role group from the merged config", func() { - cr := newTrinoCR() - buildCtx := buildCtxFor(cr, product.RoleCoordinators, nil) - - res, err := handler.BuildResources(ctx, k8sClient, cr, buildCtx) - Expect(err).NotTo(HaveOccurred()) - - // Framework-built resources. - Expect(res.ConfigMap).NotTo(BeNil()) - Expect(res.HeadlessService).NotTo(BeNil()) - Expect(res.Service).NotTo(BeNil()) - Expect(res.StatefulSet).NotTo(BeNil()) - - // config.properties comes from product defaults via the merge pipeline, rendered - // by the framework's properties adapter. - cp := res.ConfigMap.Data["config.properties"] - Expect(cp).To(ContainSubstring("coordinator=true")) - Expect(cp).To(ContainSubstring("discovery-server.enabled=true")) - - // Product-specific files appended by the handler. - Expect(res.ConfigMap.Data).To(HaveKey("jvm.config")) - Expect(res.ConfigMap.Data["jvm.config"]).To(ContainSubstring("-Xmx" + constants.DefaultCoordinatorMaxMemory)) - Expect(res.ConfigMap.Data).To(HaveKey("catalog/tpch.properties")) - - // Logging file rendered declaratively by the framework (LoggingContainers). - Expect(res.ConfigMap.Data).To(HaveKey("log4j2.properties")) - - // Primary container named "trino", config mounted at /etc/trino, CR-driven image. - container := res.StatefulSet.Spec.Template.Spec.Containers[0] - Expect(container.Name).To(Equal(constants.MainContainerName)) - Expect(container.Image).To(Equal("trinodb/trino:435")) - var mountPath string - for _, vm := range container.VolumeMounts { - if vm.Name == "config" { - mountPath = vm.MountPath - } - } - Expect(mountPath).To(Equal("/etc/trino")) - }) - - It("builds the worker role group without catalog files", func() { - cr := newTrinoCR() - buildCtx := buildCtxFor(cr, product.RoleWorkers, nil) - - res, err := handler.BuildResources(ctx, k8sClient, cr, buildCtx) - Expect(err).NotTo(HaveOccurred()) - - Expect(res.ConfigMap.Data["config.properties"]).To(ContainSubstring("coordinator=false")) - Expect(res.ConfigMap.Data["jvm.config"]).To(ContainSubstring("-Xmx" + constants.DefaultWorkerMaxMemory)) - Expect(res.ConfigMap.Data).NotTo(HaveKey("catalog/tpch.properties")) - }) - - It("lets a CRD configOverride win over a product default for the same key", func() { - cr := newTrinoCR() - crdOverrides := &commonsv1alpha1.OverridesSpec{ - ConfigOverrides: map[string]map[string]string{ - // Override a key the product default sets (coordinator=true) and add a new one. - "config.properties": {"coordinator": "false", "query.max-memory": "8GB"}, - }, - } - buildCtx := buildCtxFor(cr, product.RoleCoordinators, crdOverrides) - - res, err := handler.BuildResources(ctx, k8sClient, cr, buildCtx) - Expect(err).NotTo(HaveOccurred()) - - cp := res.ConfigMap.Data["config.properties"] - // CRD override wins over the product default. - Expect(cp).To(MatchRegexp(`(?m)^coordinator=false$`)) - Expect(cp).NotTo(MatchRegexp(`(?m)^coordinator=true$`)) - // New user key coexists with product defaults. - Expect(cp).To(ContainSubstring("query.max-memory=8GB")) - Expect(cp).To(ContainSubstring("discovery-server.enabled=true")) - }) - - It("does not clobber a user-provided catalog file (setIfAbsent; CRD wins)", func() { - // Catalog files are .properties, so configOverrides expresses them cleanly — a good - // test of the handler's setIfAbsent guard against overwriting pipeline-produced keys. - cr := newTrinoCR() // declares catalog "tpch" of type tpch - crdOverrides := &commonsv1alpha1.OverridesSpec{ - ConfigOverrides: map[string]map[string]string{ - "catalog/tpch.properties": {"connector.name": "blackhole"}, - }, - } - buildCtx := buildCtxFor(cr, product.RoleCoordinators, crdOverrides) - - res, err := handler.BuildResources(ctx, k8sClient, cr, buildCtx) - Expect(err).NotTo(HaveOccurred()) - - // The user-provided catalog wins; the product-generated tpch connector is not applied. - cat := res.ConfigMap.Data["catalog/tpch.properties"] - Expect(cat).To(ContainSubstring("connector.name=blackhole")) - Expect(cat).NotTo(ContainSubstring("connector.name=tpch")) - }) - }) -}) diff --git a/examples/trino-operator/internal/controller/vector_e2e_test.go b/examples/trino-operator/internal/controller/vector_e2e_test.go deleted file mode 100644 index f3bff77c..00000000 --- a/examples/trino-operator/internal/controller/vector_e2e_test.go +++ /dev/null @@ -1,127 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 controller - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/tools/record" - "k8s.io/utils/ptr" - ctrl "sigs.k8s.io/controller-runtime" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/constant" - "github.com/zncdatadev/operator-go/pkg/reconciler" - "github.com/zncdatadev/operator-go/pkg/vector" -) - -// TrinoCluster is the only type in either module that implements reconciler.VectorAggregatorProvider, -// so this is the one place the framework's OWN vector.yaml generation can be exercised end to end. -// Its absence is what let a stage-ordering regression ship: the aggregator address was resolved -// before MergedConfig existed, its gate read MergedConfig, and so the address was never resolved, -// no vector.yaml was written, and — because the sidecar was still registered — every role group -// with the agent enabled failed validation and stayed Degraded with no StatefulSet. -// -// Building a RoleGroupBuildContext by hand cannot catch that: the bug is in the order -// buildRoleGroupContext assigns its own fields. Only a real Reconcile can. -var _ = Describe("Vector agent, end to end through the reconciler", func() { - const ( - ns = "default" - aggregator = "vector-aggregator-discovery" - clusterName = "vector-e2e" - ) - - It("writes vector.yaml into the role group ConfigMap", func() { - By("publishing the aggregator discovery ConfigMap the CR points at") - Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: aggregator, Namespace: ns}, - Data: map[string]string{"ADDRESS": "vector-aggregator:6000"}, - })).To(Succeed()) - - By("creating a TrinoCluster with the vector agent enabled") - cr := &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: ns}, - Spec: trinov1alpha1.TrinoClusterSpec{ - Coordinators: &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - Config: &commonsv1alpha1.RoleGroupConfigSpec{ - Logging: &commonsv1alpha1.LoggingSpec{EnableVectorAgent: ptr.To(true)}, - }, - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {Replicas: ptr.To(int32(1))}, - }, - }, - }, - ClusterConfig: &trinov1alpha1.ClusterConfigSpec{ - VectorAggregatorConfigMapName: ptr.To(aggregator), - }, - }, - } - Expect(k8sClient.Create(ctx, cr)).To(Succeed()) - - By("reconciling") - handler := NewTrinoRoleGroupHandler(scheme.Scheme) - r, err := reconciler.NewGenericReconciler(&reconciler.GenericReconcilerConfig[*trinov1alpha1.TrinoCluster]{ - Client: k8sClient, - Scheme: scheme.Scheme, - Recorder: record.NewFakeRecorder(100), - RoleGroupHandler: handler, - RoleProvider: handler, - RoleGroupResolver: reconciler.RoleGroupResolverFunc[*trinov1alpha1.TrinoCluster](product.ComputeConfig), - ImageResolution: reconciler.ImageResolution{ - ProductName: constants.ProductName, - Defaults: constants.ImageDefaults(), - }, - Prototype: &trinov1alpha1.TrinoCluster{}, - }) - Expect(err).NotTo(HaveOccurred()) - - _, err = r.Reconcile(ctx, ctrl.Request{ - NamespacedName: types.NamespacedName{Namespace: ns, Name: clusterName}, - }) - Expect(err).NotTo(HaveOccurred(), - "a role group with the vector agent enabled must reconcile; a missing vector.yaml "+ - "fails the sidecar's Validate and aborts the role group") - - By("asserting the framework generated vector.yaml") - cm := &corev1.ConfigMap{} - Expect(k8sClient.Get(ctx, types.NamespacedName{ - Namespace: ns, - Name: reconciler.RoleGroupResourceName(clusterName, product.RoleCoordinators, "default"), - }, cm)).To(Succeed()) - Expect(cm.Data).To(HaveKey(vector.VectorConfigFileName), - "the CR implements VectorAggregatorProvider, so the framework owns vector.yaml") - Expect(cm.Data[vector.VectorConfigFileName]).To(ContainSubstring("vector-aggregator:6000"), - "the resolved aggregator address must reach the rendered pipeline") - - By("asserting the identity labels survive a real reconcile") - // ProductName reaches the label builder only through the build context, and the reconciler - // is the only thing that fills it in. Every handler-level test sets it by hand, so an - // omission there is invisible: app.kubernetes.io/name is simply absent on every resource - // the framework builds, and nothing fails. - Expect(cm.Labels).To(HaveKeyWithValue(constant.LabelKubernetesName, constants.ProductName), - "app.kubernetes.io/name comes from ImageResolution.ProductName via the build context") - Expect(cm.Labels).To(HaveKeyWithValue(constant.LabelKubernetesInstance, clusterName)) - }) -}) diff --git a/examples/trino-operator/internal/extensions/catalog_extension.go b/examples/trino-operator/internal/extensions/catalog_extension.go deleted file mode 100644 index 01efec9c..00000000 --- a/examples/trino-operator/internal/extensions/catalog_extension.go +++ /dev/null @@ -1,125 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "context" - "fmt" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/common" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// CatalogExtension is a ClusterExtension that validates and processes catalog configurations -// This demonstrates the ClusterExtension extension point of operator-go SDK -// -// The hooks take *TrinoCluster, the CR type the extension is registered for, so the Trino-only -// spec and status fields are reachable directly. -type CatalogExtension struct { - common.BaseExtension -} - -// NewCatalogExtension creates a new CatalogExtension -func NewCatalogExtension() *CatalogExtension { - return &CatalogExtension{ - BaseExtension: common.NewBaseExtension("catalog-extension"), - } -} - -// PreReconcile is called before reconciliation starts -func (e *CatalogExtension) PreReconcile( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, -) error { - logger := log.FromContext(ctx) - logger.Info("CatalogExtension PreReconcile", "cluster", cr.Name) - - // Validate catalog configurations - if err := e.validateCatalogs(cr); err != nil { - return fmt.Errorf("catalog validation failed: %w", err) - } - - return nil -} - -// PostReconcile is called after reconciliation completes -func (e *CatalogExtension) PostReconcile( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, -) error { - logger := log.FromContext(ctx) - logger.Info("CatalogExtension PostReconcile", "cluster", cr.Name) - - // Update status with ready catalogs - readyCatalogs := make([]string, 0, len(cr.Spec.Catalogs)) - for _, catalog := range cr.Spec.Catalogs { - readyCatalogs = append(readyCatalogs, catalog.Name) - } - cr.Status.CatalogsReady = readyCatalogs - - return nil -} - -// OnReconcileError is called when reconciliation encounters an error -func (e *CatalogExtension) OnReconcileError( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, - err error, -) error { - logger := log.FromContext(ctx) - logger.Error(err, "CatalogExtension OnReconcileError", "cluster", cr.Name) - return nil -} - -// validateCatalogs validates the catalog configurations -func (e *CatalogExtension) validateCatalogs(cr *trinov1alpha1.TrinoCluster) error { - seenNames := make(map[string]bool) - - for _, catalog := range cr.Spec.Catalogs { - // Check for duplicate catalog names - if seenNames[catalog.Name] { - return fmt.Errorf("duplicate catalog name: %s", catalog.Name) - } - seenNames[catalog.Name] = true - - // Validate catalog type - validTypes := map[string]bool{ - "hive": true, - "iceberg": true, - "kafka": true, - "mysql": true, - "postgresql": true, - "delta": true, - "tpch": true, - "tpcds": true, - } - - if !validTypes[catalog.Type] { - return fmt.Errorf("invalid catalog type: %s for catalog %s", catalog.Type, catalog.Name) - } - } - - return nil -} - -// Ensure interface implementation -var _ common.ClusterExtension[*trinov1alpha1.TrinoCluster] = &CatalogExtension{} diff --git a/examples/trino-operator/internal/extensions/catalog_extension_test.go b/examples/trino-operator/internal/extensions/catalog_extension_test.go deleted file mode 100644 index 7eba2376..00000000 --- a/examples/trino-operator/internal/extensions/catalog_extension_test.go +++ /dev/null @@ -1,171 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" -) - -var _ = Describe("CatalogExtension", func() { - var ( - ctx context.Context - ext *CatalogExtension - trinoCR *trinov1alpha1.TrinoCluster - ) - - BeforeEach(func() { - ctx = context.Background() - ext = NewCatalogExtension() - trinoCR = &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-trino", - Namespace: "default", - }, - Spec: trinov1alpha1.TrinoClusterSpec{ - Image: &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"}, - }, - } - }) - - Describe("Name", func() { - It("should return catalog-extension", func() { - Expect(ext.Name()).To(Equal("catalog-extension")) - }) - }) - - Describe("Validate", func() { - Context("with valid catalogs", func() { - It("should pass validation with hive catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-hive", Type: "hive"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with iceberg catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-iceberg", Type: "iceberg"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with kafka catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-kafka", Type: "kafka"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with mysql catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-mysql", Type: "mysql"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with postgresql catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-postgres", Type: "postgresql"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with delta catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-delta", Type: "delta"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with tpch catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-tpch", Type: "tpch"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with tpcds catalog", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-tpcds", Type: "tpcds"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with multiple catalogs", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-hive", Type: "hive"}, - {Name: "my-iceberg", Type: "iceberg"}, - {Name: "my-kafka", Type: "kafka"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass validation with empty catalogs", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{} - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Context("with invalid catalogs", func() { - It("should fail validation with duplicate catalog names", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-hive", Type: "hive"}, - {Name: "my-hive", Type: "iceberg"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("duplicate catalog name")) - }) - - It("should fail validation with invalid catalog type", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-invalid", Type: "invalid-type"}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid catalog type")) - }) - - It("should fail validation with empty catalog type", func() { - trinoCR.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my-empty-type", Type: ""}, - } - err := ext.PreReconcile(ctx, nil, trinoCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid catalog type")) - }) - }) - }) -}) diff --git a/examples/trino-operator/internal/extensions/discovery_extension.go b/examples/trino-operator/internal/extensions/discovery_extension.go deleted file mode 100644 index 12da692d..00000000 --- a/examples/trino-operator/internal/extensions/discovery_extension.go +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "context" - "fmt" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - "github.com/zncdatadev/operator-go/pkg/common" - "github.com/zncdatadev/operator-go/pkg/reconciler" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// TrinoDiscoveryKey is the key carrying the coordinator URI in the discovery ConfigMap. -const TrinoDiscoveryKey = "TRINO" - -// DiscoveryExtension is a ClusterExtension that publishes the cluster's client connection -// info as a "discovery ConfigMap" named after the CR — the kubedoop pattern every product -// operator follows (clients and dependent operators read the connection info from it). -// -// It demonstrates two SDK pieces working together: -// - the ClusterExtension PostReconcile hook (runs after all role groups are reconciled, -// so the coordinator Service exists), and -// - reconciler.EnsureDiscoveryConfigMap, which owns the ensure semantics (idempotent -// CreateOrUpdate, controller owner reference, canonical labels) while the product only -// computes the data map. -type DiscoveryExtension struct { - common.BaseExtension - scheme *runtime.Scheme -} - -// NewDiscoveryExtension creates a new DiscoveryExtension. -func NewDiscoveryExtension(scheme *runtime.Scheme) *DiscoveryExtension { - return &DiscoveryExtension{ - BaseExtension: common.NewBaseExtension("discovery-extension"), - scheme: scheme, - } -} - -var _ common.ClusterExtension[*trinov1alpha1.TrinoCluster] = &DiscoveryExtension{} - -// PreReconcile is a no-op: the coordinator Service the URI points at does not exist yet. -func (e *DiscoveryExtension) PreReconcile(_ context.Context, _ client.Client, _ *trinov1alpha1.TrinoCluster) error { - return nil -} - -// PostReconcile publishes the discovery ConfigMap once the role groups (and therefore the -// coordinator Service) have been reconciled. -func (e *DiscoveryExtension) PostReconcile(ctx context.Context, c client.Client, cr *trinov1alpha1.TrinoCluster) error { - if err := reconciler.EnsureDiscoveryConfigMap(ctx, c, e.scheme, cr, cr.Name, - map[string]string{TrinoDiscoveryKey: product.DiscoveryURI(cr)}, - reconciler.WithDiscoveryProductName("trino"), - ); err != nil { - return fmt.Errorf("failed to ensure discovery configmap %s/%s: %w", cr.Namespace, cr.Name, err) - } - - log.FromContext(ctx).V(1).Info("ensured discovery configmap", - "cluster", cr.Name, "uri", product.DiscoveryURI(cr)) - return nil -} - -// OnReconcileError is a no-op. -func (e *DiscoveryExtension) OnReconcileError(_ context.Context, _ client.Client, _ *trinov1alpha1.TrinoCluster, _ error) error { - return nil -} diff --git a/examples/trino-operator/internal/extensions/discovery_extension_test.go b/examples/trino-operator/internal/extensions/discovery_extension_test.go deleted file mode 100644 index f343ac43..00000000 --- a/examples/trino-operator/internal/extensions/discovery_extension_test.go +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions_test - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/extensions" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -var _ = Describe("DiscoveryExtension", func() { - var scheme *runtime.Scheme - var cr *trinov1alpha1.TrinoCluster - - BeforeEach(func() { - scheme = runtime.NewScheme() - Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed()) - Expect(trinov1alpha1.AddToScheme(scheme)).To(Succeed()) - - cr = &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{Name: "trino-sample", Namespace: "default"}, - Spec: trinov1alpha1.TrinoClusterSpec{ - Coordinators: &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{ - "default": {}, - }, - }, - }, - }, - } - }) - - It("publishes the discovery ConfigMap with the coordinator URI and owner reference", func() { - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr).Build() - ext := extensions.NewDiscoveryExtension(scheme) - - Expect(ext.PostReconcile(context.Background(), c, cr)).To(Succeed()) - - cm := &corev1.ConfigMap{} - Expect(c.Get(context.Background(), - types.NamespacedName{Namespace: "default", Name: "trino-sample"}, cm)).To(Succeed()) - Expect(cm.Data).To(HaveKeyWithValue(extensions.TrinoDiscoveryKey, - "http://trino-sample-coordinators-default:8080")) - Expect(cm.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "trino")) - Expect(cm.Labels).To(HaveKeyWithValue("app.kubernetes.io/instance", "trino-sample")) - Expect(cm.OwnerReferences).To(HaveLen(1)) - Expect(cm.OwnerReferences[0].Kind).To(Equal("TrinoCluster")) - Expect(*cm.OwnerReferences[0].Controller).To(BeTrue()) - }) - - It("is idempotent and refreshes the data on repeated reconciles", func() { - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr).Build() - ext := extensions.NewDiscoveryExtension(scheme) - - Expect(ext.PostReconcile(context.Background(), c, cr)).To(Succeed()) - Expect(ext.PostReconcile(context.Background(), c, cr)).To(Succeed()) - - cm := &corev1.ConfigMap{} - Expect(c.Get(context.Background(), - types.NamespacedName{Namespace: "default", Name: "trino-sample"}, cm)).To(Succeed()) - Expect(cm.Data).To(HaveKey(extensions.TrinoDiscoveryKey)) - }) - -}) diff --git a/examples/trino-operator/internal/extensions/extensions_suite_test.go b/examples/trino-operator/internal/extensions/extensions_suite_test.go deleted file mode 100644 index 4fda4023..00000000 --- a/examples/trino-operator/internal/extensions/extensions_suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestExtensions(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Extensions Suite") -} diff --git a/examples/trino-operator/internal/extensions/health_extension.go b/examples/trino-operator/internal/extensions/health_extension.go deleted file mode 100644 index 55c9afbc..00000000 --- a/examples/trino-operator/internal/extensions/health_extension.go +++ /dev/null @@ -1,97 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "context" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/common" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// HealthExtension is a RoleExtension that performs health checks for Trino roles -// This demonstrates the RoleExtension extension point of operator-go SDK -// -// Like the cluster-level extensions, it is declared for *TrinoCluster and receives that type. -type HealthExtension struct { - common.BaseExtension -} - -// NewHealthExtension creates a new HealthExtension -func NewHealthExtension() *HealthExtension { - return &HealthExtension{ - BaseExtension: common.NewBaseExtension("health-extension"), - } -} - -// PreReconcile is called before role reconciliation starts -func (e *HealthExtension) PreReconcile( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, - roleName string, -) error { - logger := log.FromContext(ctx) - logger.Info("HealthExtension PreReconcile", "cluster", cr.Name, "role", roleName) - return nil -} - -// PostReconcile is called after role reconciliation completes -func (e *HealthExtension) PostReconcile( - ctx context.Context, - k8sClient client.Client, - cr *trinov1alpha1.TrinoCluster, - roleName string, -) error { - logger := log.FromContext(ctx) - logger.Info("HealthExtension PostReconcile", "cluster", cr.Name, "role", roleName) - - // Perform role-specific health checks - switch roleName { - case "coordinators": - e.checkCoordinatorHealth(ctx, cr) - case "workers": - e.checkWorkerHealth(ctx, cr) - } - - return nil -} - -// checkCoordinatorHealth checks the health of the coordinator role -// TODO: Implement actual health checks: -// - HTTP endpoint availability -// - Query processing capability -// - Worker registration status -func (e *HealthExtension) checkCoordinatorHealth(ctx context.Context, cr *trinov1alpha1.TrinoCluster) { - logger := log.FromContext(ctx) - logger.Info("Health check not yet implemented - checking coordinator health", "cluster", cr.Name) -} - -// checkWorkerHealth checks the health of the worker role -// TODO: Implement actual health checks: -// - Worker registration with coordinator -// - Task execution capability -// - Memory/CPU usage -func (e *HealthExtension) checkWorkerHealth(ctx context.Context, cr *trinov1alpha1.TrinoCluster) { - logger := log.FromContext(ctx) - logger.Info("Health check not yet implemented - checking worker health", "cluster", cr.Name) -} - -// Ensure interface implementation -var _ common.RoleExtension[*trinov1alpha1.TrinoCluster] = &HealthExtension{} diff --git a/examples/trino-operator/internal/extensions/health_extension_test.go b/examples/trino-operator/internal/extensions/health_extension_test.go deleted file mode 100644 index 44c22051..00000000 --- a/examples/trino-operator/internal/extensions/health_extension_test.go +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" -) - -var _ = Describe("HealthExtension", func() { - var ( - ctx context.Context - ext *HealthExtension - trinoCR *trinov1alpha1.TrinoCluster - ) - - BeforeEach(func() { - ctx = context.Background() - ext = NewHealthExtension() - trinoCR = &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-trino", - Namespace: "default", - }, - Spec: trinov1alpha1.TrinoClusterSpec{ - Image: &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"}, - }, - } - }) - - Describe("Name", func() { - It("should return health-extension", func() { - Expect(ext.Name()).To(Equal("health-extension")) - }) - }) - - Describe("Validate", func() { - Context("with valid cluster", func() { - It("should pass PreReconcile for coordinators role", func() { - err := ext.PreReconcile(ctx, nil, trinoCR, "coordinators") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass PreReconcile for workers role", func() { - err := ext.PreReconcile(ctx, nil, trinoCR, "workers") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass PostReconcile for coordinators role", func() { - err := ext.PostReconcile(ctx, nil, trinoCR, "coordinators") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass PostReconcile for workers role", func() { - err := ext.PostReconcile(ctx, nil, trinoCR, "workers") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should pass PostReconcile for unknown role", func() { - err := ext.PostReconcile(ctx, nil, trinoCR, "unknown") - Expect(err).ToNot(HaveOccurred()) - }) - }) - }) -}) diff --git a/examples/trino-operator/internal/extensions/registration_test.go b/examples/trino-operator/internal/extensions/registration_test.go deleted file mode 100644 index c1279329..00000000 --- a/examples/trino-operator/internal/extensions/registration_test.go +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 extensions_test - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/extensions" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/common" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -// This mirrors what cmd/main.go wires up: the extensions this operator ships are written for -// *TrinoCluster and go into a registry instantiated for that same type, which is then handed to -// the GenericReconciler. An extension of another product's CR type would not compile here. -var _ = Describe("Extension registration", func() { - var scheme *runtime.Scheme - var cr *trinov1alpha1.TrinoCluster - - BeforeEach(func() { - scheme = runtime.NewScheme() - Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed()) - Expect(trinov1alpha1.AddToScheme(scheme)).To(Succeed()) - - cr = &trinov1alpha1.TrinoCluster{ - ObjectMeta: metav1.ObjectMeta{Name: "trino-registered", Namespace: "default"}, - Spec: trinov1alpha1.TrinoClusterSpec{ - Catalogs: []trinov1alpha1.CatalogSpec{{Name: "my-hive", Type: "hive"}}, - Coordinators: &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{"default": {}}, - }, - }, - }, - } - }) - - It("registers this operator's extensions in a TrinoCluster registry", func() { - registry := common.NewExtensionRegistry[*trinov1alpha1.TrinoCluster]() - registry.RegisterClusterExtension(extensions.NewCatalogExtension()) - registry.RegisterRoleExtension(extensions.NewHealthExtension()) - registry.RegisterClusterExtension(extensions.NewDiscoveryExtension(scheme), common.WithPriority(common.PriorityLow)) - - Expect(registry.Count()).To(Equal(3)) - // The discovery extension registers at a lower priority, so it runs after the catalog - // extension has refreshed the status. - Expect(registry.GetClusterExtensions()[0].Name()).To(Equal("catalog-extension")) - Expect(registry.GetClusterExtensions()[1].Name()).To(Equal("discovery-extension")) - }) - - It("hands the concrete TrinoCluster to every hook the registry executes", func() { - ctx := context.Background() - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr).Build() - - registry := common.NewExtensionRegistry[*trinov1alpha1.TrinoCluster]() - registry.RegisterClusterExtension(extensions.NewCatalogExtension()) - registry.RegisterRoleExtension(extensions.NewHealthExtension()) - registry.RegisterClusterExtension(extensions.NewDiscoveryExtension(scheme), common.WithPriority(common.PriorityLow)) - - Expect(registry.ExecuteClusterPreReconcile(ctx, c, cr)).To(Succeed()) - Expect(registry.ExecuteRolePostReconcile(ctx, c, cr, "coordinators")).To(Succeed()) - Expect(registry.ExecuteClusterPostReconcile(ctx, c, cr)).To(Succeed()) - - // The catalog extension wrote a Trino-only status field, so the hooks received the - // product CR rather than the SDK's ClusterInterface view of it. - Expect(cr.Status.CatalogsReady).To(Equal([]string{"my-hive"})) - }) -}) diff --git a/examples/trino-operator/internal/product/authentication.go b/examples/trino-operator/internal/product/authentication.go new file mode 100644 index 00000000..12bad405 --- /dev/null +++ b/examples/trino-operator/internal/product/authentication.go @@ -0,0 +1,159 @@ +package product + +import ( + "context" + "fmt" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" +) + +const ( + trinoInternalSecretKey = "shared-secret" + trinoTLSRuntimeDirectory = "tls-runtime" + trinoTLSSourceDirectory = "tls-source" + trinoHTTPSEndpoint = "https" +) + +// AuthenticationFacts contains references only. Secret data stays in Kubernetes +// Secret volumes/env selectors, never in generated config, shared facts or status. +type AuthenticationFacts struct { + PasswordSecret string `json:"passwordSecret"` +} + +func resolveTrinoAuthentication(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], facts *TrinoFacts, +) (framework.FactDiagnostic, error) { + facts.Authentication = AuthenticationFacts{} + references := in.Platform.Authentication + if in.ClusterConfig.TLSSecret != "" && in.ClusterConfig.TLSSecretClass != "" { + return authDiagnostic(framework.FactsInvalid, "MultipleTLSCertificateSources"), nil + } + for _, source := range []struct { + name string + keys []string + }{{in.ClusterConfig.InternalSecret, []string{trinoInternalSecretKey}}, {in.ClusterConfig.TLSSecret, []string{"tls.crt", "tls.key"}}} { + if source.name == "" { + continue + } + if _, diagnostic, err := authenticationSecret(ctx, reader, in.Group.Namespace, source.name, source.keys); err != nil || diagnostic.State != framework.FactsResolved { + return diagnostic, err + } + } + if len(references) == 0 { + return framework.FactDiagnostic{State: framework.FactsResolved}, nil + } + if len(references) != 1 { + return authDiagnostic(framework.FactsInvalid, "UnsupportedAuthenticationCombination"), nil + } + if references[0].OIDC.ClientCredentialsSecret != "" || len(references[0].OIDC.ExtraScopes) != 0 { + return authDiagnostic(framework.FactsInvalid, "UnsupportedTrinoOIDCSettings"), nil + } + result, err := framework.ResolveAuthenticationClass(ctx, reader, references[0].AuthenticationClass) + if err != nil || result.Diagnostic.State != framework.FactsResolved { + return result.Diagnostic, err + } + if result.Value.Static == nil { + return authDiagnostic(framework.FactsInvalid, "UnsupportedTrinoAuthenticationProvider"), nil + } + if in.ClusterConfig.InternalSecret == "" || (in.ClusterConfig.TLSSecret == "") == (in.ClusterConfig.TLSSecretClass == "") { + return authDiagnostic(framework.FactsInvalid, "AuthenticationRequiresTLSAndInternalSecret"), nil + } + name := result.Value.Static.UserCredentialsSecret.Name + password, diagnostic, err := authenticationSecret(ctx, reader, in.Group.Namespace, name, []string{"password.db"}) + if err != nil || diagnostic.State != framework.FactsResolved { + return diagnostic, err + } + // Read only the key's shape; never retain or echo password hashes in facts. + if len(password.Data["password.db"]) == 0 { + return authDiagnostic(framework.FactsInvalid, "EmptyPasswordDatabase"), nil + } + facts.Authentication.PasswordSecret = name + return framework.FactDiagnostic{State: framework.FactsResolved, Reason: "TrinoPasswordAuthenticationResolved"}, nil +} + +func authDiagnostic(state framework.FactState, reason string) framework.FactDiagnostic { + return framework.FactDiagnostic{State: state, Reason: reason, Message: "Trino authentication configuration is unavailable or invalid; secret values are not reported"} +} + +func authenticationSecret(ctx context.Context, reader framework.FactsReader, namespace, name string, keys []string) ( + *corev1.Secret, framework.FactDiagnostic, error, +) { + object := &corev1.Secret{} + if err := reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, object); err != nil { + if apierrors.IsNotFound(err) { + return nil, authDiagnostic(framework.FactsPending, "AuthenticationSecretMissing"), nil + } + return nil, framework.FactDiagnostic{}, err + } + if !object.DeletionTimestamp.IsZero() { + return nil, authDiagnostic(framework.FactsPending, "AuthenticationSecretDeleting"), nil + } + for _, key := range keys { + if len(object.Data[key]) == 0 { + return nil, authDiagnostic(framework.FactsInvalid, "AuthenticationSecretKeyMissing"), nil + } + } + return object, framework.FactDiagnostic{State: framework.FactsResolved}, nil +} + +const prepareTrinoTLS = `import os,pathlib,tempfile +source=pathlib.Path('/kubedoop/tls-source'); destination=pathlib.Path('/kubedoop/tls-runtime') +content=(source/'tls.key').read_bytes()+b'\n'+(source/'tls.crt').read_bytes() +fd,name=tempfile.mkstemp(dir=destination) +try: + with os.fdopen(fd,'wb') as output: + output.write(content);output.flush();os.fsync(output.fileno()) + os.chmod(name,0o600);os.replace(name,destination/'server.pem') +finally: + if os.path.exists(name):os.unlink(name) +` + +func configureTrinoAuthentication(r *framework.RuntimeDescription, + in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], +) error { + authenticated := in.Facts.Authentication.PasswordSecret != "" + if !authenticated && len(in.Platform.Authentication) > 0 { + return fmt.Errorf("authentication input has no resolved Trino consumer") + } + configuration := findFile(r.Files, trinoConfigDirectory, "config.properties").Content.(framework.KeyValues) + if in.ClusterConfig.InternalSecret != "" { + r.Main.Env = append(r.Main.Env, corev1.EnvVar{Name: "TRINO_INTERNAL_SHARED_SECRET", ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: in.ClusterConfig.InternalSecret}, Key: trinoInternalSecretKey}}}) + configuration.Values["internal-communication.shared-secret"] = framework.Literal("${ENV:TRINO_INTERNAL_SHARED_SECRET}") + } + if in.Group.Role != trinoCoordinatorRole || (in.ClusterConfig.TLSSecret == "" && in.ClusterConfig.TLSSecretClass == "") { + return nil + } + if authenticated { + configuration.Values["http-server.authentication.type"] = framework.Literal("PASSWORD") + } + configuration.Values["http-server.https.enabled"] = framework.Literal("true") + configuration.Values["http-server.https.port"] = framework.Literal("8443") + configuration.Values["http-server.https.keystore.path"] = framework.Literal("/kubedoop/tls-runtime/server.pem") + secret := &framework.SecretVolume{SecretName: in.ClusterConfig.TLSSecret} + if in.ClusterConfig.TLSSecretClass != "" { + scope := []string{"pod", "service=" + in.Group.ServiceName()} + if in.ClusterConfig.ListenerClass != "" { + // The published Listener address may differ from the group Service DNS. + scope = append(scope, "listener-volume="+trinoListenerDirectory) + } + secret = &framework.SecretVolume{SecretClass: in.ClusterConfig.TLSSecretClass, Format: "tls-pem", Scope: scope} + } + r.Directories = append(r.Directories, framework.Directory{Name: trinoTLSSourceDirectory, Secret: secret}, framework.Directory{Name: trinoTLSRuntimeDirectory}) + r.Main.Access = append(r.Main.Access, framework.DirectoryAccess{Directory: trinoTLSRuntimeDirectory, MountPath: "/kubedoop/tls-runtime", ReadOnly: true}) + r.Initializers = append(r.Initializers, framework.Process{Name: "initialize-tls", Command: []string{trinoPython, "-c", prepareTrinoTLS}, Identity: r.Main.Identity.DeepCopy(), + Access: []framework.DirectoryAccess{{Directory: trinoTLSSourceDirectory, MountPath: "/kubedoop/tls-source", ReadOnly: true}, {Directory: trinoTLSRuntimeDirectory, MountPath: "/kubedoop/tls-runtime"}}}) + if authenticated { + r.Directories = append(r.Directories, framework.Directory{Name: "authentication", Secret: &framework.SecretVolume{SecretName: in.Facts.Authentication.PasswordSecret}}) + r.Main.Access = append(r.Main.Access, framework.DirectoryAccess{Directory: "authentication", MountPath: "/kubedoop/authentication", ReadOnly: true}) + r.Files = append(r.Files, trinoFile("password-authenticator.properties", map[string]framework.PropertyValue{ + "password-authenticator.name": framework.Literal("file"), "file.password-file": framework.Literal("/kubedoop/authentication/password.db"), + "file.refresh-period": framework.Literal("5s"), + })) + } + r.Endpoints = append(r.Endpoints, framework.Endpoint{Name: trinoHTTPSEndpoint, Port: 8443}) + return nil +} diff --git a/examples/trino-operator/internal/product/authentication_test.go b/examples/trino-operator/internal/product/authentication_test.go new file mode 100644 index 00000000..bcfde118 --- /dev/null +++ b/examples/trino-operator/internal/product/authentication_test.go @@ -0,0 +1,126 @@ +package product + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +const ( + authUsersFixture = "users" + authInternalFixture = "internal" +) + +type authenticationReader struct{ secrets map[string]*corev1.Secret } + +func (r *authenticationReader) Get(_ context.Context, key types.NamespacedName, into framework.FactResource) error { + if class, ok := into.(*unstructured.Unstructured); ok { + class.Object = map[string]any{"spec": map[string]any{"provider": map[string]any{"static": map[string]any{ + "userCredentialsSecret": map[string]any{"name": authUsersFixture}}}}} + return nil + } + secret, ok := r.secrets[key.Name] + if !ok { + return apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, key.Name) + } + *into.(*corev1.Secret) = *secret.DeepCopy() + return nil +} + +func TestPasswordAuthenticationRequiresTLSAndConsumesOnlySecretReferences(t *testing.T) { + current := effectiveInput() + current.Platform.Authentication = []framework.Authentication{{AuthenticationClass: authUsersFixture}} + current.ClusterConfig.TLSSecret = "server-tls" + current.ClusterConfig.InternalSecret = authInternalFixture + in := framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]{Platform: current.Platform, + ClusterConfig: current.ClusterConfig, Group: current.Group, Config: current.Config, Shared: BaseFacts()} + reader := &authenticationReader{secrets: map[string]*corev1.Secret{ + authUsersFixture: {Data: map[string][]byte{"password.db": []byte("private-hash")}}, + "server-tls": {Data: map[string][]byte{"tls.key": []byte("private-key"), "tls.crt": []byte("public-cert")}}, + authInternalFixture: {Data: map[string][]byte{trinoInternalSecretKey: []byte("private-internal-secret")}}, + }} + result, err := ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsResolved { + t.Fatalf("resolution: %+v %v", result, err) + } + encoded, _ := json.Marshal(result) + if strings.Contains(string(encoded), "private-") { + t.Fatal("credential bytes escaped into facts") + } + current.Facts = *result.Value + current.Group.Role = trinoCoordinatorRole + runtime, err := generateTrino(current) + if err != nil { + t.Fatal(err) + } + config := findFile(runtime.Files, trinoConfigDirectory, "config.properties") + value, known := literalProperty(config, "http-server.authentication.type") + if !known || value != "PASSWORD" { + t.Fatal("PASSWORD not configured") + } + if _, known := literalProperty(config, "http-server.authentication.allow-insecure-over-http"); known { + t.Fatal("insecure HTTP auth enabled") + } + found := false + for _, env := range runtime.Main.Env { + if env.Name == "TRINO_INTERNAL_SHARED_SECRET" && env.ValueFrom != nil && env.ValueFrom.SecretKeyRef.Name == authInternalFixture { + found = true + } + } + if !found || findFile(runtime.Files, trinoConfigDirectory, "password-authenticator.properties") == nil || + len(runtime.Initializers) != 2 || runtime.Initializers[1].Name != "initialize-tls" { + t.Fatal("runtime consumers missing") + } + in.ClusterConfig.TLSSecret = "" + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsInvalid || result.Value != nil { + t.Fatal("plaintext authentication accepted") + } + in.ClusterConfig = current.ClusterConfig + delete(reader.secrets, authUsersFixture) + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsPending || result.Value != nil { + t.Fatal("missing password Secret did not wait") + } +} + +func TestAutoTLSIncludesPublishedListenerIdentity(t *testing.T) { + for _, listenerClass := range []string{"", "external"} { + t.Run("listener="+listenerClass, func(t *testing.T) { + in := effectiveInput() + in.Group.Role = trinoCoordinatorRole + in.ClusterConfig.TLSSecretClass = "tls" + in.ClusterConfig.ListenerClass = listenerClass + runtime, err := generateTrino(in) + if err != nil { + t.Fatal(err) + } + for _, directory := range runtime.Directories { + if directory.Name != trinoTLSSourceDirectory { + continue + } + if directory.Secret == nil || directory.Secret.SecretClass != "tls" { + t.Fatal("AutoTLS source missing") + } + scopes := directory.Secret.Scope + if !slices.Contains(scopes, "pod") || !slices.Contains(scopes, "service="+in.Group.ServiceName()) { + t.Fatal("internal TLS identities missing") + } + if slices.Contains(scopes, "listener-volume="+trinoListenerDirectory) != (listenerClass != "") { + t.Fatalf("published Listener identity not reflected in TLS scope: %v", scopes) + } + return + } + t.Fatal("TLS source directory missing") + }) + } +} diff --git a/examples/trino-operator/internal/product/config.go b/examples/trino-operator/internal/product/config.go deleted file mode 100644 index dcf8b0df..00000000 --- a/examples/trino-operator/internal/product/config.go +++ /dev/null @@ -1,122 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 product holds Trino's product-intrinsic logic — the knowledge that is neither the -// SDK framework's nor the user's, expressed as data that flows through the SDK merge pipeline. -package product - -import ( - "context" - "fmt" - "slices" - - "sigs.k8s.io/controller-runtime/pkg/client" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - "github.com/zncdatadev/operator-go/pkg/reconciler" -) - -// Role names. These must match the keys returned by TrinoCluster.GetSpec().Roles. -const ( - RoleCoordinators = "coordinators" - RoleWorkers = "workers" -) - -// ComputeConfig is Trino's RoleGroupResolver. It computes the product's config.properties for a -// given role group and returns it as a *reconciler.Contribution, which the SDK renders into the -// same override shape users write in the CRD. The SDK merges it as the LOWEST layer -// (product < role < role group), so any value a user sets via configOverrides always wins. -// -// It runs AFTER the typed config block is folded (product defaults < role < role group), so a -// value derived from the effective config — a JVM heap sized from the memory limit the user raised -// — is reachable here and reaches the ConfigMap. That was impossible before: the effective config -// was not computed until after the role group's ConfigMap had already been written. -// -// This is config generation, not defaulting: it runs every reconcile and is where -// role-specific product knowledge lives (coordinator vs worker) and where values are derived -// from live cluster state (the discovery URI is built from the coordinator Service the -// framework will create). It contains no imperative resource construction — purely data. -// The ctx and client are unused here — trino's configuration is a pure function of the CR — but -// they are what the seam exists for: a product resolving an S3Connection reference or a ZooKeeper -// address does that lookup here and reports a failure through the error return, rather than -// swallowing it and rendering a silently wrong config. -func ComputeConfig( - _ context.Context, _ client.Client, cr *trinov1alpha1.TrinoCluster, - rg *reconciler.RoleGroupBuildContext, -) (*reconciler.Contribution, error) { - roleName := rg.RoleName - port := CoordinatorPort(cr) - - props := map[string]string{ - "http-server.http.port": fmt.Sprintf("%d", port), - "discovery.uri": discoveryURI(cr, port), - } - - switch roleName { - case RoleCoordinators: - props["coordinator"] = "true" - props["node-scheduler.include-coordinator"] = "false" - props["discovery-server.enabled"] = "true" - case RoleWorkers: - props["coordinator"] = "false" - } - - return &reconciler.Contribution{ - ConfigOverrides: map[string]map[string]string{ - "config.properties": props, - }, - }, nil -} - -// CoordinatorPort returns the coordinator HTTP port from the CR or the product default. -func CoordinatorPort(cr *trinov1alpha1.TrinoCluster) int32 { - if cr.Spec.Coordinators != nil && cr.Spec.Coordinators.HTTPPort != 0 { - return cr.Spec.Coordinators.HTTPPort - } - return constants.DefaultHTTPPort -} - -// coordinatorServiceName returns the client-facing coordinator Service name. The SDK names -// role group resources as {cluster}-{role}-{group}, so we derive the name from a coordinator -// role group, matching the Service the framework actually creates. Group names are sorted so -// the choice is deterministic across reconciles (map iteration order is randomized) — without -// this, the discovery URI could change between reconciles and churn the config in a deployment -// with multiple coordinator role groups. -func coordinatorServiceName(cr *trinov1alpha1.TrinoCluster) string { - groupName := constants.DefaultRoleGroupName - if cr.Spec.Coordinators != nil && len(cr.Spec.Coordinators.RoleGroups) > 0 { - names := make([]string, 0, len(cr.Spec.Coordinators.RoleGroups)) - for g := range cr.Spec.Coordinators.RoleGroups { - names = append(names, g) - } - slices.Sort(names) - groupName = names[0] - } - return reconciler.RoleGroupResourceName(cr.Name, RoleCoordinators, groupName) -} - -// discoveryURI builds the Trino discovery URI from the coordinator Service name and port. -func discoveryURI(cr *trinov1alpha1.TrinoCluster, port int32) string { - return fmt.Sprintf("http://%s:%d", coordinatorServiceName(cr), port) -} - -// DiscoveryURI returns the client-facing coordinator URI. It backs both the workers' -// discovery.uri in config.properties and the cluster discovery ConfigMap published by -// extensions.DiscoveryExtension. -func DiscoveryURI(cr *trinov1alpha1.TrinoCluster) string { - return discoveryURI(cr, CoordinatorPort(cr)) -} diff --git a/examples/trino-operator/internal/product/config_test.go b/examples/trino-operator/internal/product/config_test.go deleted file mode 100644 index d8e11069..00000000 --- a/examples/trino-operator/internal/product/config_test.go +++ /dev/null @@ -1,100 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 product_test - -import ( - "context" - - "strings" - "testing" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" - "github.com/zncdatadev/operator-go/pkg/reconciler" -) - -func testCR() *trinov1alpha1.TrinoCluster { - cr := &trinov1alpha1.TrinoCluster{} - cr.Name = "test-trino" - cr.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{ - RoleSpec: commonsv1alpha1.RoleSpec{ - RoleGroups: map[string]commonsv1alpha1.RoleGroupSpec{"default": {}}, - }, - } - return cr -} - -func configProps(t *testing.T, cr *trinov1alpha1.TrinoCluster, role string) map[string]string { - t.Helper() - ov, err := product.ComputeConfig(context.Background(), nil, cr, - &reconciler.RoleGroupBuildContext{RoleName: role, RoleGroupName: "default"}) - if err != nil { - t.Fatalf("ComputeConfig failed: %v", err) - } - if ov == nil || ov.ConfigOverrides["config.properties"] == nil { - t.Fatalf("ComputeConfig returned no config.properties for role %q", role) - } - return ov.ConfigOverrides["config.properties"] -} - -func TestComputeConfigCoordinator(t *testing.T) { - props := configProps(t, testCR(), product.RoleCoordinators) - - if got := props["coordinator"]; got != "true" { - t.Errorf("coordinator = %q, want true", got) - } - if got := props["discovery-server.enabled"]; got != "true" { - t.Errorf("discovery-server.enabled = %q, want true", got) - } - // The discovery URI must point at the Service the SDK actually creates - // ({cluster}-{role}-{group}). - if uri := props["discovery.uri"]; !strings.Contains(uri, "test-trino-coordinators-default") { - t.Errorf("discovery.uri = %q, want it to reference test-trino-coordinators-default", uri) - } -} - -func TestComputeConfigWorker(t *testing.T) { - props := configProps(t, testCR(), product.RoleWorkers) - - if got := props["coordinator"]; got != "false" { - t.Errorf("coordinator = %q, want false", got) - } - if _, ok := props["discovery-server.enabled"]; ok { - t.Errorf("workers must not set discovery-server.enabled") - } - // Workers still discover the coordinator. - if uri := props["discovery.uri"]; !strings.Contains(uri, "test-trino-coordinators-default") { - t.Errorf("discovery.uri = %q, want it to reference the coordinator service", uri) - } -} - -func TestCoordinatorPortDefaultAndOverride(t *testing.T) { - cr := testCR() - if got := product.CoordinatorPort(cr); got != 8080 { - t.Errorf("default CoordinatorPort = %d, want 8080", got) - } - - cr.Spec.Coordinators.HTTPPort = 9090 - if got := product.CoordinatorPort(cr); got != 9090 { - t.Errorf("overridden CoordinatorPort = %d, want 9090", got) - } - props := configProps(t, cr, product.RoleCoordinators) - if got := props["http-server.http.port"]; got != "9090" { - t.Errorf("http-server.http.port = %q, want 9090", got) - } -} diff --git a/examples/trino-operator/internal/product/data.go b/examples/trino-operator/internal/product/data.go new file mode 100644 index 00000000..4ce85544 --- /dev/null +++ b/examples/trino-operator/internal/product/data.go @@ -0,0 +1,32 @@ +package product + +import ( + "maps" + "path" + "slices" + "strings" +) + +const tpchCatalog = "tpch" + +// BaseFacts supplies the bundled TPCH catalog when no external catalog is referenced. +func BaseFacts() TrinoFacts { + return TrinoFacts{Catalogs: map[string]map[string]string{tpchCatalog: {"connector.name": tpchCatalog}}} +} + +func cloneFacts(in TrinoFacts) TrinoFacts { + out := in + out.Catalogs = make(map[string]map[string]string, len(in.Catalogs)) + for name, properties := range in.Catalogs { + out.Catalogs[name] = maps.Clone(properties) + } + out.S3.Credentials.Scope = slices.Clone(in.S3.Credentials.Scope) + return out +} + +func sortedKeys[V any](values map[string]V) []string { return slices.Sorted(maps.Keys(values)) } + +func relativeFile(value string) bool { + return value != "" && value != "." && value != ".." && !path.IsAbs(value) && + path.Clean(value) == value && !strings.HasPrefix(value, "../") && !strings.ContainsRune(value, '\x00') +} diff --git a/examples/trino-operator/internal/product/definition.go b/examples/trino-operator/internal/product/definition.go new file mode 100644 index 00000000..e5ba4433 --- /dev/null +++ b/examples/trino-operator/internal/product/definition.go @@ -0,0 +1,266 @@ +package product + +import ( + "fmt" + "path" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + trinoName = "trino" + trinoLauncher = "/kubedoop/trino-server/bin/launcher" + trinoCoordinatorRole = "coordinators" + trinoWorkerRole = "workers" + trinoConfigDirectory = "config" + trinoLogDirectory = "log" + trinoServerLog = "server.json" + trinoRunArgument = "run" + trinoHTTPEndpoint = "http" + trinoInfoLevel = "INFO" + trinoDataDirectory = "data" + trinoJVMFile = "jvm.config" + trinoEnvironmentKey = "node.environment" + trinoHTTPCheck = "trino.http" + trinoExecutionCheck = "trino.execution" + trinoNodeFile = "node.properties" + trinoNodeIDKey = "node.id" +) + +var trinoEnvironmentPattern = regexp.MustCompile(`^[a-z0-9][_a-z0-9]*$`) + +// TrinoClusterConfig is cluster-wide product intent, outside role inheritance and facts. +type TrinoClusterConfig struct { + ListenerClass string `json:"listenerClass"` + NodeEnvironment string `json:"nodeEnvironment"` + TLSSecret string `json:"tlsSecret"` + TLSSecretClass string `json:"tlsSecretClass"` + InternalSecret string `json:"internalSecret"` +} + +type TrinoConfig struct { + Hive HiveCatalog `json:"hive"` + HTTPPort int32 `json:"httpPort"` + // Explicit local shutdown identity; its management authorization must already exist. + ShutdownUser string `json:"shutdownUser"` + ShutdownCredentialsSecret string `json:"shutdownCredentialsSecret"` + // Empty means no external reference; nonempty resolves catalogs.json in the CR namespace. + CatalogConfigMapName string `json:"catalogConfigMapName"` +} + +// TrinoFacts is already-resolved group data. The generator performs no API reads. +type TrinoFacts struct { + S3 framework.ResolvedS3Connection `json:"s3"` + Authentication AuthenticationFacts `json:"authentication"` + Catalogs map[string]map[string]string `json:"catalogs"` +} + +// Definition declares Trino 476 input, native files and runtime intent. +// The framework owns configuration folding, helpers, resources and reconciliation. +func Definition() framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts] { + defaults := framework.Config[TrinoConfig]{ + Common: framework.CommonConfig{ + GracefulShutdownTimeout: metav1.Duration{Duration: 30 * time.Second}, + Resources: framework.Resources{ + CPU: framework.CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("2")}, + Memory: framework.Memory{Limit: resource.MustParse("1536Mi")}, + }, + Logging: framework.Logging{EnableVectorAgent: true, Containers: map[string]framework.ContainerLogging{ + trinoName: {Console: framework.Logger{Level: trinoOffLevel}, File: framework.Logger{Level: trinoTraceLevel}, + Loggers: map[string]framework.Logger{trinoRootLogger: {Level: trinoInfoLevel}, trinoLoggerName: {Level: trinoInfoLevel}}}, + }}, + }, + Product: TrinoConfig{HTTPPort: 8080}, + } + return framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + ClusterConfigDefaults: TrinoClusterConfig{NodeEnvironment: "kubedoop"}, + ImageDefaults: framework.ImageConfig{Repo: "quay.io/zncdatadev", ProductVersion: "476", + KubedoopVersion: "0.0.0-dev", PullPolicy: corev1.PullIfNotPresent}, + Name: trinoName, Roles: map[string]framework.RoleDefinition[TrinoConfig]{ + trinoCoordinatorRole: {Config: defaults, + RoleConfig: framework.RoleConfig{PodDisruptionBudget: framework.PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: 0}}}, + trinoWorkerRole: {Config: defaults, + RoleConfig: framework.RoleConfig{PodDisruptionBudget: framework.PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: 1}}}, + }, + ValidateInput: validateTrinoInput, GenerateGroup: generateTrino, GenerateCluster: trinoDiscovery, + ValidateFinal: validateTrinoFinal, + } +} + +func validateTrinoInput(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) error { + if err := validateTrinoLifecycle(in); err != nil { + return err + } + if !trinoEnvironmentPattern.MatchString(in.ClusterConfig.NodeEnvironment) { + return fmt.Errorf("clusterConfig.nodeEnvironment must match [a-z0-9][_a-z0-9]*") + } + if in.Config.Product.HTTPPort < 1 || in.Config.Product.HTTPPort > 65535 { + return fmt.Errorf("httpPort must be between 1 and 65535") + } + coordinators := 0 + for _, group := range in.Topology { + if group.Group.Role == trinoCoordinatorRole { + coordinators++ + if group.Group.Replicas != 1 { + return fmt.Errorf("this Trino example requires one coordinator replica") + } + } + } + if coordinators != 1 { + return fmt.Errorf("this Trino example requires one coordinator group") + } + if _, err := resolveTrinoLogging(in.Config.Common.Logging); err != nil { + return err + } + return ValidateTrinoCatalogs(in.Facts.Catalogs) +} + +// ValidateTrinoCatalogs is shared by external resolution and the pure generator. +// It validates the supported catalog shape, not plugin availability or credentials. +func ValidateTrinoCatalogs(catalogs map[string]map[string]string) error { + for _, name := range sortedKeys(catalogs) { + if !relativeFile(name) || strings.Contains(name, "/") || !utf8.ValidString(name) { + return fmt.Errorf("catalog name must be a single valid file component") + } + values := catalogs[name] + if values == nil || strings.TrimSpace(values["connector.name"]) == "" { + return fmt.Errorf("catalog requires a nonempty connector.name") + } + for key, value := range values { + if key == "" || !utf8.ValidString(key) || !utf8.ValidString(value) { + return fmt.Errorf("catalog property keys must be nonempty and properties must contain valid UTF-8") + } + } + } + return nil +} + +func trinoCoordinatorURI(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) (string, error) { + for _, group := range in.Topology { + if group.Group.Role != trinoCoordinatorRole { + continue + } + if group.Error != "" || group.Config == nil { + return "", fmt.Errorf("coordinator input unavailable: %s", group.Error) + } + return fmt.Sprintf("http://%s:%d", group.Group.ServiceDNS(), group.Config.Product.HTTPPort), nil + } + return "", fmt.Errorf("coordinator input unavailable: no coordinator group") +} + +func trinoFile(name string, values map[string]framework.PropertyValue) framework.File { + return framework.File{Directory: trinoConfigDirectory, Path: name, + Content: framework.KeyValues{Codec: framework.PropertiesCodec{}, Values: values}} +} + +func generateTrino(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) ( + framework.RuntimeDescription, error, +) { + uri, err := trinoCoordinatorURI(in) + if err != nil { + return framework.RuntimeDescription{}, err + } + // Reserve one quarter of the declared memory for non-heap JVM usage. + bytes, exact := in.Config.Common.Resources.Memory.Limit.AsInt64() + heapMi := (bytes / (1024 * 1024)) * 3 / 4 + if !exact || heapMi <= 0 { + return framework.RuntimeDescription{}, fmt.Errorf("memory must yield a positive integral heap") + } + uid, nonRoot := int64(1001), true // The selected Kubedoop Trino image runs with this identity. + configAccess := framework.DirectoryAccess{Directory: trinoConfigDirectory, MountPath: "/etc/trino", ReadOnly: true} + dataAccess := framework.DirectoryAccess{Directory: trinoDataDirectory, MountPath: "/var/trino/data"} + logAccess := framework.DirectoryAccess{Directory: trinoLogDirectory, MountPath: "/kubedoop/log/trino"} + logFile := trinoServerLog + logging, err := resolveTrinoLogging(in.Config.Common.Logging) + if err != nil { + return framework.RuntimeDescription{}, err + } + configuration := map[string]framework.PropertyValue{ + "coordinator": framework.Literal(strconv.FormatBool(in.Group.Role == trinoCoordinatorRole)), + "http-server.http.port": framework.Literal(strconv.Itoa(int(in.Config.Product.HTTPPort))), + "discovery.uri": framework.Literal(uri), + "log.enable-console": framework.Literal(strconv.FormatBool(logging.Console)), + } + if in.Group.Role == trinoCoordinatorRole { + configuration["node-scheduler.include-coordinator"] = framework.Literal("false") + } + if logging.File { + configuration["log.path"] = framework.Literal(path.Join(logAccess.MountPath, logFile)) + configuration["log.format"] = framework.Literal("JSON") + } + r := framework.RuntimeDescription{ + ConfigDirectory: trinoConfigDirectory, + Main: framework.Process{ + Name: trinoName, + Command: []string{trinoLauncher}, + Args: []string{"--etc-dir=" + configAccess.MountPath, trinoRunArgument}, + Env: []corev1.EnvVar{{Name: "TRINO_NODE_ID", ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.uid"}, + }}}, + Identity: &corev1.SecurityContext{ + RunAsUser: &uid, RunAsGroup: &uid, RunAsNonRoot: &nonRoot, + }, + Access: []framework.DirectoryAccess{configAccess, dataAccess, logAccess}, + }, + Directories: []framework.Directory{{Name: trinoConfigDirectory}, {Name: trinoDataDirectory, Data: true}, {Name: trinoLogDirectory}}, + SharedGroup: &uid, + Endpoints: []framework.Endpoint{{Name: trinoHTTPEndpoint, Port: in.Config.Product.HTTPPort}}, + Files: []framework.File{ + trinoFile("config.properties", configuration), + {Directory: trinoConfigDirectory, Path: trinoJVMFile, Content: framework.Lines{fmt.Sprintf("-Xmx%dm", heapMi)}}, + trinoFile(trinoNodeFile, map[string]framework.PropertyValue{ + trinoNodeIDKey: framework.Literal("${ENV:TRINO_NODE_ID}"), trinoEnvironmentKey: framework.Literal(in.ClusterConfig.NodeEnvironment), + "node.data-dir": framework.Literal(dataAccess.MountPath), + }), + }, + } + configureTrinoLifecycle(&r, in) + configureTrinoListener(&r, in) + if logging.File { + r.LogOutputs = []framework.LogOutput{{Container: trinoName, Directory: logAccess.Directory, RelativePath: logFile}} + } + r.Files = append(r.Files, trinoFile("log.properties", logging.Levels)) + for _, name := range sortedKeys(in.Facts.Catalogs) { + if !relativeFile(name) || strings.Contains(name, "/") { + return framework.RuntimeDescription{}, fmt.Errorf("catalog name must be a single file component: %q", name) + } + values := map[string]framework.PropertyValue{} + for key, value := range in.Facts.Catalogs[name] { + values[key] = framework.Literal(value) + } + r.Files = append(r.Files, trinoFile("catalog/"+name+".properties", values)) + } + if err := configureTrinoAuthentication(&r, in); err != nil { + return framework.RuntimeDescription{}, err + } + if err := configureTrinoS3(&r, in); err != nil { + return framework.RuntimeDescription{}, err + } + return r, nil +} + +// Shared output is explicit about waiting versus withdrawing all resources. +func trinoDiscovery(in framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]) (framework.ClusterOutput, error) { + for _, group := range in.Groups { + if group.Group.Role != trinoCoordinatorRole { + continue + } + if group.Error != "" { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "coordinator input unavailable"}, nil + } + if group.Facts != nil && group.Facts.State != framework.FactsResolved { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "coordinator facts unavailable"}, nil + } + return trinoGroupDiscovery(in.Cluster, group) + + } + return framework.ClusterOutput{State: framework.ClusterOutputReady}, nil +} diff --git a/examples/trino-operator/internal/product/facts.go b/examples/trino-operator/internal/product/facts.go new file mode 100644 index 00000000..dcf824ba --- /dev/null +++ b/examples/trino-operator/internal/product/facts.go @@ -0,0 +1,112 @@ +package product + +import ( + "context" + "fmt" + "unicode/utf8" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + kubernetesjson "sigs.k8s.io/json" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +const trinoCatalogSourceKey = "catalogs.json" + +// ResolveFacts is the sample product adapter used by generated registration. +// It is not a generic framework policy or a plugin/credential health check. +func resolveCatalogFacts(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], +) (framework.FactResult[TrinoFacts], error) { + facts := cloneFacts(in.Shared) + name := in.Config.Product.CatalogConfigMapName + if name == "" { + return resolvedTrinoFacts(facts, "NoCatalogReference"), nil + } + if len(validation.IsDNS1123Subdomain(name)) != 0 { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{State: framework.FactsInvalid, + Reason: "InvalidCatalogReference", Message: "Catalog ConfigMap name is invalid"}}, nil + } + cm := &corev1.ConfigMap{} + if err := reader.Get(ctx, types.NamespacedName{Namespace: in.Group.Namespace, Name: name}, cm); err != nil { + if apierrors.IsNotFound(err) { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{State: framework.FactsPending, + Reason: "CatalogSourceMissing", Message: "Referenced catalog ConfigMap does not exist"}}, nil + } + return framework.FactResult[TrinoFacts]{}, err + } + if !cm.DeletionTimestamp.IsZero() { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{State: framework.FactsPending, + Reason: "CatalogSourceDeleting", Message: "Referenced catalog ConfigMap is deleting"}}, nil + } + raw, found := cm.Data[trinoCatalogSourceKey] + if !found { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{State: framework.FactsInvalid, + Reason: "CatalogSourceKeyMissing", Message: "Referenced ConfigMap has no catalogs.json data key"}}, nil + } + catalogs, err := parseTrinoCatalogs(raw) + if err != nil { + // Catalog properties may contain credentials. Diagnostics identify the + // dependency separately; they never echo values or parser excerpts. + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{State: framework.FactsInvalid, + Reason: "InvalidCatalogSource", Message: "Referenced catalogs.json is not a valid catalog document"}}, nil + } + facts.Catalogs = catalogs + return resolvedTrinoFacts(facts, "CatalogSourceResolved"), nil +} + +func resolvedTrinoFacts(facts TrinoFacts, reason string) framework.FactResult[TrinoFacts] { + return framework.FactResult[TrinoFacts]{Value: &facts, Diagnostic: framework.FactDiagnostic{ + State: framework.FactsResolved, Reason: reason, Message: "Catalog generation facts are resolved; loading is not observed", + }} +} + +func parseTrinoCatalogs(raw string) (map[string]map[string]string, error) { + // Pointer values distinguish JSON null from a valid empty string. Strict + // decoding also rejects duplicate catalog/property keys instead of taking last. + var document map[string]map[string]*string + strict, err := kubernetesjson.UnmarshalStrict([]byte(raw), &document) + if err != nil || len(strict) != 0 || document == nil || !utf8.ValidString(raw) { + return nil, fmt.Errorf("catalog document must be a strict JSON object") + } + catalogs := make(map[string]map[string]string, len(document)) + for name, properties := range document { + if properties == nil { + return nil, fmt.Errorf("catalog properties cannot be null") + } + catalogs[name] = make(map[string]string, len(properties)) + for key, value := range properties { + if value == nil { + return nil, fmt.Errorf("catalog property value cannot be null") + } + catalogs[name][key] = *value + } + } + if err := ValidateTrinoCatalogs(catalogs); err != nil { + return nil, err + } + return catalogs, nil +} + +// ResolveFacts composes independent, exact platform/product resolutions. Pending +// or invalid authentication never produces a partial execution fact set. +func ResolveFacts(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], +) (framework.FactResult[TrinoFacts], error) { + result, err := resolveCatalogFacts(ctx, reader, in) + if err != nil || result.Diagnostic.State != framework.FactsResolved { + return result, err + } + diagnostic, err := resolveTrinoAuthentication(ctx, reader, in, result.Value) + if err != nil || diagnostic.State != framework.FactsResolved { + return framework.FactResult[TrinoFacts]{Diagnostic: diagnostic}, err + } + diagnostic, err = resolveTrinoS3(ctx, reader, in, result.Value) + if err != nil || diagnostic.State != framework.FactsResolved { + return framework.FactResult[TrinoFacts]{Diagnostic: diagnostic}, err + } + return result, nil +} diff --git a/examples/trino-operator/internal/product/facts_test.go b/examples/trino-operator/internal/product/facts_test.go new file mode 100644 index 00000000..ebeec141 --- /dev/null +++ b/examples/trino-operator/internal/product/facts_test.go @@ -0,0 +1,80 @@ +package product + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + 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/schema" + "k8s.io/apimachinery/pkg/types" +) + +type catalogReader struct { + object *corev1.ConfigMap + err error + calls int + key types.NamespacedName +} + +func (r *catalogReader) Get(_ context.Context, key types.NamespacedName, into framework.FactResource) error { + r.calls++ + r.key = key + if r.err != nil { + return r.err + } + *into.(*corev1.ConfigMap) = *r.object.DeepCopy() + return nil +} + +func TestCatalogFactsPresenceWaitAndSanitizedFailure(t *testing.T) { + current := effectiveInput() + in := framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + Group: current.Group, Config: current.Config, ClusterConfig: current.ClusterConfig, Shared: BaseFacts(), + } + reader := &catalogReader{} + result, err := ResolveFacts(t.Context(), reader, in) + if err != nil || reader.calls != 0 || result.Diagnostic.State != framework.FactsResolved { + t.Fatal("no catalog reference should use isolated base facts without IO") + } + result.Value.Catalogs[tpchCatalog]["connector.name"] = "changed" + if in.Shared.Catalogs[tpchCatalog]["connector.name"] != tpchCatalog { + t.Fatal("resolved facts alias the base facts") + } + in.Config.Product.CatalogConfigMapName = "catalogs" + reader.err = apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "catalogs") + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsPending || result.Value != nil || + reader.key != (types.NamespacedName{Namespace: current.Group.Namespace, Name: "catalogs"}) { + t.Fatalf("missing namespace-scoped reference did not wait: %+v %v", result, err) + } + reader.err = nil + reader.object = &corev1.ConfigMap{Data: map[string]string{trinoCatalogSourceKey: `{"tpch":{"connector.name":"tpch"}}`}} + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsResolved || result.Value.Catalogs[tpchCatalog]["connector.name"] != tpchCatalog { + t.Fatalf("valid catalog was not resolved: %+v %v", result, err) + } + reader.object.DeletionTimestamp = &metav1.Time{Time: metav1.Now().Time} + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsPending { + t.Fatal("deleting source should wait instead of providing stale catalogs") + } + reader.object.DeletionTimestamp = nil + for _, document := range []string{`null`, `{"tpch":null}`, `{"tpch":{"connector.name":null}}`, + `{"tpch":{"connector.name":"tpch","connector.name":"private-password"}}`, `{"tpch":{"password":"private-password"}}`} { + reader.object.Data[trinoCatalogSourceKey] = document + result, err = ResolveFacts(t.Context(), reader, in) + if err != nil || result.Diagnostic.State != framework.FactsInvalid || result.Value != nil || + strings.Contains(result.Diagnostic.Message, "private-password") { + t.Fatalf("invalid dependency was leaked or accepted: %+v %v", result, err) + } + } + reader.err = errors.New("read unavailable") + if _, err := ResolveFacts(t.Context(), reader, in); !errors.Is(err, reader.err) { + t.Fatal("read error was misclassified as pending or valid facts") + } +} diff --git a/examples/trino-operator/internal/product/final.go b/examples/trino-operator/internal/product/final.go new file mode 100644 index 00000000..6661efc3 --- /dev/null +++ b/examples/trino-operator/internal/product/final.go @@ -0,0 +1,104 @@ +package product + +import ( + "fmt" + "path" + "strconv" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +// validateTrinoFinal checks only modeled relations under the declared launch +// premise. framework.Unknown content or execution is not rewritten or called consistent. +func validateTrinoFinal(view framework.FinalView) []framework.Check { + if !view.FilePreparationKnown { + return []framework.Check{{Subject: trinoExecutionCheck, State: framework.Unknown, Reason: "file preparation changed"}} + } + main := findContainer(view.Pod, view.Generated.Main.Name) + if !processPremise(view.Generated.Main, main) { + return []framework.Check{{Subject: trinoExecutionCheck, State: framework.Unknown, + Reason: "image, command, args, environment or working directory changed"}} + } + for _, access := range view.Generated.Main.Access { + if access.Directory != view.Generated.ConfigDirectory { + continue + } + found := false + for _, mount := range main.VolumeMounts { + if strings.HasPrefix(mount.MountPath, access.MountPath+"/") { + return []framework.Check{{Subject: trinoExecutionCheck, State: framework.Unknown, + Reason: "a nested mount may replace generated configuration"}} + } + if mount.Name == access.Directory && mount.MountPath == access.MountPath && + mount.SubPath == "" && mount.SubPathExpr == "" { + found = true + } + } + if !found { + return []framework.Check{{Subject: trinoExecutionCheck, State: framework.Unknown, Reason: "configuration mount changed"}} + } + } + checks := make([]framework.Check, 0, 6) + for _, name := range []string{"config.properties", trinoNodeFile, trinoJVMFile, "log.properties"} { + if findFile(view.Files, view.Generated.ConfigDirectory, name) == nil { + checks = append(checks, framework.Check{Subject: name, State: framework.Conflict, Reason: "required by the declared Trino launcher"}) + } + } + file := findFile(view.Files, view.Generated.ConfigDirectory, "config.properties") + port, known := literalProperty(file, "http-server.http.port") + if known { + number, err := strconv.ParseInt(port, 10, 32) + if err != nil || number < 1 || number > 65535 { + checks = append(checks, framework.Check{Subject: trinoHTTPCheck, State: framework.Conflict, Reason: "invalid explicit HTTP port"}) + } else { + matches := false + for _, declared := range main.Ports { + if declared.Name == trinoHTTPEndpoint && declared.ContainerPort == int32(number) { + matches = true + } + } + state, reason := framework.Consistent, "file port matches the final named container port" + if !matches { + state, reason = framework.Conflict, "file port does not match the final named container port" + } + checks = append(checks, framework.Check{Subject: trinoHTTPCheck, State: state, Reason: reason}) + } + } else { + checks = append(checks, framework.Check{Subject: trinoHTTPCheck, State: framework.Unknown, + Reason: "HTTP property is absent or not structured"}) + } + logPath, known := literalProperty(file, "log.path") + for _, output := range view.Generated.LogOutputs { + if !view.LogCollectionKnown { + continue + } + expected := "" + for _, access := range view.Generated.Main.Access { + if access.Directory == output.Directory { + expected = path.Join(access.MountPath, output.RelativePath) + } + } + state, reason := framework.Unknown, "native log path is not known" + if known { + state, reason = framework.Consistent, "native log path matches declared collection source" + if logPath != expected { + state, reason = framework.Conflict, fmt.Sprintf("log.path %q differs from collected path %q", logPath, expected) + } + } + checks = append(checks, framework.Check{Subject: "trino.logging", State: state, Reason: reason}) + } + return checks +} + +func literalProperty(file *framework.File, key string) (string, bool) { + if file == nil { + return "", false + } + properties, ok := file.Content.(framework.KeyValues) + if !ok { + return "", false + } + value, ok := properties.Values[key].(framework.Literal) + return string(value), ok +} diff --git a/examples/trino-operator/internal/product/helpers.go b/examples/trino-operator/internal/product/helpers.go new file mode 100644 index 00000000..af75572b --- /dev/null +++ b/examples/trino-operator/internal/product/helpers.go @@ -0,0 +1,35 @@ +package product + +import ( + "reflect" + "slices" + + "github.com/zncdatadev/operator-go/pkg/framework" + + corev1 "k8s.io/api/core/v1" +) + +func findContainer(pod corev1.PodTemplateSpec, name string) *corev1.Container { + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == name { + return &pod.Spec.Containers[i] + } + } + return nil +} + +func findFile(files []framework.File, directory, name string) *framework.File { + for i := range files { + if files[i].Directory == directory && files[i].Path == name { + return &files[i] + } + } + return nil +} + +func processPremise(process framework.Process, container *corev1.Container) bool { + return container != nil && len(container.EnvFrom) == 0 && container.WorkingDir == "" && + process.Image == container.Image && slices.Equal(process.Command, container.Command) && + slices.Equal(process.Args, container.Args) && slices.EqualFunc(process.Env, container.Env, + func(a, b corev1.EnvVar) bool { return reflect.DeepEqual(a, b) }) +} diff --git a/examples/trino-operator/internal/product/lifecycle.go b/examples/trino-operator/internal/product/lifecycle.go new file mode 100644 index 00000000..9456a622 --- /dev/null +++ b/examples/trino-operator/internal/product/lifecycle.go @@ -0,0 +1,117 @@ +package product + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const trinoPython = "python3" + +// The initialization check is idempotent. It checks the materialized inputs and +// writable directories without creating an initialization-completed marker that +// could outlive a Pod or be mistaken for a product migration receipt. +const trinoInitialize = `import os,pathlib,tempfile +for name in ('config.properties','node.properties','jvm.config'): + p=pathlib.Path('/etc/trino')/name + if not p.is_file(): raise RuntimeError('missing materialized input: '+name) + with p.open('rb') as source: source.read(1) +for directory in ('/var/trino/data','/kubedoop/log/trino'): + with tempfile.TemporaryFile(dir=directory) as probe: + probe.write(b'kubedoop-initialization-check'); probe.flush(); os.fsync(probe.fileno()) +print('KUBEDOOP_INITIALIZED',flush=True) +` + +const trinoReady = `import json,sys,urllib.request +with urllib.request.urlopen('http://127.0.0.1:'+sys.argv[1]+'/v1/info',timeout=2) as response: + info=json.load(response) + if info.get('starting') is not False: raise RuntimeError('Trino is starting') +` + +// This protocol waits for the exact JVM birth identity after accepting shutdown. +// A successful PUT is not graceful completion. If the JVM exits, kubelet may +// terminate this hook's exec process; hook and main-process exit evidence differ. +// The explicit deadline fails the hook and leaves the final termination decision +// to kubelet's declared Pod budget. It never sends kill or treats network errors +// as a successful business shutdown. +const trinoShutdown = `import base64,glob,pathlib,sys,time,urllib.request +port,user,budget=sys.argv[1],sys.argv[2],int(sys.argv[3]) +deadline=time.monotonic()+budget +candidates=[] +for name in glob.glob('/proc/[0-9]*/cmdline'): + try: + if b'io.trino.server.TrinoServer' in pathlib.Path(name).read_bytes().split(b'\0'): + candidates.append(pathlib.Path(name).parent) + except (FileNotFoundError,PermissionError,ProcessLookupError): pass +if len(candidates)!=1: raise RuntimeError('cannot establish exact JVM identity') +process=candidates[0] +def birth(): + try: + fields=(process/'stat').read_text().rsplit(')',1)[1].split() + return fields[19] if fields[0]!='Z' else None + except (FileNotFoundError,ProcessLookupError): return None +started=birth() +if started is None: raise RuntimeError('JVM exited before shutdown request') +headers={'Content-Type':'application/json','X-Trino-User':user} +if len(sys.argv)>4: + root=pathlib.Path(sys.argv[4]) + username=(root/'username').read_text().strip(); password=(root/'password').read_text().rstrip('\r\n') + if not username or not password: raise RuntimeError('shutdown credentials are incomplete') + headers['Authorization']='Basic '+base64.b64encode((username+':'+password).encode()).decode() + headers['X-Trino-User']=username +request=urllib.request.Request('http://127.0.0.1:'+port+'/v1/info/state',data=b'"SHUTTING_DOWN"',method='PUT',headers=headers) +with urllib.request.urlopen(request,timeout=min(5,budget)) as response: + if response.status!=200: raise RuntimeError('shutdown was not accepted') + with open('/proc/1/fd/1','w') as output: + print('KUBEDOOP_SHUTDOWN_ACCEPTED',response.status,'jvm_pid='+process.name,'starttime='+started,flush=True,file=output) +while birth()==started: + if time.monotonic()>=deadline: raise TimeoutError('JVM did not exit within shutdown budget') + time.sleep(.25) +` + +func configureTrinoLifecycle(r *framework.RuntimeDescription, in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) { + port := strconv.Itoa(int(in.Config.Product.HTTPPort)) + probe := &corev1.Probe{ProbeHandler: corev1.ProbeHandler{Exec: &corev1.ExecAction{ + Command: []string{trinoPython, "-c", trinoReady, port}}}, TimeoutSeconds: 3, PeriodSeconds: 2, FailureThreshold: 120} + r.Main.StartupProbe = probe.DeepCopy() + r.Main.ReadinessProbe = probe.DeepCopy() + r.Main.ReadinessProbe.FailureThreshold = 3 + r.Main.LivenessProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/v1/info", Port: intstr.FromString("http")}}, PeriodSeconds: 10, FailureThreshold: 6} + r.Initializers = []framework.Process{{Name: "initialize-trino", Image: r.Main.Image, + Command: []string{trinoPython, "-c", trinoInitialize}, Identity: r.Main.Identity.DeepCopy(), + Access: append([]framework.DirectoryAccess(nil), r.Main.Access...)}} + r.Coordination = &framework.WorkloadCoordination{ProgressDeadline: metav1.Duration{Duration: 10 * time.Minute}} + if in.Group.Role == trinoCoordinatorRole { + r.Coordination.ShutdownPriority = 100 + } + if in.Group.Role == trinoWorkerRole && (in.Config.Product.ShutdownUser != "" || in.Config.Product.ShutdownCredentialsSecret != "") { + budget := int64(in.Config.Common.GracefulShutdownTimeout.Duration/time.Second) - 2 + credentials := in.Config.Product.ShutdownCredentialsSecret + if credentials != "" { + r.Directories = append(r.Directories, framework.Directory{Name: "shutdown-credentials", Secret: &framework.SecretVolume{SecretName: credentials}}) + r.Main.Access = append(r.Main.Access, framework.DirectoryAccess{Directory: "shutdown-credentials", MountPath: "/kubedoop/shutdown-credentials", ReadOnly: true}) + } + r.Main.Lifecycle = &corev1.Lifecycle{PreStop: &corev1.LifecycleHandler{Exec: &corev1.ExecAction{ + Command: []string{trinoPython, "-c", trinoShutdown, port, in.Config.Product.ShutdownUser, strconv.FormatInt(budget, 10)}}}} + if credentials != "" { + r.Main.Lifecycle.PreStop.Exec.Command = append(r.Main.Lifecycle.PreStop.Exec.Command, "/kubedoop/shutdown-credentials") + } + } +} + +func validateTrinoLifecycle(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) error { + user := in.Config.Product.ShutdownUser + if strings.ContainsAny(user, "\r\n\x00") || len(user) > 256 || strings.TrimSpace(user) != user { + return fmt.Errorf("shutdownUser must be a bounded HTTP identity without control characters") + } + if (user != "" || in.Config.Product.ShutdownCredentialsSecret != "") && in.Config.Common.GracefulShutdownTimeout.Duration < 10*time.Second { + return fmt.Errorf("shutdownUser requires at least 10s gracefulShutdownTimeout") + } + return nil +} diff --git a/examples/trino-operator/internal/product/lifecycle_test.go b/examples/trino-operator/internal/product/lifecycle_test.go new file mode 100644 index 00000000..727e6729 --- /dev/null +++ b/examples/trino-operator/internal/product/lifecycle_test.go @@ -0,0 +1,53 @@ +package product + +import ( + "os/exec" + "slices" + "testing" +) + +func TestTrinoLifecycleConsumesTypedInputWithoutDefaultManagementGrant(t *testing.T) { + in := effectiveInput() + runtime, err := generateTrino(in) + if err != nil { + t.Fatal(err) + } + if runtime.Main.Lifecycle != nil || len(runtime.Initializers) != 1 || runtime.Main.StartupProbe == nil || runtime.Main.ReadinessProbe == nil || runtime.Coordination == nil { + t.Fatal("default native initialization/probes or explicit shutdown boundary missing") + } + in.Config.Product.ShutdownCredentialsSecret = "admin-credentials" + runtime, err = generateTrino(in) + if err != nil { + t.Fatal(err) + } + if runtime.Main.Lifecycle == nil || !slices.Contains(runtime.Main.Lifecycle.PreStop.Exec.Command, "/kubedoop/shutdown-credentials") { + t.Fatal("secret-based shutdown is not consumed") + } + found := false + for _, dir := range runtime.Directories { + if dir.Secret != nil && dir.Secret.SecretName == "admin-credentials" { + found = true + } + } + if !found { + t.Fatal("credentials secret was not mounted") + } + in.Group.Role = trinoCoordinatorRole + runtime, err = generateTrino(in) + if err != nil || runtime.Main.Lifecycle != nil || runtime.Coordination.ShutdownPriority != 100 { + t.Fatal("coordinator must stop after workers without invoking a worker-only shutdown protocol") + } +} + +func TestTrinoLifecycleScriptsCompile(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal("Python is required to verify the actual image protocol scripts", err) + } + for name, script := range map[string]string{"initialize": trinoInitialize, "ready": trinoReady, "shutdown": trinoShutdown} { + command := exec.Command(python, "-c", "import sys; compile(sys.argv[1],sys.argv[2],'exec')", script, name) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s: %v %s", name, err, output) + } + } +} diff --git a/examples/trino-operator/internal/product/listener.go b/examples/trino-operator/internal/product/listener.go new file mode 100644 index 00000000..f4272e52 --- /dev/null +++ b/examples/trino-operator/internal/product/listener.go @@ -0,0 +1,58 @@ +package product + +import ( + "fmt" + "net" + "strconv" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const trinoListenerDirectory = "listener" + +func configureTrinoListener(r *framework.RuntimeDescription, in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) { + if in.ClusterConfig.ListenerClass == "" || in.Group.Role != trinoCoordinatorRole { + return + } + r.Directories = append(r.Directories, framework.Directory{Name: trinoListenerDirectory, Listener: &framework.ListenerVolume{Class: in.ClusterConfig.ListenerClass}}) + r.Main.Access = append(r.Main.Access, framework.DirectoryAccess{Directory: trinoListenerDirectory, MountPath: "/kubedoop/listener", ReadOnly: true}) +} +func trinoGroupDiscovery(cluster framework.ClusterIdentity, group framework.GroupOutcome) (framework.ClusterOutput, error) { + if group.Platform != nil && (group.Platform.Phase != "Observing" || group.Platform.Diagnostic.State != framework.FactsResolved) { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "coordinator platform results unavailable"}, nil + } + scheme, port := trinoHTTPEndpoint, int32(0) + for _, endpoint := range group.GeneratedEndpoints { + if endpoint.Name == trinoHTTPEndpoint && port == 0 { + port = endpoint.Port + } + if endpoint.Name == trinoHTTPSEndpoint { + scheme, port = trinoHTTPSEndpoint, endpoint.Port + } + } + if port == 0 { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "coordinator generated endpoint unavailable"}, nil + } + address := group.Group.ServiceDNS() + if group.Platform != nil && len(group.Platform.Listeners) > 0 { + found := false + for _, listener := range group.Platform.Listeners { + if listener.Directory != trinoListenerDirectory { + continue + } + if externalPort, ok := listener.Ports[scheme]; ok { + address, port, found = listener.Address, externalPort, true + break + } + } + if !found { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "coordinator listener endpoint unavailable"}, nil + } + } + return framework.ClusterOutput{State: framework.ClusterOutputReady, ConfigMaps: []corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{Name: cluster.Name + "-discovery", Namespace: cluster.Namespace}, + Data: map[string]string{"TRINO_URI": fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(address, strconv.Itoa(int(port))))}, + }}}, nil +} diff --git a/examples/trino-operator/internal/product/logging.go b/examples/trino-operator/internal/product/logging.go new file mode 100644 index 00000000..dbf34a81 --- /dev/null +++ b/examples/trino-operator/internal/product/logging.go @@ -0,0 +1,93 @@ +package product + +import ( + "fmt" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +const ( + trinoRootLogger = "ROOT" + trinoOffLevel = "OFF" + trinoTraceLevel = "TRACE" + trinoDebugLevel = "DEBUG" + trinoErrorLevel = "ERROR" + trinoWarnLevel = "WARN" + trinoLoggerName = "io.trino" +) + +type trinoLoggingPlan struct { + Console, File bool + Levels map[string]framework.PropertyValue +} + +// Airlift 336 routes both handlers through JUL logger levels. A single enabled +// sink, or two sinks with the same threshold, can be represented by clamping +// each explicit logger (including ROOT). Unequal enabled thresholds cannot. +// ROOT is the framework spelling; Airlift's native root property key is empty. +func resolveTrinoLogging(logging framework.Logging) (trinoLoggingPlan, error) { + var plan trinoLoggingPlan + for _, container := range sortedKeys(logging.Containers) { + if container != trinoName { + return plan, fmt.Errorf("unsupported log producer %q", container) + } + } + config, exists := logging.Containers[trinoName] + if !exists { + return plan, fmt.Errorf("config.logging.containers.trino is required") + } + console, err := trinoLogRank(config.Console.Level) + if err != nil { + return plan, fmt.Errorf("config.logging.containers.trino.console.level: %w", err) + } + file, err := trinoLogRank(config.File.Level) + if err != nil { + return plan, fmt.Errorf("config.logging.containers.trino.file.level: %w", err) + } + plan.Console, plan.File = config.Console.Level != trinoOffLevel, config.File.Level != trinoOffLevel + if plan.Console && plan.File && console != file { + return plan, fmt.Errorf("trino cannot represent different active console.level and file.level thresholds; " + + "use equal thresholds or set one sink to OFF") + } + threshold := file + if plan.Console { + threshold = console + } + plan.Levels = make(map[string]framework.PropertyValue, len(config.Loggers)) + for _, name := range sortedKeys(config.Loggers) { + if strings.TrimSpace(name) == "" { + return trinoLoggingPlan{}, fmt.Errorf("trino logger name is empty; use ROOT for the root logger") + } + level := config.Loggers[name].Level + rank, err := trinoLogRank(level) + if err != nil { + return trinoLoggingPlan{}, fmt.Errorf("unsupported Trino logger level for %q: %w", name, err) + } + if rank < threshold { + level = config.File.Level + if plan.Console { + level = config.Console.Level + } + } + key := name + if key == trinoRootLogger { + key = "" + } + plan.Levels[key] = framework.Literal(level) + } + if _, exists := plan.Levels[""]; !exists { + return trinoLoggingPlan{}, fmt.Errorf("config.logging.containers.trino.loggers.ROOT is required") + } + return plan, nil +} + +func trinoLogRank(level string) (int, error) { + levels := []string{trinoTraceLevel, trinoDebugLevel, trinoInfoLevel, trinoWarnLevel, trinoErrorLevel, trinoOffLevel} + for rank, candidate := range levels { + if level == candidate { + return rank, nil + } + } + return 0, fmt.Errorf("unsupported level %q", level) +} diff --git a/examples/trino-operator/internal/product/product_test.go b/examples/trino-operator/internal/product/product_test.go new file mode 100644 index 00000000..0cd8fbed --- /dev/null +++ b/examples/trino-operator/internal/product/product_test.go @@ -0,0 +1,115 @@ +package product + +import ( + "slices" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func effectiveInput() framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts] { + definition := Definition() + cluster := framework.ClusterIdentity{Name: "sample", Namespace: "test"} + coordinator := framework.GroupIdentity{ClusterIdentity: cluster, Role: trinoCoordinatorRole, Name: "default", Replicas: 1} + worker := framework.GroupIdentity{ClusterIdentity: cluster, Role: trinoWorkerRole, Name: "default", Replicas: 2} + coordinatorConfig, workerConfig := definition.Roles[trinoCoordinatorRole].Config, definition.Roles[trinoWorkerRole].Config + return framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + Group: worker, Config: workerConfig, ClusterConfig: definition.ClusterConfigDefaults, Facts: BaseFacts(), + Image: framework.ResolvedImage{Reference: "quay.io/zncdatadev/trino:476-kubedoop0.0.0-dev"}, + Topology: []framework.ResolvedGroup[TrinoConfig]{ + {Group: coordinator, Config: &coordinatorConfig}, {Group: worker, Config: &workerConfig}, + }, + } +} + +func TestNativeDefinitionUsesPodUIDAndExplicitImageIdentity(t *testing.T) { + input := effectiveInput() + definition := Definition() + if err := definition.ValidateInput(input); err != nil { + t.Fatal(err) + } + description, err := definition.GenerateGroup(input) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(description.Main.Command, []string{trinoLauncher}) || + *description.Main.Identity.RunAsUser != 1001 || *description.SharedGroup != 1001 { + t.Fatalf("Trino image execution contract changed: %+v", description.Main) + } + if len(description.Main.Env) != 1 || description.Main.Env[0].Name != "TRINO_NODE_ID" || + description.Main.Env[0].ValueFrom.FieldRef.FieldPath != "metadata.uid" { + t.Fatal("default process no longer supplies the Pod UID to native environment interpolation") + } + node := findFile(description.Files, trinoConfigDirectory, trinoNodeFile) + id, known := literalProperty(node, trinoNodeIDKey) + if !known || id != "${ENV:TRINO_NODE_ID}" { + t.Fatalf("default node identity is not native Pod UID interpolation: %q", id) + } + config := findFile(description.Files, trinoConfigDirectory, "config.properties") + uri, known := literalProperty(config, "discovery.uri") + if !known || uri != "http://sample-coordinators-default.test.svc:8080" { + t.Fatalf("discovery did not consume complete topology: %q", uri) + } + if len(description.LogOutputs) != 1 || findFile(description.Files, trinoConfigDirectory, "catalog/tpch.properties") == nil { + t.Fatal("native file outputs or bundled catalog missing") + } + input.Config.Common.Logging.Containers[trinoName] = framework.ContainerLogging{ + Console: framework.Logger{Level: trinoInfoLevel}, File: framework.Logger{Level: trinoOffLevel}, + Loggers: map[string]framework.Logger{trinoRootLogger: {Level: trinoInfoLevel}}, + } + description, err = definition.GenerateGroup(input) + if err != nil || len(description.LogOutputs) != 0 { + t.Fatalf("OFF native file sink must withdraw its actual output: %v", err) + } + if _, known := literalProperty(findFile(description.Files, trinoConfigDirectory, "config.properties"), "log.path"); known { + t.Fatal("OFF native file sink retained log.path") + } +} + +func TestNativeLoggingThresholdsAndUnsupportedValues(t *testing.T) { + for _, tc := range []struct { + name, console, file, logger, root string + wantError bool + }{ + {"file-default", trinoOffLevel, trinoTraceLevel, trinoDebugLevel, trinoInfoLevel, false}, + {"single-clamp", trinoWarnLevel, trinoOffLevel, trinoInfoLevel, trinoWarnLevel, false}, + {"equal-sinks", trinoErrorLevel, trinoErrorLevel, trinoDebugLevel, trinoErrorLevel, false}, + {"unequal-sinks", trinoInfoLevel, trinoWarnLevel, trinoInfoLevel, "", true}, + {"fatal", trinoOffLevel, "FATAL", trinoInfoLevel, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + logging := framework.Logging{Containers: map[string]framework.ContainerLogging{trinoName: { + Console: framework.Logger{Level: tc.console}, File: framework.Logger{Level: tc.file}, + Loggers: map[string]framework.Logger{trinoRootLogger: {Level: trinoInfoLevel}, trinoLoggerName: {Level: tc.logger}}, + }}} + plan, err := resolveTrinoLogging(logging) + if (err != nil) != tc.wantError { + t.Fatalf("unexpected native mapping: %+v %v", plan, err) + } + if err == nil && plan.Levels[""] != framework.Literal(tc.root) { + t.Fatalf("native empty root key has incorrect threshold: %+v", plan.Levels) + } + }) + } +} + +func TestDiscoveryWaitsForDeclaredCoordinatorAndWithdrawsAbsentRole(t *testing.T) { + in := framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]{Cluster: effectiveInput().Group.ClusterIdentity} + output, err := trinoDiscovery(in) + if err != nil || output.State != framework.ClusterOutputReady || len(output.ConfigMaps) != 0 { + t.Fatal("no declared coordinator should explicitly withdraw shared discovery") + } + in.Groups = []framework.GroupOutcome{{Group: effectiveInput().Topology[0].Group, Error: "invalid input"}} + output, err = trinoDiscovery(in) + if err != nil || output.State != framework.ClusterOutputPending || output.Reason == "" || len(output.ConfigMaps) != 0 { + t.Fatal("failed declared coordinator should keep shared output pending") + } + in.Groups[0].Error = "" + in.Groups[0].GeneratedEndpoints = []framework.Endpoint{{Name: trinoHTTPEndpoint, Port: 8080}} + output, err = trinoDiscovery(in) + if err != nil || output.State != framework.ClusterOutputReady || len(output.ConfigMaps) != 1 || + !strings.HasSuffix(output.ConfigMaps[0].Name, "-discovery") { + t.Fatalf("generated discovery is incomplete: %+v %v", output, err) + } +} diff --git a/examples/trino-operator/internal/product/s3.go b/examples/trino-operator/internal/product/s3.go new file mode 100644 index 00000000..3edfc716 --- /dev/null +++ b/examples/trino-operator/internal/product/s3.go @@ -0,0 +1,119 @@ +package product + +import ( + "context" + "fmt" + "net" + "net/url" + "strconv" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" +) + +// HiveCatalog is product intent; connection selection and inheritance belong to +// the framework's S3 domain. Its catalog name is fixed so overrides stay explicit. +type HiveCatalog struct { + MetastoreURI string `json:"metastoreURI"` + S3 framework.S3Connection `json:"s3"` +} + +const ( + s3CatalogName = "hive" + s3CredentialSlot = "s3-credentials" + s3CredentialPath = "/kubedoop/s3-credentials" + s3AccessEnvironment = "TRINO_S3_ACCESS_KEY" + s3SecretEnvironment = "TRINO_S3_SECRET_KEY" + s3ShellFlags = "-ec" + s3Shell = "/bin/sh" +) + +// The script contains only fixed paths and variable names. It does not evaluate +// file content as shell code or write credential bytes into a ConfigMap. +const s3Launcher = `TRINO_S3_ACCESS_KEY="$(cat /kubedoop/s3-credentials/ACCESS_KEY)" +TRINO_S3_SECRET_KEY="$(cat /kubedoop/s3-credentials/SECRET_KEY)" +test -n "$TRINO_S3_ACCESS_KEY" +test -n "$TRINO_S3_SECRET_KEY" +export TRINO_S3_ACCESS_KEY TRINO_S3_SECRET_KEY +exec "$0" "$@"` + +func resolveTrinoS3(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], facts *TrinoFacts, +) (framework.FactDiagnostic, error) { + hive := in.Config.Product.Hive + if hive.S3.Type == "" || hive.S3.Type == framework.S3Disabled { + if hive.MetastoreURI != "" { + return framework.FactDiagnostic{State: framework.FactsInvalid, Reason: "InvalidHiveS3", + Message: "Hive metastoreURI requires an enabled S3 connection"}, nil + } + facts.S3 = framework.ResolvedS3Connection{} + return framework.FactDiagnostic{State: framework.FactsResolved, Reason: "HiveS3Disabled"}, nil + } + if err := validateMetastoreURI(hive.MetastoreURI); err != nil { + return framework.FactDiagnostic{State: framework.FactsInvalid, Reason: "InvalidHiveMetastore", + Message: err.Error()}, nil + } + result, err := framework.ResolveS3Connection(ctx, reader, in.Group.Namespace, hive.S3) + if err != nil || result.Diagnostic.State != framework.FactsResolved { + return result.Diagnostic, err + } + if _, exists := facts.Catalogs[s3CatalogName]; exists { + return framework.FactDiagnostic{State: framework.FactsInvalid, Reason: "ConflictingHiveCatalog", + Message: "The typed Hive/S3 input and catalog source both declare hive"}, nil + } + facts.S3 = *result.Value + if facts.Catalogs == nil { + facts.Catalogs = map[string]map[string]string{} + } + // These names are from Trino tag 476, not the current documentation's + // renamed filesystem gate. The consumer uses Airlift env substitution. + facts.Catalogs[s3CatalogName] = map[string]string{ + "connector.name": s3CatalogName, "hive.metastore.uri": hive.MetastoreURI, + "fs.native-s3.enabled": strconv.FormatBool(true), "s3.endpoint": facts.S3.Endpoint, "s3.region": facts.S3.Region, + "s3.path-style-access": strconv.FormatBool(facts.S3.PathStyle), + "s3.aws-access-key": "${ENV:" + s3AccessEnvironment + "}", + "s3.aws-secret-key": "${ENV:" + s3SecretEnvironment + "}", + } + return result.Diagnostic, nil +} + +func validateMetastoreURI(value string) error { + uri, err := url.Parse(value) + if err != nil || uri.Scheme != "thrift" || uri.User != nil || uri.Path != "" || uri.RawQuery != "" || uri.Fragment != "" { + return fmt.Errorf("hive.metastoreURI must be a single thrift://host:port endpoint") + } + host, port, err := net.SplitHostPort(uri.Host) + if err != nil || host == "" { + return fmt.Errorf("hive.metastoreURI requires host and port") + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return fmt.Errorf("hive.metastoreURI has an invalid port") + } + return nil +} + +func configureTrinoS3(runtime *framework.RuntimeDescription, + in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], +) error { + if in.Facts.S3.Endpoint == "" { + if in.Config.Product.Hive.S3.Type != "" && in.Config.Product.Hive.S3.Type != framework.S3Disabled { + return fmt.Errorf("enabled Hive/S3 input has no resolved connection facts") + } + return nil + } + credentials := in.Facts.S3.Credentials + runtime.Directories = append(runtime.Directories, framework.Directory{Name: s3CredentialSlot, + Secret: &framework.SecretVolume{SecretName: credentials.SecretName, SecretClass: credentials.SecretClass, + Scope: credentials.Scope}}) + runtime.Main.Access = append(runtime.Main.Access, + framework.DirectoryAccess{Directory: s3CredentialSlot, MountPath: s3CredentialPath, ReadOnly: true}) + // The fixed launcher remains part of Command ($0), so replacing CLI Args + // cannot turn the first launcher option into the executable. + runtime.Main.Command = append([]string{s3Shell, s3ShellFlags, s3Launcher}, runtime.Main.Command...) + // Endpoint/region/addressing changes must reach a fresh native process even + // when an installation has not opted into general ConfigMap restarts. + runtime.Main.Env = append(runtime.Main.Env, corev1.EnvVar{Name: "TRINO_S3_CONNECTION", + Value: in.Facts.S3.Endpoint + "/" + in.Facts.S3.Region + "/" + strconv.FormatBool(in.Facts.S3.PathStyle)}) + return nil +} diff --git a/examples/trino-operator/internal/product/s3_launcher_test.go b/examples/trino-operator/internal/product/s3_launcher_test.go new file mode 100644 index 00000000..5095d339 --- /dev/null +++ b/examples/trino-operator/internal/product/s3_launcher_test.go @@ -0,0 +1,114 @@ +package product + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// Exercise the real internal pipeline with the actual product adapter: the +// regression crossed generation, inherited CLI overrides and final Pod patches. +func TestS3LauncherRetainsCommandAcrossOverrideChannels(t *testing.T) { + roleArgs := []string{testTrinoEtcArgument, "-D", "operator.go.update=role", trinoRunArgument} + groupArgs := []string{testTrinoEtcArgument, "-D", "operator.go.update=group", trinoRunArgument} + emptyArgs := []string{} + configured := effectiveInput() + configured.Config.Product.Hive = HiveCatalog{MetastoreURI: "thrift://metastore.test:9083", + S3: framework.S3Connection{Type: framework.S3Inline, Inline: framework.S3Endpoint{Host: "minio.test", Port: 9000, + Credentials: framework.S3Credentials{SecretName: s3CredentialSlot}}}} + resolved, err := ResolveFacts(t.Context(), nil, framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + Group: configured.Group, Config: configured.Config, ClusterConfig: configured.ClusterConfig, Shared: configured.Facts}) + if err != nil || resolved.Value == nil { + t.Fatalf("S3 fixture facts did not resolve: %v", err) + } + config, err := json.Marshal(map[string]any{"hive": map[string]any{ + "metastoreURI": configured.Config.Product.Hive.MetastoreURI, + "s3": map[string]any{"type": framework.S3Inline, "inline": configured.Config.Product.Hive.S3.Inline}}}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + group *[]string + pod json.RawMessage + want []string + }{ + {name: "inherited-role-cli", want: roleArgs}, + {name: "group-cli", group: &groupArgs, want: groupArgs}, + {name: "explicit-empty-cli", group: &emptyArgs, want: emptyArgs}, + {name: "role-pod-above-group-cli", group: &groupArgs, want: []string{"--etc-dir=/pod/config", trinoRunArgument}, + pod: json.RawMessage(`{"spec":{"containers":[{"name":"trino","args":["--etc-dir=/pod/config","run"]}]}}`)}, + } { + t.Run(tc.name, func(t *testing.T) { + projection := input.Projection{Cluster: framework.ClusterIdentity{Name: "sample", Namespace: "test"}, + Roles: []input.Role{ + {Name: trinoCoordinatorRole, Config: config, Groups: []input.Group{{Name: "primary"}}}, + {Name: trinoWorkerRole, Config: config, + Overrides: &input.Overrides{CLIOverrides: &roleArgs, PodOverrides: tc.pod}, + Groups: []input.Group{{Name: "primary", Overrides: &input.Overrides{CLIOverrides: tc.group}}}}, + }} + plan, err := pipeline.Build(Definition(), projection, *resolved.Value, framework.AssemblyOptions{ + MaterializerImage: "helper:test", VectorImage: "vector:test"}) + if err != nil { + t.Fatal(err) + } + for _, group := range plan.Groups { + if group.Outcome.Group.Role != trinoWorkerRole { + continue + } + if group.Resources == nil || group.Outcome.Error != "" { + t.Fatalf("S3 and valid override composition failed: %s", group.Outcome.Error) + } + main := group.Resources.StatefulSet.Spec.Template.Spec.Containers[0] + if !slices.Equal(main.Command, []string{s3Shell, s3ShellFlags, s3Launcher, trinoLauncher}) || + !slices.Equal(main.Args, tc.want) { + t.Fatalf("launcher/argument boundary changed: command=%q args=%q", main.Command, main.Args) + } + runS3Launcher(t, main.Command, main.Args) + return + } + t.Fatal("worker was not generated") + }) + } +} + +func runS3Launcher(t *testing.T, command, args []string) { + t.Helper() + directory := t.TempDir() + // Only fixed credential reads are replaced by fixture output. The actual + // wrapper executes in /bin/sh and must forward all options without evaluation. + cat := `#!/bin/sh +case "$1" in + /kubedoop/s3-credentials/ACCESS_KEY) printf '%s' 'fixture-access' ;; + /kubedoop/s3-credentials/SECRET_KEY) printf '%s' 'fixture-secret' ;; + *) exit 1 ;; +esac +` + launcher := `#!/bin/sh +test "$TRINO_S3_ACCESS_KEY" = fixture-access || exit 1 +test "$TRINO_S3_SECRET_KEY" = fixture-secret || exit 1 +printf '%s\n' "$@" +` + for name, content := range map[string]string{"cat": cat, "launcher": launcher} { + if err := os.WriteFile(filepath.Join(directory, name), []byte(content), 0700); err != nil { + t.Fatal(err) + } + } + argv := slices.Clone(command) + argv[3] = filepath.Join(directory, "launcher") // replace the image-only executable with a local argument observer + argv = append(argv, args...) + process := exec.CommandContext(t.Context(), argv[0], argv[1:]...) + process.Env = append(os.Environ(), "PATH="+directory+":"+os.Getenv("PATH")) + output, err := process.CombinedOutput() + if err != nil || string(output) != strings.Join(args, "\n")+"\n" { + t.Fatalf("actual shell did not execute launcher with final CLI args: %v, output=%q", err, output) + } +} diff --git a/examples/trino-operator/internal/product/s3_test.go b/examples/trino-operator/internal/product/s3_test.go new file mode 100644 index 00000000..90bba738 --- /dev/null +++ b/examples/trino-operator/internal/product/s3_test.go @@ -0,0 +1,60 @@ +package product + +import ( + "context" + "slices" + "strconv" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +const testTrinoEtcArgument = "--etc-dir=/etc/trino" + +func TestTrinoS3CatalogConsumesResolvedConnectionAndCredentialFiles(t *testing.T) { + in := effectiveInput() + in.Config.Product.Hive = HiveCatalog{MetastoreURI: "thrift://metastore.test.svc:9083", + S3: framework.S3Connection{Type: framework.S3Inline, Inline: framework.S3Endpoint{ + Host: "minio.test.svc", Port: 9000, PathStyle: true, + Credentials: framework.S3Credentials{SecretName: s3CredentialSlot}}}} + result, err := ResolveFacts(context.Background(), nil, + framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + Group: in.Group, Config: in.Config, ClusterConfig: in.ClusterConfig, Shared: in.Facts}) + if err != nil || result.Value == nil || result.Diagnostic.State != framework.FactsResolved { + t.Fatalf("inline connection resolution failed: %+v %v", result, err) + } + in.Facts = *result.Value + if err := validateTrinoInput(in); err != nil { + t.Fatal(err) + } + runtime, err := generateTrino(in) + if err != nil { + t.Fatal(err) + } + catalog := findFile(runtime.Files, trinoConfigDirectory, "catalog/hive.properties") + for key, want := range map[string]string{ + "fs.native-s3.enabled": strconv.FormatBool(true), "s3.endpoint": "http://minio.test.svc:9000", + "s3.path-style-access": strconv.FormatBool(true), "s3.region": "us-east-1", + "s3.aws-access-key": "${ENV:TRINO_S3_ACCESS_KEY}", "s3.aws-secret-key": "${ENV:TRINO_S3_SECRET_KEY}", + } { + if got, known := literalProperty(catalog, key); !known || got != want { + t.Fatalf("Trino 476 catalog %s: got %q want %q", key, got, want) + } + } + if !slices.Equal(runtime.Main.Command, []string{s3Shell, s3ShellFlags, s3Launcher, trinoLauncher}) || + !slices.Equal(runtime.Main.Args, []string{testTrinoEtcArgument, trinoRunArgument}) { + t.Fatal("credential files have no native process consumer") + } + index := slices.IndexFunc(runtime.Directories, func(directory framework.Directory) bool { + return directory.Name == s3CredentialSlot + }) + if index < 0 || runtime.Directories[index].Secret == nil || + runtime.Directories[index].Secret.SecretName != s3CredentialSlot { + t.Fatal("credential reference was not declared as a platform directory") + } + if !slices.ContainsFunc(runtime.Main.Access, func(access framework.DirectoryAccess) bool { + return access.Directory == s3CredentialSlot && access.MountPath == s3CredentialPath && access.ReadOnly + }) { + t.Fatal("native launcher credential path is not mounted read-only") + } +} diff --git a/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook.go b/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook.go deleted file mode 100644 index 1bcfe2c3..00000000 --- a/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook.go +++ /dev/null @@ -1,271 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 v1alpha1 - -import ( - "context" - "fmt" - "reflect" - "regexp" - "strings" - - ctrl "sigs.k8s.io/controller-runtime" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - "github.com/zncdatadev/operator-go/pkg/webhook" -) - -// nolint:unused -// log is for logging in this package. -var trinoclusterlog = logf.Log.WithName("trinocluster-resource") - -// SetupTrinoClusterWebhookWithManager registers the webhook for TrinoCluster in the manager. -func SetupTrinoClusterWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr, &trinov1alpha1.TrinoCluster{}). - WithValidator(&TrinoClusterCustomValidator{}). - WithDefaulter(&TrinoClusterCustomDefaulter{}). - Complete() -} - -// +kubebuilder:webhook:path=/mutate-trino-kubedoop-dev-v1alpha1-trinocluster,mutating=true,failurePolicy=fail,sideEffects=None,groups=trino.kubedoop.dev,resources=trinoclusters,verbs=create;update,versions=v1alpha1,name=mtrinocluster-v1alpha1.kb.io,admissionReviewVersions=v1 - -// TrinoClusterCustomDefaulter struct is responsible for setting default values on the custom resource of the -// Kind TrinoCluster when those are created or updated. -type TrinoClusterCustomDefaulter struct{} - -// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind TrinoCluster. -func (d *TrinoClusterCustomDefaulter) Default(_ context.Context, obj *trinov1alpha1.TrinoCluster) error { - trinoclusterlog.Info("Defaulting for TrinoCluster", "name", obj.GetName()) - - // spec.image is deliberately NOT defaulted here. Webhook defaults are persisted into the spec - // at admission and never recomputed, so writing kubedoopVersion here would freeze every cluster - // on the operator version that first admitted it. The handler's ImageDefaults fills the same - // fields on every reconcile instead — see internal/controller/trino_handler.go. - - // Initialize coordinators if not specified - if obj.Spec.Coordinators == nil { - obj.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{} - } - - // Set default coordinator port if not specified - if obj.Spec.Coordinators.HTTPPort == 0 { - obj.Spec.Coordinators.HTTPPort = constants.DefaultHTTPPort - trinoclusterlog.Info("Set default coordinator HTTP port", "port", constants.DefaultHTTPPort) - } - - // Initialize workers if not specified - if obj.Spec.Workers == nil { - obj.Spec.Workers = &trinov1alpha1.WorkersSpec{} - } - - // Set default worker port if not specified - if obj.Spec.Workers.HTTPPort == 0 { - obj.Spec.Workers.HTTPPort = constants.DefaultHTTPPort - trinoclusterlog.Info("Set default worker HTTP port", "port", constants.DefaultHTTPPort) - } - - return nil -} - -// +kubebuilder:webhook:path=/validate-trino-kubedoop-dev-v1alpha1-trinocluster,mutating=false,failurePolicy=fail,sideEffects=None,groups=trino.kubedoop.dev,resources=trinoclusters,verbs=create;update,versions=v1alpha1,name=vtrinocluster-v1alpha1.kb.io,admissionReviewVersions=v1 - -// TrinoClusterCustomValidator struct is responsible for validating the TrinoCluster resource -// when it is created, updated, or deleted. -type TrinoClusterCustomValidator struct{} - -// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type TrinoCluster. -func (v *TrinoClusterCustomValidator) ValidateCreate(_ context.Context, obj *trinov1alpha1.TrinoCluster) (admission.Warnings, error) { - trinoclusterlog.Info("Validation for TrinoCluster upon creation", "name", obj.GetName()) - - errs := v.validateTrinoCluster(obj) - if errs.HasErrors() { - return nil, errs - } - - return nil, nil -} - -// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type TrinoCluster. -func (v *TrinoClusterCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj *trinov1alpha1.TrinoCluster) (admission.Warnings, error) { - trinoclusterlog.Info("Validation for TrinoCluster upon update", "name", newObj.GetName()) - - errs := v.validateTrinoCluster(newObj) - - // Validate immutable fields - if oldObj.Spec.Image != nil && !reflect.DeepEqual(oldObj.Spec.Image, newObj.Spec.Image) { - errs.Add("spec.image", "image cannot be changed after creation") - } - - if errs.HasErrors() { - return nil, errs - } - - return nil, nil -} - -// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type TrinoCluster. -func (v *TrinoClusterCustomValidator) ValidateDelete(_ context.Context, obj *trinov1alpha1.TrinoCluster) (admission.Warnings, error) { - trinoclusterlog.Info("Validation for TrinoCluster upon deletion", "name", obj.GetName()) - - // No validation needed for deletion - return nil, nil -} - -// validateTrinoCluster validates all fields of the TrinoCluster -func (v *TrinoClusterCustomValidator) validateTrinoCluster(obj *trinov1alpha1.TrinoCluster) webhook.ValidationErrors { - errs := webhook.ValidationErrors{} - - // Validate image - if obj.Spec.Image != nil { - if obj.Spec.Image.Custom != "" { - if err := validateImage(obj.Spec.Image.Custom); err != nil { - errs.AddWithValue("spec.image.custom", err.Error(), obj.Spec.Image.Custom) - } - } else if _, err := obj.Spec.Image.ResolveImage(constants.ProductName, constants.ImageDefaults()); err != nil { - // Resolved against the same defaults the handler uses, so a spec stating only - // productVersion — which the handler CAN resolve — is not rejected here. - errs.Add("spec.image", err.Error()) - } - } - - // Validate coordinators - if obj.Spec.Coordinators != nil { - v.validateCoordinators(obj.Spec.Coordinators, &errs) - } - - // Validate workers - if obj.Spec.Workers != nil { - v.validateWorkers(obj.Spec.Workers, &errs) - } - - // Validate catalogs - for i, catalog := range obj.Spec.Catalogs { - v.validateCatalog(&catalog, i, &errs) - } - - return errs -} - -// validateCoordinators validates coordinator configuration -func (v *TrinoClusterCustomValidator) validateCoordinators(spec *trinov1alpha1.CoordinatorsSpec, errs *webhook.ValidationErrors) { - // Validate HTTP port - if spec.HTTPPort != 0 { - if err := validatePort(spec.HTTPPort); err != nil { - errs.AddWithValue("spec.coordinators.httpPort", err.Error(), spec.HTTPPort) - } - } -} - -// validateWorkers validates worker configuration -func (v *TrinoClusterCustomValidator) validateWorkers(spec *trinov1alpha1.WorkersSpec, errs *webhook.ValidationErrors) { - // Validate HTTP port - if spec.HTTPPort != 0 { - if err := validatePort(spec.HTTPPort); err != nil { - errs.AddWithValue("spec.workers.httpPort", err.Error(), spec.HTTPPort) - } - } -} - -// validateCatalog validates catalog configuration -func (v *TrinoClusterCustomValidator) validateCatalog(spec *trinov1alpha1.CatalogSpec, index int, errs *webhook.ValidationErrors) { - fieldPrefix := fmt.Sprintf("spec.catalogs[%d]", index) - - // Validate catalog name - if spec.Name == "" { - errs.Add(fieldPrefix+".name", "catalog name is required") - } else if err := validateCatalogName(spec.Name); err != nil { - errs.AddWithValue(fieldPrefix+".name", err.Error(), spec.Name) - } - - // Validate catalog type - if spec.Type == "" { - errs.Add(fieldPrefix+".type", "catalog type is required") - } else if err := validateCatalogType(spec.Type); err != nil { - errs.AddWithValue(fieldPrefix+".type", err.Error(), spec.Type) - } -} - -// validateImage validates container image format -func validateImage(image string) error { - // Image format: [registry/]repository[:tag] - // Examples: trinodb/trino:435, ghcr.io/trinodb/trino:435, trino:latest - imagePattern := `^([a-z0-9-]+(\.[a-z0-9-]+)*(:[0-9]+)?/)?[a-z0-9_.-]+(/[a-z0-9_.-]+)*(:[a-zA-Z0-9_.-]+)?$` - matched, err := regexp.MatchString(imagePattern, image) - if err != nil { - return fmt.Errorf("failed to validate image: %w", err) - } - if !matched { - return fmt.Errorf("invalid image format") - } - return nil -} - -// validatePort validates port number range -func validatePort(port int32) error { - if port < 1 || port > 65535 { - return fmt.Errorf("must be between 1 and 65535") - } - return nil -} - -// validateCatalogName validates catalog name format -func validateCatalogName(name string) error { - // Catalog name must be lowercase alphanumeric with underscores - // and cannot start with a number - if len(name) == 0 || len(name) > 64 { - return fmt.Errorf("must be between 1 and 64 characters") - } - - namePattern := `^[a-z][a-z0-9_]*$` - matched, err := regexp.MatchString(namePattern, name) - if err != nil { - return fmt.Errorf("failed to validate catalog name: %w", err) - } - if !matched { - return fmt.Errorf("must start with a lowercase letter and contain only lowercase letters, numbers, and underscores") - } - - return nil -} - -// validateCatalogType validates catalog type against allowed values -func validateCatalogType(catalogType string) error { - validTypes := map[string]bool{ - "hive": true, - "iceberg": true, - "kafka": true, - "mysql": true, - "postgresql": true, - "delta": true, - "tpch": true, - "tpcds": true, - } - - normalizedType := strings.ToLower(catalogType) - if !validTypes[normalizedType] { - validList := make([]string, 0, len(validTypes)) - for t := range validTypes { - validList = append(validList, t) - } - return fmt.Errorf("must be one of: %s", strings.Join(validList, ", ")) - } - - return nil -} diff --git a/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook_test.go b/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook_test.go deleted file mode 100644 index fcff4295..00000000 --- a/examples/trino-operator/internal/webhook/v1alpha1/trinocluster_webhook_test.go +++ /dev/null @@ -1,246 +0,0 @@ -/* -Copyright 2024 ZNCDataDev. - -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 v1alpha1 - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - "github.com/zncdatadev/operator-go/examples/trino-operator/internal/constants" - commonsv1alpha1 "github.com/zncdatadev/operator-go/pkg/apis/commons/v1alpha1" -) - -var _ = Describe("TrinoCluster Webhook", func() { - var ( - ctx context.Context - obj *trinov1alpha1.TrinoCluster - oldObj *trinov1alpha1.TrinoCluster - validator TrinoClusterCustomValidator - defaulter TrinoClusterCustomDefaulter - ) - - BeforeEach(func() { - ctx = context.Background() - obj = &trinov1alpha1.TrinoCluster{ - Spec: trinov1alpha1.TrinoClusterSpec{}, - } - oldObj = &trinov1alpha1.TrinoCluster{ - Spec: trinov1alpha1.TrinoClusterSpec{}, - } - validator = TrinoClusterCustomValidator{} - defaulter = TrinoClusterCustomDefaulter{} - }) - - Context("When creating TrinoCluster under Defaulting Webhook", func() { - It("Should NOT default the image — that is the handler's job now", func() { - // Webhook defaults are persisted into the spec at admission and never recomputed, so - // writing kubedoopVersion here froze every cluster on the operator version that first - // admitted it: an operator upgrade could not move an existing cluster onto the - // co-released product image. The handler's ImageDefaults fills the same fields on every - // reconcile instead (internal/controller/trino_handler.go), which is also what lets a - // user write only `productVersion` and still get a valid kubedoop tag. - obj.Spec.Image = nil - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Image).To(BeNil(), "the spec must record only what the user wrote") - }) - - It("Should leave a user-specified image untouched", func() { - obj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "custom/trino:latest"} - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Image.Custom).To(Equal("custom/trino:latest")) - Expect(obj.Spec.Image.Repo).To(BeEmpty()) - Expect(obj.Spec.Image.ProductVersion).To(BeEmpty()) - }) - - It("Should let the handler resolve what the webhook no longer writes", func() { - // The end-to-end statement of the change: a spec carrying only productVersion — which - // GetImage could not turn into a reference at all — resolves against the same defaults - // the handler uses, including the -kubedoop suffix the registry requires. - obj.Spec.Image = &commonsv1alpha1.ImageSpec{ProductVersion: "999"} - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - - image, err := obj.Spec.Image.ResolveImage(constants.ProductName, constants.ImageDefaults()) - Expect(err).NotTo(HaveOccurred()) - Expect(image).To(Equal(constants.DefaultImageRepo + "/" + constants.ProductName + - ":999-kubedoop" + constants.DefaultImageKubedoopVersion)) - }) - - It("Should initialize coordinators with default port", func() { - obj.Spec.Coordinators = nil - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Coordinators).NotTo(BeNil()) - Expect(obj.Spec.Coordinators.HTTPPort).To(Equal(constants.DefaultHTTPPort)) - }) - - It("Should not override coordinator port when specified", func() { - obj.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{HTTPPort: 9090} - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Coordinators.HTTPPort).To(Equal(int32(9090))) - }) - - It("Should initialize workers with default port", func() { - obj.Spec.Workers = nil - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Workers).NotTo(BeNil()) - Expect(obj.Spec.Workers.HTTPPort).To(Equal(constants.DefaultHTTPPort)) - }) - - It("Should not override worker port when specified", func() { - obj.Spec.Workers = &trinov1alpha1.WorkersSpec{HTTPPort: 9091} - Expect(defaulter.Default(ctx, obj)).To(Succeed()) - Expect(obj.Spec.Workers.HTTPPort).To(Equal(int32(9091))) - }) - }) - - Context("When creating TrinoCluster under Validating Webhook", func() { - It("Should admit valid TrinoCluster", func() { - obj.Spec.Image = &commonsv1alpha1.ImageSpec{ - Repo: constants.DefaultImageRepo, - ProductVersion: constants.DefaultImageProductVersion, - } - obj.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{HTTPPort: 8080} - obj.Spec.Workers = &trinov1alpha1.WorkersSpec{HTTPPort: 8080} - warnings, err := validator.ValidateCreate(ctx, obj) - Expect(warnings).To(BeNil()) - Expect(err).To(Succeed()) - }) - - It("Should deny invalid image format", func() { - obj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "INVALID_IMAGE!"} - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.image")) - }) - - It("Should admit coordinator port 0 (unset, will be defaulted)", func() { - obj.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{HTTPPort: 0} - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(Succeed()) - }) - - It("Should deny invalid coordinator port (too high)", func() { - obj.Spec.Coordinators = &trinov1alpha1.CoordinatorsSpec{HTTPPort: 70000} - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.coordinators.httpPort")) - }) - - It("Should deny invalid worker port", func() { - obj.Spec.Workers = &trinov1alpha1.WorkersSpec{HTTPPort: -1} - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.workers.httpPort")) - }) - - It("Should deny catalog with empty name", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "", Type: "hive"}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.catalogs[0].name")) - }) - - It("Should deny catalog with invalid name (starts with number)", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "1hive", Type: "hive"}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.catalogs[0].name")) - }) - - It("Should deny catalog with invalid name (uppercase)", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "Hive", Type: "hive"}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.catalogs[0].name")) - }) - - It("Should deny catalog with empty type", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my_hive", Type: ""}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.catalogs[0].type")) - }) - - It("Should deny catalog with invalid type", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "my_catalog", Type: "invalid_type"}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("spec.catalogs[0].type")) - }) - - It("Should admit valid catalog configuration", func() { - obj.Spec.Catalogs = []trinov1alpha1.CatalogSpec{ - {Name: "hive_catalog", Type: "hive", Properties: map[string]string{"key": "value"}}, - {Name: "iceberg_catalog", Type: "iceberg"}, - {Name: "kafka_catalog", Type: "kafka"}, - {Name: "mysql_catalog", Type: "mysql"}, - {Name: "postgres_catalog", Type: "postgresql"}, - {Name: "delta_catalog", Type: "delta"}, - {Name: "tpch_catalog", Type: "tpch"}, - {Name: "tpcds_catalog", Type: "tpcds"}, - } - _, err := validator.ValidateCreate(ctx, obj) - Expect(err).To(Succeed()) - }) - }) - - Context("When updating TrinoCluster under Validating Webhook", func() { - It("Should admit valid update", func() { - oldObj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"} - obj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"} - _, err := validator.ValidateUpdate(ctx, oldObj, obj) - Expect(err).To(Succeed()) - }) - - It("Should deny image change", func() { - oldObj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:435"} - obj.Spec.Image = &commonsv1alpha1.ImageSpec{Custom: "trinodb/trino:436"} - _, err := validator.ValidateUpdate(ctx, oldObj, obj) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("image cannot be changed")) - }) - - It("Should admit update when old image was nil", func() { - oldObj.Spec.Image = nil - obj.Spec.Image = &commonsv1alpha1.ImageSpec{ - Repo: constants.DefaultImageRepo, - ProductVersion: constants.DefaultImageProductVersion, - } - _, err := validator.ValidateUpdate(ctx, oldObj, obj) - Expect(err).To(Succeed()) - }) - }) - - Context("When deleting TrinoCluster under Validating Webhook", func() { - It("Should always admit deletion", func() { - _, err := validator.ValidateDelete(ctx, obj) - Expect(err).To(Succeed()) - }) - }) -}) diff --git a/examples/trino-operator/internal/webhook/v1alpha1/webhook_suite_test.go b/examples/trino-operator/internal/webhook/v1alpha1/webhook_suite_test.go deleted file mode 100644 index 372e2f03..00000000 --- a/examples/trino-operator/internal/webhook/v1alpha1/webhook_suite_test.go +++ /dev/null @@ -1,165 +0,0 @@ -/* -Copyright 2026 ZNCDataDev. - -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 v1alpha1 - -import ( - "context" - "crypto/tls" - "fmt" - "net" - "os" - "path/filepath" - "testing" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - "sigs.k8s.io/controller-runtime/pkg/webhook" - - trinov1alpha1 "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" - // +kubebuilder:scaffold:imports -) - -// These tests use Ginkgo (BDD-style Go testing framework). Refer to -// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. - -var ( - ctx context.Context - cancel context.CancelFunc - k8sClient client.Client - cfg *rest.Config - testEnv *envtest.Environment -) - -func TestAPIs(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Webhook Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - ctx, cancel = context.WithCancel(context.TODO()) - - var err error - err = trinov1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - // +kubebuilder:scaffold:scheme - - By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: false, - - WebhookInstallOptions: envtest.WebhookInstallOptions{ - Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, - }, - } - - // Retrieve the first found binary directory to allow running tests from IDEs - if getFirstFoundEnvTestBinaryDir() != "" { - testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() - } - - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) - - // start webhook server using Manager. - webhookInstallOptions := &testEnv.WebhookInstallOptions - mgr, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - WebhookServer: webhook.NewServer(webhook.Options{ - Host: webhookInstallOptions.LocalServingHost, - Port: webhookInstallOptions.LocalServingPort, - CertDir: webhookInstallOptions.LocalServingCertDir, - }), - LeaderElection: false, - Metrics: metricsserver.Options{BindAddress: "0"}, - }) - Expect(err).NotTo(HaveOccurred()) - - err = SetupTrinoClusterWebhookWithManager(mgr) - Expect(err).NotTo(HaveOccurred()) - - // +kubebuilder:scaffold:webhook - - go func() { - defer GinkgoRecover() - err = mgr.Start(ctx) - Expect(err).NotTo(HaveOccurred()) - }() - - // wait for the webhook server to get ready. - dialer := &net.Dialer{Timeout: time.Second} - addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) - Eventually(func() error { - conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) - if err != nil { - return err - } - - return conn.Close() - }).Should(Succeed()) -}) - -var _ = AfterSuite(func() { - By("tearing down the test environment") - cancel() - Eventually(func() error { - return testEnv.Stop() - }, time.Minute, time.Second).Should(Succeed()) -}) - -// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. -// ENVTEST-based tests depend on specific binaries, usually located in paths set by -// controller-runtime. When running tests directly (e.g., via an IDE) without using -// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. -// -// This function streamlines the process by finding the required binaries, similar to -// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are -// properly set up, run 'make setup-envtest' beforehand. -func getFirstFoundEnvTestBinaryDir() string { - basePath := filepath.Join("..", "..", "..", "bin", "k8s") - entries, err := os.ReadDir(basePath) - if err != nil { - logf.Log.Error(err, "Failed to read directory", "path", basePath) - return "" - } - for _, entry := range entries { - if entry.IsDir() { - return filepath.Join(basePath, entry.Name()) - } - } - return "" -} diff --git a/examples/trino-operator/test/e2e/e2e_suite_test.go b/examples/trino-operator/test/e2e/e2e_suite_test.go deleted file mode 100644 index aba466ae..00000000 --- a/examples/trino-operator/test/e2e/e2e_suite_test.go +++ /dev/null @@ -1,101 +0,0 @@ -//go:build e2e -// +build e2e - -/* -Copyright 2026 ZNCDataDev. - -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 e2e - -import ( - "fmt" - "os" - "os/exec" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/zncdatadev/operator-go/examples/trino-operator/test/utils" -) - -var ( - // managerImage is the manager image to be built and loaded for testing. - managerImage = "example.com/trino-operator:v0.0.1" - // shouldCleanupCertManager tracks whether CertManager was installed by this suite. - shouldCleanupCertManager = false -) - -// TestE2E runs the e2e test suite to validate the solution in an isolated environment. -// The default setup requires Kind and CertManager. -// -// To skip CertManager installation, set: CERT_MANAGER_INSTALL_SKIP=true -func TestE2E(t *testing.T) { - RegisterFailHandler(Fail) - _, _ = fmt.Fprintf(GinkgoWriter, "Starting trino-operator e2e test suite\n") - RunSpecs(t, "e2e suite") -} - -var _ = BeforeSuite(func() { - By("building the manager image") - cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", managerImage)) - _, err := utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager image") - - // TODO(user): If you want to change the e2e test vendor from Kind, - // ensure the image is built and available, then remove the following block. - By("loading the manager image on Kind") - err = utils.LoadImageToKindClusterWithName(managerImage) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager image into Kind") - - setupCertManager() -}) - -var _ = AfterSuite(func() { - teardownCertManager() -}) - -// setupCertManager installs CertManager if needed for webhook tests. -// Skips installation if CERT_MANAGER_INSTALL_SKIP=true or if already present. -func setupCertManager() { - if os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" { - _, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager installation (CERT_MANAGER_INSTALL_SKIP=true)\n") - return - } - - By("checking if CertManager is already installed") - if utils.IsCertManagerCRDsInstalled() { - _, _ = fmt.Fprintf(GinkgoWriter, "CertManager is already installed. Skipping installation.\n") - return - } - - // Mark for cleanup before installation to handle interruptions and partial installs. - shouldCleanupCertManager = true - - By("installing CertManager") - Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") -} - -// teardownCertManager uninstalls CertManager if it was installed by setupCertManager. -// This ensures we only remove what we installed. -func teardownCertManager() { - if !shouldCleanupCertManager { - _, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager cleanup (not installed by this suite)\n") - return - } - - By("uninstalling CertManager") - utils.UninstallCertManager() -} diff --git a/examples/trino-operator/test/e2e/e2e_test.go b/examples/trino-operator/test/e2e/e2e_test.go deleted file mode 100644 index f3eaccff..00000000 --- a/examples/trino-operator/test/e2e/e2e_test.go +++ /dev/null @@ -1,413 +0,0 @@ -//go:build e2e -// +build e2e - -/* -Copyright 2026 ZNCDataDev. - -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 e2e - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/zncdatadev/operator-go/examples/trino-operator/test/utils" -) - -// namespace where the project is deployed in -const namespace = "trino-operator-system" - -// serviceAccountName created for the project -const serviceAccountName = "trino-operator-controller-manager" - -// metricsServiceName is the name of the metrics service of the project -const metricsServiceName = "trino-operator-controller-manager-metrics-service" - -// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data -const metricsRoleBindingName = "trino-operator-metrics-binding" - -var _ = Describe("Manager", Ordered, func() { - var controllerPodName string - - // Before running the tests, set up the environment by creating the namespace, - // enforce the restricted security policy to the namespace, installing CRDs, - // and deploying the controller. - BeforeAll(func() { - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) - _, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") - - By("labeling the namespace to enforce the restricted security policy") - cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, - "pod-security.kubernetes.io/enforce=restricted") - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") - - By("installing CRDs") - cmd = exec.Command("make", "install") - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") - - By("deploying the controller-manager") - cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage)) - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") - }) - - // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, - // and deleting the namespace. - AfterAll(func() { - By("cleaning up the curl pod for metrics") - cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) - _, _ = utils.Run(cmd) - - By("undeploying the controller-manager") - cmd = exec.Command("make", "undeploy") - _, _ = utils.Run(cmd) - - By("uninstalling CRDs") - cmd = exec.Command("make", "uninstall") - _, _ = utils.Run(cmd) - - By("removing manager namespace") - cmd = exec.Command("kubectl", "delete", "ns", namespace) - _, _ = utils.Run(cmd) - }) - - // After each test, check for failures and collect logs, events, - // and pod descriptions for debugging. - AfterEach(func() { - specReport := CurrentSpecReport() - if specReport.Failed() { - By("Fetching controller manager pod logs") - cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) - controllerLogs, err := utils.Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) - } - - By("Fetching Kubernetes events") - cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") - eventsOutput, err := utils.Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) - } - - By("Fetching curl-metrics logs") - cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) - metricsOutput, err := utils.Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) - } - - By("Fetching controller manager pod description") - cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) - podDescription, err := utils.Run(cmd) - if err == nil { - fmt.Println("Pod description:\n", podDescription) - } else { - fmt.Println("Failed to describe controller pod") - } - } - }) - - SetDefaultEventuallyTimeout(2 * time.Minute) - SetDefaultEventuallyPollingInterval(time.Second) - - Context("Manager", func() { - It("should run successfully", func() { - By("validating that the controller-manager pod is running as expected") - verifyControllerUp := func(g Gomega) { - // Get the name of the controller-manager pod - cmd := exec.Command("kubectl", "get", - "pods", "-l", "control-plane=controller-manager", - "-o", "go-template={{ range .items }}"+ - "{{ if not .metadata.deletionTimestamp }}"+ - "{{ .metadata.name }}"+ - "{{ \"\\n\" }}{{ end }}{{ end }}", - "-n", namespace, - ) - - podOutput, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") - podNames := utils.GetNonEmptyLines(podOutput) - g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") - controllerPodName = podNames[0] - g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) - - // Validate the pod's status - cmd = exec.Command("kubectl", "get", - "pods", controllerPodName, "-o", "jsonpath={.status.phase}", - "-n", namespace, - ) - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") - } - Eventually(verifyControllerUp).Should(Succeed()) - }) - - It("should ensure the metrics endpoint is serving metrics", func() { - By("creating a ClusterRoleBinding for the service account to allow access to metrics") - cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, - "--clusterrole=trino-operator-metrics-reader", - fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), - ) - _, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") - - By("validating that the metrics service is available") - cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") - - By("getting the service account token") - token, err := serviceAccountToken() - Expect(err).NotTo(HaveOccurred()) - Expect(token).NotTo(BeEmpty()) - - By("ensuring the controller pod is ready") - verifyControllerPodReady := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace, - "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("True"), "Controller pod not ready") - } - Eventually(verifyControllerPodReady, 3*time.Minute, time.Second).Should(Succeed()) - - By("verifying that the controller manager is serving the metrics server") - verifyMetricsServerStarted := func(g Gomega) { - cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(ContainSubstring("Serving metrics server"), - "Metrics server not yet started") - } - Eventually(verifyMetricsServerStarted, 3*time.Minute, time.Second).Should(Succeed()) - - By("waiting for the webhook service endpoints to be ready") - verifyWebhookEndpointsReady := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "endpointslices.discovery.k8s.io", "-n", namespace, - "-l", "kubernetes.io/service-name=trino-operator-webhook-service", - "-o", "jsonpath={range .items[*]}{range .endpoints[*]}{.addresses[*]}{end}{end}") - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "Webhook endpoints should exist") - g.Expect(output).ShouldNot(BeEmpty(), "Webhook endpoints not yet ready") - } - Eventually(verifyWebhookEndpointsReady, 3*time.Minute, time.Second).Should(Succeed()) - - By("verifying the mutating webhook server is ready") - verifyMutatingWebhookReady := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "mutatingwebhookconfigurations.admissionregistration.k8s.io", - "trino-operator-mutating-webhook-configuration", - "-o", "jsonpath={.webhooks[0].clientConfig.caBundle}") - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "MutatingWebhookConfiguration should exist") - g.Expect(output).ShouldNot(BeEmpty(), "Mutating webhook CA bundle not yet injected") - } - Eventually(verifyMutatingWebhookReady, 3*time.Minute, time.Second).Should(Succeed()) - - By("verifying the validating webhook server is ready") - verifyValidatingWebhookReady := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "validatingwebhookconfigurations.admissionregistration.k8s.io", - "trino-operator-validating-webhook-configuration", - "-o", "jsonpath={.webhooks[0].clientConfig.caBundle}") - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "ValidatingWebhookConfiguration should exist") - g.Expect(output).ShouldNot(BeEmpty(), "Validating webhook CA bundle not yet injected") - } - Eventually(verifyValidatingWebhookReady, 3*time.Minute, time.Second).Should(Succeed()) - - By("waiting additional time for webhook server to stabilize") - time.Sleep(5 * time.Second) - - // +kubebuilder:scaffold:e2e-metrics-webhooks-readiness - - By("creating the curl-metrics pod to access the metrics endpoint") - cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", - "--namespace", namespace, - "--image=curlimages/curl:latest", - "--overrides", - fmt.Sprintf(`{ - "spec": { - "containers": [{ - "name": "curl", - "image": "curlimages/curl:latest", - "command": ["/bin/sh", "-c"], - "args": [ - "for i in $(seq 1 30); do curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics && exit 0 || sleep 2; done; exit 1" - ], - "securityContext": { - "readOnlyRootFilesystem": true, - "allowPrivilegeEscalation": false, - "capabilities": { - "drop": ["ALL"] - }, - "runAsNonRoot": true, - "runAsUser": 1000, - "seccompProfile": { - "type": "RuntimeDefault" - } - } - }], - "serviceAccountName": "%s" - } - }`, token, metricsServiceName, namespace, serviceAccountName)) - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") - - By("waiting for the curl-metrics pod to complete.") - verifyCurlUp := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "pods", "curl-metrics", - "-o", "jsonpath={.status.phase}", - "-n", namespace) - output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") - } - Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) - - By("getting the metrics by checking curl-metrics logs") - verifyMetricsAvailable := func(g Gomega) { - metricsOutput, err := getMetricsOutput() - g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") - g.Expect(metricsOutput).NotTo(BeEmpty()) - g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) - } - Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed()) - }) - - It("should provisioned cert-manager", func() { - By("validating that cert-manager has the certificate Secret") - verifyCertManager := func(g Gomega) { - cmd := exec.Command("kubectl", "get", "secrets", "webhook-server-cert", "-n", namespace) - _, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - } - Eventually(verifyCertManager).Should(Succeed()) - }) - - It("should have CA injection for mutating webhooks", func() { - By("checking CA injection for mutating webhooks") - verifyCAInjection := func(g Gomega) { - cmd := exec.Command("kubectl", "get", - "mutatingwebhookconfigurations.admissionregistration.k8s.io", - "trino-operator-mutating-webhook-configuration", - "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") - mwhOutput, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(len(mwhOutput)).To(BeNumerically(">", 10)) - } - Eventually(verifyCAInjection).Should(Succeed()) - }) - - It("should have CA injection for validating webhooks", func() { - By("checking CA injection for validating webhooks") - verifyCAInjection := func(g Gomega) { - cmd := exec.Command("kubectl", "get", - "validatingwebhookconfigurations.admissionregistration.k8s.io", - "trino-operator-validating-webhook-configuration", - "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") - vwhOutput, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(len(vwhOutput)).To(BeNumerically(">", 10)) - } - Eventually(verifyCAInjection).Should(Succeed()) - }) - - // +kubebuilder:scaffold:e2e-webhooks-checks - - // TODO: Customize the e2e test suite with scenarios specific to your project. - // Consider applying sample/CR(s) and check their status and/or verifying - // the reconciliation by using the metrics, i.e.: - // metricsOutput, err := getMetricsOutput() - // Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") - // Expect(metricsOutput).To(ContainSubstring( - // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, - // strings.ToLower(), - // )) - }) -}) - -// serviceAccountToken returns a token for the specified service account in the given namespace. -// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request -// and parsing the resulting token from the API response. -func serviceAccountToken() (string, error) { - const tokenRequestRawString = `{ - "apiVersion": "authentication.k8s.io/v1", - "kind": "TokenRequest" - }` - - // Temporary file to store the token request - secretName := fmt.Sprintf("%s-token-request", serviceAccountName) - tokenRequestFile := filepath.Join("/tmp", secretName) - err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)) - if err != nil { - return "", err - } - - var out string - verifyTokenCreation := func(g Gomega) { - // Execute kubectl command to create the token - cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf( - "/api/v1/namespaces/%s/serviceaccounts/%s/token", - namespace, - serviceAccountName, - ), "-f", tokenRequestFile) - - output, err := cmd.CombinedOutput() - g.Expect(err).NotTo(HaveOccurred()) - - // Parse the JSON output to extract the token - var token tokenRequest - err = json.Unmarshal(output, &token) - g.Expect(err).NotTo(HaveOccurred()) - - out = token.Status.Token - } - Eventually(verifyTokenCreation).Should(Succeed()) - - return out, err -} - -// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. -func getMetricsOutput() (string, error) { - By("getting the curl-metrics logs") - cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) - return utils.Run(cmd) -} - -// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, -// containing only the token field that we need to extract. -type tokenRequest struct { - Status struct { - Token string `json:"token"` - } `json:"status"` -} diff --git a/examples/trino-operator/test/runtime/logging-controller/main.go b/examples/trino-operator/test/runtime/logging-controller/main.go new file mode 100644 index 00000000..b0061d82 --- /dev/null +++ b/examples/trino-operator/test/runtime/logging-controller/main.go @@ -0,0 +1,143 @@ +// Command logging-controller validates the native Python logging adapter and +// central Vector discovery through formal generated registration. It is a +// bounded acceptance fixture, not the production Trino executable. +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + generated "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" + "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1/registration" + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + "github.com/zncdatadev/operator-go/pkg/framework" + nativelogging "github.com/zncdatadev/operator-go/pkg/framework/logging" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +const ( + processName = "python" + configDirectory = "config" + logsDirectory = "logs" +) + +const server = `import json,logging,logging.config +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from urllib.parse import urlparse,parse_qs +logging.config.dictConfig(json.load(open('/config/logging.json'))) +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + marker=parse_qs(urlparse(self.path).query).get('marker',['ready'])[0] + logging.getLogger('product').debug(marker+'-debug') + logging.getLogger('product').warning(marker+'-warning') + self.send_response(200);self.end_headers();self.wfile.write(marker.encode()) + def log_message(self,*args): pass +ThreadingHTTPServer(('0.0.0.0',8080),Handler).serve_forever() +` + +func main() { + if err := run(os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + flags := flag.NewFlagSet("logging-controller", flag.ContinueOnError) + kubeconfig := flags.String("kubeconfig", "", "explicit experiment kubeconfig") + namespace := flags.String("namespace", "", "isolated watched namespace") + image := flags.String("image", "", "pinned image containing python3") + helper := flags.String("materializer-image", "", "built framework materializer image") + vector := flags.String("vector-image", "", "pinned Vector image") + if err := flags.Parse(args); err != nil { + return err + } + for _, value := range []*string{kubeconfig, namespace, image, helper, vector} { + if strings.TrimSpace(*value) == "" { + return fmt.Errorf("all five explicit experiment flags are required") + } + } + if flags.NArg() != 0 { + return fmt.Errorf("positional arguments are unsupported") + } + config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig) + if err != nil { + return err + } + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return err + } + if err := generated.AddToScheme(scheme); err != nil { + return err + } + ctrl.SetLogger(zap.New()) + manager, err := ctrl.NewManager(config, ctrl.Options{Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0", + Cache: cache.Options{DefaultNamespaces: map[string]cache.Config{*namespace: {}}}}) + if err != nil { + return err + } + if err := registration.Register(manager, definition(*image), registration.Options[struct{}]{ + FactRefreshInterval: time.Second, Assembly: framework.AssemblyOptions{ + MaterializerImage: *helper, VectorImage: *vector}, + }); err != nil { + return err + } + return manager.Start(ctrl.SetupSignalHandler()) +} + +func definition(image string) framework.ProductDefinition[product.TrinoConfig, product.TrinoClusterConfig, struct{}] { + defaults := framework.Config[product.TrinoConfig]{Common: framework.CommonConfig{ + Resources: framework.Resources{CPU: framework.CPU{Min: resource.MustParse("50m"), Max: resource.MustParse("500m")}, + Memory: framework.Memory{Limit: resource.MustParse("128Mi")}}, + Logging: framework.Logging{EnableVectorAgent: true, Containers: map[string]framework.ContainerLogging{ + processName: {Console: framework.Logger{Level: "WARN"}, File: framework.Logger{Level: "DEBUG"}, + Loggers: map[string]framework.Logger{"ROOT": {Level: "INFO"}, "product": {Level: "DEBUG"}}}}}, + }} + return framework.ProductDefinition[product.TrinoConfig, product.TrinoClusterConfig, struct{}]{ + Name: "logging-experiment", ImageDefaults: framework.ImageConfig{Custom: image, PullPolicy: corev1.PullIfNotPresent}, + Roles: map[string]framework.RoleDefinition[product.TrinoConfig]{ + "workers": {Config: defaults}, "coordinators": {Config: defaults}, + }, + GenerateGroup: func(in framework.EffectiveInput[product.TrinoConfig, product.TrinoClusterConfig, struct{}]) ( + framework.RuntimeDescription, error, + ) { + logging, exists := in.Config.Common.Logging.Containers[processName] + if !exists || len(in.Config.Common.Logging.Containers) != 1 { + return framework.RuntimeDescription{}, fmt.Errorf("logging experiment consumes exactly the python container") + } + content, err := nativelogging.Python(logging, "/logs/server.log") + if err != nil { + return framework.RuntimeDescription{}, err + } + uid := int64(1000) + out := framework.RuntimeDescription{ConfigDirectory: configDirectory, SharedGroup: &uid, + Main: framework.Process{Name: processName, Command: []string{"python3"}, Args: []string{"-u", "-c", server}, + Access: []framework.DirectoryAccess{{Directory: configDirectory, MountPath: "/config", ReadOnly: true}, + {Directory: logsDirectory, MountPath: "/logs"}}}, + Directories: []framework.Directory{{Name: configDirectory}, {Name: logsDirectory}}, + Files: []framework.File{{Directory: configDirectory, Path: "logging.json", Content: content}}, + Endpoints: []framework.Endpoint{{Name: "http", Port: 8080}}, + } + if logging.File.Level != "OFF" { + out.LogOutputs = []framework.LogOutput{ + {Container: processName, Directory: logsDirectory, RelativePath: "server.log"}, + } + } + return out, nil + }, + } +} diff --git a/examples/trino-operator/test/runtime/storage-controller/main.go b/examples/trino-operator/test/runtime/storage-controller/main.go new file mode 100644 index 00000000..e7e3dd6b --- /dev/null +++ b/examples/trino-operator/test/runtime/storage-controller/main.go @@ -0,0 +1,201 @@ +// Command storage-controller runs the retained-directory acceptance fixture through +// formal generated registration. Its process reads marker data; it is not Trino. +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + generatedtrino "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1" + "github.com/zncdatadev/operator-go/examples/trino-operator/api/v1alpha1/registration" + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + api "github.com/zncdatadev/operator-go/pkg/framework" +) + +const ( + workerRole = "workers" + defaultGroup = "default" + processName = "trino" + dataSlot = "data" + dataPath = "/data" + httpPort = int32(8080) +) + +// No path in this program creates or modifies the marker. The harness writes +// and fsyncs it separately, after observing the controller's binding receipt. +const markerServer = `from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +MARKER_PATH = Path("/data/marker.json") +MAX_MARKER_BYTES = 65536 + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/healthz": + self.reply(200, b"ready\n", "text/plain") + return + if self.path != "/marker": + self.reply(404, b"not found\n", "text/plain") + return + try: + with MARKER_PATH.open("rb") as source: + content = source.read(MAX_MARKER_BYTES + 1) + except FileNotFoundError: + self.reply(404, b"marker absent\n", "text/plain") + return + except OSError: + self.reply(500, b"marker unreadable\n", "text/plain") + return + if len(content) > MAX_MARKER_BYTES: + self.reply(413, b"marker too large\n", "text/plain") + return + self.reply(200, content, "application/json") + + def reply(self, status, content, content_type): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + +if __name__ == "__main__": + server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler) + print("STORAGE_MARKER_SERVER_READY port=8080 marker=/data/marker.json", flush=True) + server.serve_forever() +` + +type options struct { + kubeconfig, namespace, image, storageClass string + capacity resource.Quantity +} + +func main() { + if err := run(os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) { + _, _ = fmt.Fprintln(os.Stderr, "storage-controller:", err) + os.Exit(1) + } +} + +func parseOptions(args []string) (options, error) { + var out options + flags := flag.NewFlagSet("storage-controller", flag.ContinueOnError) + flags.StringVar(&out.kubeconfig, "kubeconfig", "", "explicit kubeconfig file (required)") + flags.StringVar(&out.namespace, "namespace", "", "namespace to watch (required)") + flags.StringVar(&out.image, "image", "", "fixed experiment image containing python3 (required)") + flags.StringVar(&out.storageClass, "storage-class", "", "explicit retained storage class (required)") + capacity := flags.String("capacity", "64Mi", "positive data claim capacity") + if err := flags.Parse(args); err != nil { + return out, err + } + for _, value := range []string{out.kubeconfig, out.namespace, out.image, out.storageClass} { + if strings.TrimSpace(value) == "" { + return out, fmt.Errorf("--kubeconfig, --namespace, --image and --storage-class are required") + } + } + if flags.NArg() != 0 { + return out, fmt.Errorf("no positional arguments are accepted") + } + if len(validation.IsDNS1123Label(out.namespace)) != 0 || + len(validation.IsDNS1123Subdomain(out.storageClass)) != 0 { + return out, fmt.Errorf("namespace or storage class name is invalid") + } + var err error + out.capacity, err = resource.ParseQuantity(*capacity) + if err != nil || out.capacity.Sign() <= 0 { + return out, fmt.Errorf("--capacity must be a positive Kubernetes quantity") + } + return out, nil +} + +func run(args []string) error { + settings, err := parseOptions(args) + if err != nil { + return err + } + configuration, err := clientcmd.BuildConfigFromFlags("", settings.kubeconfig) + if err != nil { + return err + } + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, storagev1.AddToScheme, generatedtrino.AddToScheme, + } { + if err := add(scheme); err != nil { + return err + } + } + ctrl.SetLogger(zap.New()) + manager, err := ctrl.NewManager(configuration, ctrl.Options{Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0", + Cache: cache.Options{DefaultNamespaces: map[string]cache.Config{settings.namespace: {}}}, + }) + if err != nil { + return err + } + if err := registration.Register(manager, storageDefinition(settings.image, settings.storageClass, settings.capacity), + registration.Options[product.TrinoFacts]{}); err != nil { + return err + } + return manager.Start(ctrl.SetupSignalHandler()) +} + +func storageDefinition( + image, class string, capacity resource.Quantity, +) api.ProductDefinition[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts] { + defaults := api.Config[product.TrinoConfig]{Common: api.CommonConfig{ + Resources: api.Resources{CPU: api.CPU{Min: resource.MustParse("50m"), Max: resource.MustParse("500m")}, + Memory: api.Memory{Limit: resource.MustParse("128Mi")}, + Storage: api.Storage{Type: api.StoragePersistent, StorageClassName: class, Capacity: capacity.DeepCopy()}}, + Logging: api.Logging{Containers: map[string]api.ContainerLogging{}}, + }, Product: product.TrinoConfig{HTTPPort: httpPort}} + return api.ProductDefinition[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts]{ + ImageDefaults: api.ImageConfig{Custom: image, PullPolicy: corev1.PullIfNotPresent}, + Name: "storage-experiment", + Roles: map[string]api.RoleDefinition[product.TrinoConfig]{ + "coordinators": {Config: defaults}, workerRole: {Config: defaults}, + }, + ValidateInput: func( + in api.EffectiveInput[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts], + ) error { + if in.ClusterConfig != (product.TrinoClusterConfig{}) { + return fmt.Errorf("storage experiment does not consume Trino cluster configuration") + } + if in.Group.Role != workerRole || in.Group.Name != defaultGroup || in.Group.Replicas > 1 { + return fmt.Errorf("storage experiment supports only workers/default with zero or one replica") + } + if in.Config.Product.HTTPPort != httpPort || in.Config.Common.Logging.EnableVectorAgent || + len(in.Config.Common.Logging.Containers) != 0 { + return fmt.Errorf("storage experiment requires HTTP 8080 with Vector and product logging disabled") + } + return nil + }, + GenerateGroup: func( + in api.EffectiveInput[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts], + ) (api.RuntimeDescription, error) { + uid, nonRoot := int64(1000), true + return api.RuntimeDescription{ + Main: api.Process{Name: processName, + Command: []string{"python3"}, Args: []string{"-u", "-c", markerServer}, + Identity: &corev1.SecurityContext{RunAsUser: &uid, RunAsGroup: &uid, RunAsNonRoot: &nonRoot}, + Access: []api.DirectoryAccess{{Directory: dataSlot, MountPath: dataPath}}}, + Directories: []api.Directory{{Name: dataSlot, Data: true}}, + SharedGroup: &uid, Endpoints: []api.Endpoint{{Name: "http", Port: httpPort}}, + }, nil + }, + } +} diff --git a/examples/trino-operator/test/runtime/storage-controller/main_test.go b/examples/trino-operator/test/runtime/storage-controller/main_test.go new file mode 100644 index 00000000..9c55d31b --- /dev/null +++ b/examples/trino-operator/test/runtime/storage-controller/main_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/zncdatadev/operator-go/examples/trino-operator/internal/product" + api "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestStorageOptionsRequireExplicitScope(t *testing.T) { + base := []string{"--kubeconfig=/not-read", "--namespace=experiment", "--image=fixed-image", "--storage-class=retained"} + settings, err := parseOptions(base) + if err != nil || settings.capacity.Cmp(resource.MustParse("64Mi")) != 0 { + t.Fatalf("unexpected defaults: %+v, %v", settings, err) + } + for _, extra := range []string{"--capacity=0", "--capacity=-1Gi", "--capacity=nope", "--namespace=", "positional"} { + if _, err := parseOptions(append(append([]string{}, base...), extra)); err == nil { + t.Fatalf("accepted %q", extra) + } + } + if _, err := parseOptions(nil); err == nil { + t.Fatal("accepted implicit cluster scope") + } +} + +func TestStorageDefinitionDeclaresOnlyRetainedMarkerProcess(t *testing.T) { + definition := storageDefinition("fixed-image", "retained", resource.MustParse("64Mi")) + in := api.EffectiveInput[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts]{ + Group: api.GroupIdentity{Role: workerRole, Name: defaultGroup, Replicas: 1}, + Config: definition.Roles[workerRole].Config, + } + if err := definition.ValidateInput(in); err != nil { + t.Fatal(err) + } + run, err := definition.GenerateGroup(in) + if err != nil { + t.Fatal(err) + } + if len(run.Files) != 0 || len(run.LogOutputs) != 0 || len(run.Directories) != 1 || !run.Directories[0].Data { + t.Fatal("marker fixture must declare only one retained data slot") + } + main := run.Main + if main.Name != processName || main.Command[0] != "python3" || main.Args[2] != markerServer || + *main.Identity.RunAsUser != 1000 || *main.Identity.RunAsGroup != 1000 || *run.SharedGroup != 1000 || + len(main.Access) != 1 || main.Access[0].Directory != dataSlot || main.Access[0].MountPath != dataPath { + t.Fatalf("unexpected marker process: %+v", main) + } + if in.Config.Common.Resources.Storage.StorageClassName != "retained" || + in.Config.Common.Resources.Storage.Capacity.Cmp(resource.MustParse("64Mi")) != 0 { + t.Fatal("retained source request changed") + } +} + +func TestMarkerServerReadsWithoutInitializingData(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is unavailable; runtime evidence must still exercise the image") + } + const verify = `import http.client, os, pathlib, sys, threading +scope = {"__name__": "fixture_test"} +exec(compile(sys.stdin.read(), "marker_server", "exec"), scope) +marker = pathlib.Path(sys.argv[1]) / "marker.json" +scope["MARKER_PATH"] = marker +server = scope["ThreadingHTTPServer"](("127.0.0.1", 0), scope["Handler"]) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +def request(method, path): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=3) + try: + connection.request(method, path) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() +try: + assert request("GET", "/healthz") == (200, b"ready\n") + assert request("GET", "/marker")[0] == 404 + assert request("POST", "/marker")[0] == 501 + assert not marker.exists(), "server initialized missing data" + content = b'{"nonce":"test-only-existing-content"}\n' + with marker.open("wb") as output: + output.write(content) + output.flush() + os.fsync(output.fileno()) + before = marker.stat() + assert request("GET", "/marker") == (200, content) + assert request("GET", "/unknown")[0] == 404 + after = marker.stat() + assert before.st_mtime_ns == after.st_mtime_ns and marker.read_bytes() == content +finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + assert not thread.is_alive() +` + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, python, "-c", verify, t.TempDir()) + command.Stdin = strings.NewReader(markerServer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("marker server verification failed: %v\n%s", err, output) + } +} + +func TestStorageDefinitionRejectsUnsupportedWorkloads(t *testing.T) { + definition := storageDefinition("fixed-image", "retained", resource.MustParse("64Mi")) + for _, scenario := range []string{"coordinator", "group", "replicas", "port", "logging", "cluster"} { + in := api.EffectiveInput[product.TrinoConfig, product.TrinoClusterConfig, product.TrinoFacts]{ + Group: api.GroupIdentity{Role: workerRole, Name: defaultGroup, Replicas: 1}, + Config: definition.Roles[workerRole].Config, + } + switch scenario { + case "coordinator": + in.Group.Role = "coordinators" + case "group": + in.Group.Name = "other" + case "replicas": + in.Group.Replicas = 2 + case "port": + in.Config.Product.HTTPPort = 9090 + case "logging": + in.Config.Common.Logging.EnableVectorAgent = true + case "cluster": + in.ClusterConfig.NodeEnvironment = "production" + } + if err := definition.ValidateInput(in); err == nil { + t.Fatalf("accepted unsupported %s", scenario) + } + } +} diff --git a/examples/trino-operator/test/utils/utils.go b/examples/trino-operator/test/utils/utils.go deleted file mode 100644 index 2099201d..00000000 --- a/examples/trino-operator/test/utils/utils.go +++ /dev/null @@ -1,226 +0,0 @@ -/* -Copyright 2026 ZNCDataDev. - -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 utils - -import ( - "bufio" - "bytes" - "fmt" - "os" - "os/exec" - "strings" - - . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck -) - -const ( - certmanagerVersion = "v1.19.3" - certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" - - defaultKindBinary = "kind" - defaultKindCluster = "kind" -) - -func warnError(err error) { - _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) -} - -// Run executes the provided command within this context -func Run(cmd *exec.Cmd) (string, error) { - dir, _ := GetProjectDir() - cmd.Dir = dir - - if err := os.Chdir(cmd.Dir); err != nil { - _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) - } - - cmd.Env = append(os.Environ(), "GO111MODULE=on") - command := strings.Join(cmd.Args, " ") - _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) - } - - return string(output), nil -} - -// UninstallCertManager uninstalls the cert manager -func UninstallCertManager() { - url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) - cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { - warnError(err) - } - - // Delete leftover leases in kube-system (not cleaned by default) - kubeSystemLeases := []string{ - "cert-manager-cainjector-leader-election", - "cert-manager-controller", - } - for _, lease := range kubeSystemLeases { - cmd = exec.Command("kubectl", "delete", "lease", lease, - "-n", "kube-system", "--ignore-not-found", "--force", "--grace-period=0") - if _, err := Run(cmd); err != nil { - warnError(err) - } - } -} - -// InstallCertManager installs the cert manager bundle. -func InstallCertManager() error { - url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) - cmd := exec.Command("kubectl", "apply", "-f", url) - if _, err := Run(cmd); err != nil { - return err - } - // Wait for cert-manager-webhook to be ready, which can take time if cert-manager - // was re-installed after uninstalling on a cluster. - cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", - "--for", "condition=Available", - "--namespace", "cert-manager", - "--timeout", "5m", - ) - - _, err := Run(cmd) - return err -} - -// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed -// by verifying the existence of key CRDs related to Cert Manager. -func IsCertManagerCRDsInstalled() bool { - // List of common Cert Manager CRDs - certManagerCRDs := []string{ - "certificates.cert-manager.io", - "issuers.cert-manager.io", - "clusterissuers.cert-manager.io", - "certificaterequests.cert-manager.io", - "orders.acme.cert-manager.io", - "challenges.acme.cert-manager.io", - } - - // Execute the kubectl command to get all CRDs - cmd := exec.Command("kubectl", "get", "crds") - output, err := Run(cmd) - if err != nil { - return false - } - - // Check if any of the Cert Manager CRDs are present - crdList := GetNonEmptyLines(output) - for _, crd := range certManagerCRDs { - for _, line := range crdList { - if strings.Contains(line, crd) { - return true - } - } - } - - return false -} - -// LoadImageToKindClusterWithName loads a local docker image to the kind cluster -func LoadImageToKindClusterWithName(name string) error { - cluster := defaultKindCluster - if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { - cluster = v - } - kindOptions := []string{"load", "docker-image", name, "--name", cluster} - kindBinary := defaultKindBinary - if v, ok := os.LookupEnv("KIND"); ok { - kindBinary = v - } - cmd := exec.Command(kindBinary, kindOptions...) - _, err := Run(cmd) - return err -} - -// GetNonEmptyLines converts given command output string into individual objects -// according to line breakers, and ignores the empty elements in it. -func GetNonEmptyLines(output string) []string { - var res []string - elements := strings.SplitSeq(output, "\n") - for element := range elements { - if element != "" { - res = append(res, element) - } - } - - return res -} - -// GetProjectDir will return the directory where the project is -func GetProjectDir() (string, error) { - wd, err := os.Getwd() - if err != nil { - return wd, fmt.Errorf("failed to get current working directory: %w", err) - } - wd = strings.ReplaceAll(wd, "/test/e2e", "") - return wd, nil -} - -// UncommentCode searches for target in the file and remove the comment prefix -// of the target content. The target content may span multiple lines. -func UncommentCode(filename, target, prefix string) error { - // false positive - // nolint:gosec - content, err := os.ReadFile(filename) - if err != nil { - return fmt.Errorf("failed to read file %q: %w", filename, err) - } - strContent := string(content) - - idx := strings.Index(strContent, target) - if idx < 0 { - return fmt.Errorf("unable to find the code %q to be uncommented", target) - } - - out := new(bytes.Buffer) - _, err = out.Write(content[:idx]) - if err != nil { - return fmt.Errorf("failed to write to output: %w", err) - } - - scanner := bufio.NewScanner(bytes.NewBufferString(target)) - if !scanner.Scan() { - return nil - } - for { - if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { - return fmt.Errorf("failed to write to output: %w", err) - } - // Avoid writing a newline in case the previous line was the last in target. - if !scanner.Scan() { - break - } - if _, err = out.WriteString("\n"); err != nil { - return fmt.Errorf("failed to write to output: %w", err) - } - } - - if _, err = out.Write(content[idx+len(target):]); err != nil { - return fmt.Errorf("failed to write to output: %w", err) - } - - // false positive - // nolint:gosec - if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { - return fmt.Errorf("failed to write file %q: %w", filename, err) - } - - return nil -} diff --git a/go.mod b/go.mod index 4bd28428..867d2444 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( k8s.io/client-go v0.35.4 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 sigs.k8s.io/yaml v1.6.0 ) @@ -72,7 +73,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // 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.3.2-0.20260122202528-d9cc6641c482 // indirect ) diff --git a/hack/framework-e2e/.gitignore b/hack/framework-e2e/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/hack/framework-e2e/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/hack/framework-e2e/README.md b/hack/framework-e2e/README.md new file mode 100644 index 00000000..d32d888c --- /dev/null +++ b/hack/framework-e2e/README.md @@ -0,0 +1,70 @@ +# Formal framework runtime acceptance + +This harness builds the actual Trino reference operator and the SDK materializer, installs the example's generated CRD and deployment RBAC, and exercises them in a new disposable kind cluster. Historical discussion prototypes are local records, not runtime dependencies. + +Run from the repository root on an arm64 Docker host with Go 1.25, Python 3, Docker, kind, kubectl and kustomize on PATH. Allow about 8 GiB of Docker memory. The workload images and Kubernetes node image are fixed by digest in `run.py`; amd64 is not covered by this fixture. Supply a clean commons-operator checkout; this acceptance baseline uses `6e65371`. The harness records the supplied revision and builds its unmodified restarter. + +```sh +make lint +make test GOTESTFLAGS='-p=2' +make verify-generate +make -C examples/trino-operator test lint build +python3 -B -m unittest discover -s hack/framework-e2e -p 'test_*.py' +python3 -B hack/framework-e2e/run.py \ + --commons ../commons-operator \ + --output .local/engineering-notes/runs/framework-acceptance-01 +``` + +Use a new empty output directory for every run. Store local reports under the Git-ignored `.local/engineering-notes/runs/`; never commit generated runtime evidence. The runner cross-compiles the operator and materializer to Linux arm64, builds unique local images, compiles the host restarter and retained-volume fixture, and loads the images into its own kind cluster. It uses an explicit private kubeconfig for every cluster operation. It compares the default kubeconfig hashes and existing kind node identities/states before and after; it reaps its own restarter and removes its own cluster and temporary image tags on success or failure. + +Acceptance includes: + +- Generated registration and the actual executable running under delivered RBAC. +- Native Trino node IDs equal to the current Pod UIDs, and TPCH query results requiring the worker. +- File, env, CLI and Pod updates; external catalog refresh without editing the CR; commons restarter stamps and delivered file bytes. +- Native JSON file events appearing in Vector stdout, pause/resume, stopped workloads, worker retirement, operator restart and re-addition. +- A stable final observation across a full facts-refresh interval, including CR/resource versions and restart counts. +- A separate Retain marker fixture using public registration and standard CR storage input + (role class/type/32Mi, role-group capacity override): same live PVC/PV identities and marker bytes after retirement, operator restart and same-CR re-addition. + +`verification.json` must say both `passed: true` and `cleanup: true`. The evidence directory contains source and binary hashes, exact images, inputs, resource snapshots, SQL/log observations, all command receipts and cleanup records. The directory's `kubeconfig` is an ephemeral cluster credential: keep raw evidence local. CI artifact selection must explicitly omit kubeconfig and executables. A successful storage marker fixture is not Trino business-data recovery, and SQL probes after convergence do not promise uninterrupted queries during rollout. + +## Delivery versions + +Build the SDK, generator, generated API/registration, operator and materializer from the same reviewed source revision. The checked-in generated API and registration each declare input contract `1`; registration and `generate -check` reject a mismatched contract. Re-run generation and commit all generated files when C/S or generator output changes. + +The materialization plan is `v1` and its runtime properties codec is `properties-v1`; the helper rejects unknown plan/codec versions. These are internal delivery contracts, not product-author APIs. The materializer image labels record those versions and the build's source revision. A matching label alone does not replace the execution tests. + +```sh +make materializer-image MATERIALIZER_IMG=registry.example/team/materializer: +make -C examples/trino-operator docker-build IMG=registry.example/team/trino-operator: +make -C examples/trino-operator build-installer IMG=registry.example/team/trino-operator: +``` + +Set the installer materializer and Vector references to the tested immutable image digests before distributing it. Image builds above do not publish anything. The local development tags in the sample deployment are build inputs, not evidence that a public registry release exists. Remote registry publication and release tagging are separate release operations. + + +## E01–E05 integrated domain acceptance + +The same runner now includes the implemented domain expansion. It builds the +unchanged local secret-operator and listener-operator manager/CSI executables, +records their source hashes, packages a unique platform image and pins the native +CSI sidecars to observed immutable digests. Those components run only in the +runner's disposable kind cluster. Platform source repositories must have clean +build inputs; `.worktree/` is excluded from unrelated untracked-file checks. + +Additional stages verify actual typed initialization and active-query worker +shutdown; PASSWORD SQL over verified TLS and native Secret refresh; AutoTLS CSI +mounts and observed Listener discovery; Hive/MinIO S3 writes/reads through inline +and reference connection branches; approved data adoption/migration/destruction +and provisioner backend reclamation; and native Python log delivery to a Vector +receiver with destination refresh. Product/operator code uses formal registration. +The separate lightweight logging/storage processes are explicit framework +fixtures and are not substituted for the Trino authentication, S3 or lifecycle +processes. + +Data experiments create their own namespace and new marker files. No preexisting +business volume is an input. Failed attempts keep diagnostics and are marked +failed; a later complete run has a separate output directory. Raw evidence may +contain temporary credentials and private kubeconfig, so only inspected receipts +may be published or checked in. Do not include executables or TLS private keys. diff --git a/hack/framework-e2e/authentication.py b/hack/framework-e2e/authentication.py new file mode 100644 index 00000000..a666514f --- /dev/null +++ b/hack/framework-e2e/authentication.py @@ -0,0 +1,141 @@ +"""Real PASSWORD authentication over verified TLS using the formal Trino operator.""" +import base64 +import copy +import hashlib +import json +import pathlib +import secrets +import time + +from runtime import RuntimeVerifier, current_condition, named, require + +CLIENT_USER = "framework-e02-user" + +QUERY = r'''import base64,json,pathlib,ssl,sys,time,urllib.request,urllib.error +mode=sys.argv[1];root=pathlib.Path('/kubedoop/auth-client') +username=(root/'username').read_text();password=(root/'password').read_text() +headers={'X-Trino-User':username} +if mode!='anonymous': + credential=username+':'+password+('incorrect' if mode=='wrong' else '') + headers['Authorization']='Basic '+base64.b64encode(credential.encode()).decode() +context=ssl.create_default_context(cafile=str(root/'ca.crt')) +base='http://127.0.0.1:8080' if mode=='http' else 'https://127.0.0.1:8443' +request=urllib.request.Request(base+'/v1/statement',data=b'SELECT current_user, (SELECT count(*) FROM tpch.tiny.nation)',headers=headers) +rows=[];query=None;deadline=time.monotonic()+90 +while True: + try: + with urllib.request.urlopen(request,context=context,timeout=10) as response:value=json.load(response) + except urllib.error.HTTPError as error: + print(json.dumps({'status':error.code,'mode':mode,'tls_verification':mode!='http'}));sys.exit(0) + query=value.get('id',query) + if 'error' in value: + print(json.dumps({'query_id':query,'error':value['error'],'mode':mode}));sys.exit(0) + rows.extend(value.get('data',[])) + if not value.get('nextUri'):break + if time.monotonic()>=deadline:raise TimeoutError('authenticated query timed out') + request=urllib.request.Request(value['nextUri'],headers=headers);time.sleep(.2) +print(json.dumps({'query_id':query,'rows':rows,'mode':mode,'tls_verification':True})) +''' + + +def password_database(password): + # Trino 476's native EncryptionUtil expects PBKDF2WithHmacSHA1 for this format. + salt = secrets.token_bytes(32) + iterations = 600000 + hashed = hashlib.pbkdf2_hmac("sha1", password.encode(), salt, iterations, dklen=32) + return f"{CLIENT_USER}:{iterations}:{salt.hex()}:{hashed.hex()}\n" + + +class AuthenticationVerifier(RuntimeVerifier): + def persist(self): + self.run.write("authentication.json", self.report) + + def secret(self, name, data): + self.run.apply({"apiVersion": "v1", "kind": "Secret", "metadata": {"name": name, "namespace": self.namespace}, + "type": "Opaque", "data": {key: base64.b64encode(value.encode() if isinstance(value, str) else value).decode() + for key, value in data.items()}}) + + def ready(self): + require(current_condition(self.cr(), "WorkloadsReady"), "authenticated workloads not ready") + result = {} + for role in ("coordinators", "workers"): + name = self.name + "-" + role + "-default-0" + pod = self.get("pod", name) + require(pod and not pod["metadata"].get("deletionTimestamp"), "Pod missing/terminating") + main = named(pod.get("status", {}).get("containerStatuses", []), "trino") + require(main and main.get("ready"), "Trino process is not ready") + result[role] = {"uid": pod["metadata"]["uid"], "container_id": main["containerID"]} + if role == "coordinators": + init = named(pod.get("status", {}).get("initContainerStatuses", []), "initialize-tls") + require(init and init.get("state", {}).get("terminated", {}).get("exitCode") == 0, + "TLS material assembly was not executed") + return result + + def authenticated(self): + self.ready() + result = self.exec("coordinators", QUERY, "valid") + self.report.setdefault("authentication_probes", []).append(result) + self.persist() + return result if result.get("rows") == [[CLIENT_USER, 25]] else None + + def verify(self): + original = copy.deepcopy(self.cr()["spec"]) + class_name = self.run.cluster + "-auth" + certificate = self.run.output / "e02-tls.crt" + key = self.run.output / "e02-tls.key" + created = [] + self.report["scope"] = "real platform AuthenticationClass, native PASSWORD provider, verified HTTPS SQL and credential Secret refresh" + try: + # Use the platform's actual CRD, not a look-alike test schema. + crd = pathlib.Path(self.run.args.commons).expanduser() / "config/crd/bases/authentication.kubedoop.dev_authenticationclasses.yaml" + self.run.kube("apply", "-f", crd) + self.run.wait_crd_established("authenticationclasses.authentication.kubedoop.dev") + self.run.command(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=localhost", + "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1", "-keyout", key, "-out", certificate]) + cert = certificate.read_bytes() + self.secret("e02-tls", {"tls.crt": cert, "tls.key": key.read_bytes()}); created.append("e02-tls") + key.unlink() + password = secrets.token_urlsafe(32) + self.secret("e02-users", {"password.db": password_database(password)}); created.append("e02-users") + self.secret("e02-internal", {"shared-secret": secrets.token_urlsafe(512)}); created.append("e02-internal") + self.secret("e02-client", {"username": CLIENT_USER, "password": password, "ca.crt": cert}); created.append("e02-client") + self.run.apply({"apiVersion": "authentication.kubedoop.dev/v1alpha1", "kind": "AuthenticationClass", + "metadata": {"name": class_name}, "spec": {"provider": {"static": {"userCredentialsSecret": {"name": "e02-users"}}}}}) + self.patch({"clusterConfig": {"authentication": [{"authenticationClass": class_name}], "tlsSecret": "e02-tls", + "internalSecret": "e02-internal"}, + "coordinators": {"podOverrides": {"spec": { + "containers": [{"name": "trino", "volumeMounts": [{"name": "auth-client", "mountPath": "/kubedoop/auth-client", "readOnly": True}]}], + "volumes": [{"name": "auth-client", "secret": {"secretName": "e02-client"}}]}}}}) + query = self.until("verified TLS and native PASSWORD query", self.authenticated, timeout=300) + before = self.ready() + self.phase("authenticated-native-sql", {"query": query, "pods": before, + "certificate_sha256": hashlib.sha256(cert).hexdigest(), "ca_verification": True}) + for mode in ("anonymous", "wrong", "http"): + result = self.exec("coordinators", QUERY, mode) + require(result.get("status") in (401, 403), "authentication/TLS prerequisite bypassed: " + repr(result)) + self.phase("rejected-" + mode, result) + generation = self.cr()["metadata"]["generation"] + password = secrets.token_urlsafe(32) + self.secret("e02-users", {"password.db": password_database(password)}) + self.secret("e02-client", {"username": CLIENT_USER, "password": password, "ca.crt": cert}) + def refreshed(): + result = self.authenticated() + current = self.ready() + require(current["coordinators"]["uid"] != before["coordinators"]["uid"], + "native credential revision has not reached a new coordinator") + return {"query": result, "pods": current} if result else None + after = self.until("Secret credential refresh without CR edit", refreshed, timeout=300) + require(self.cr()["metadata"]["generation"] == generation, "credential refresh required a CR edit") + self.phase("credential-secret-refresh", {"generation_unchanged": generation, **after}) + self.report["passed"] = True + finally: + self.kube("patch", "trinoclusters.trino.kubedoop.dev", self.name, "--type=json", "-p", + json.dumps([{"op": "replace", "path": "/spec", "value": original}])) + # Keep inputs present while the old authenticated Pods retire. + self.until("restore unauthenticated fixture", lambda: self.healthy(), timeout=300) + for name in created: + self.kube("delete", "secret", name, "--ignore-not-found") + self.run.kube("delete", "authenticationclass", class_name, "--ignore-not-found") + if key.exists(): + key.unlink() + self.persist() diff --git a/hack/framework-e2e/lifecycle.py b/hack/framework-e2e/lifecycle.py new file mode 100644 index 00000000..62586e87 --- /dev/null +++ b/hack/framework-e2e/lifecycle.py @@ -0,0 +1,228 @@ +"""Formal Trino lifecycle acceptance on run.py's explicitly disposable cluster. + +No alternate operator or product lifecycle implementation is used: this verifier +only changes CR intent and records the formal operator's/native kubelet's effects. +The caller owns the cluster, operator, materializer and commons restarter. +""" +import copy +import json +import pathlib +import subprocess +import time + +from runtime import RuntimeVerifier, current_condition, named, require + +ADMIN = "framework-e04-local-admin" + +QUERY = r'''import http.client,json,sys,time,urllib.request,urllib.error +headers={'X-Trino-User':'framework-e2e'} +r=urllib.request.Request('http://127.0.0.1:8080/v1/statement',data=sys.argv[1].encode(),headers=headers) +rows=[];query=None;deadline=time.monotonic()+120;attempt=0 +while True: + remaining=deadline-time.monotonic() + if remaining<=0:raise TimeoutError('original query did not complete within total deadline') + attempt+=1;started=time.monotonic() + print(json.dumps({'query_id':query,'request':r.full_url,'method':r.get_method(),'attempt':attempt}),flush=True) + try: + with urllib.request.urlopen(r,timeout=min(10,remaining)) as response:value=json.load(response) + except (OSError,urllib.error.URLError,http.client.HTTPException) as error: + print(json.dumps({'query_id':query,'transfer_error':type(error).__name__,'elapsed':time.monotonic()-started,'attempt':attempt}),flush=True) + # A repeated POST could submit another query. Only replay the same GET token + # for the known original query, within the unchanged overall deadline. + if query is None or r.get_method()!='GET':raise + if isinstance(error,urllib.error.HTTPError) and error.code not in (502,503,504):raise + time.sleep(.25);continue + if query is None: + query=value['id'];print(json.dumps({'query_id':query,'started':True}),flush=True) + if value.get('id')!=query:raise RuntimeError('response changed query identity') + print(json.dumps({'query_id':query,'state':value.get('stats',{}).get('state'),'elapsed':time.monotonic()-started,'attempt':attempt}),flush=True) + rows.extend(value.get('data',[])) + if 'error' in value: + print(json.dumps({'query_id':query,'error':value['error']}),flush=True);sys.exit(1) + if not value.get('nextUri'): + print(json.dumps({'query_id':query,'rows':rows,'complete':True}),flush=True);break + r=urllib.request.Request(value['nextUri'],headers=headers);time.sleep(.1) +''' +TASKS = r'''import json,sys,urllib.request +r=urllib.request.Request('http://127.0.0.1:8080/v1/query/'+sys.argv[1],headers={'X-Trino-User':'framework-e2e'}) +with urllib.request.urlopen(r,timeout=5) as response: value=json.load(response) +tasks=[] +def visit(stage): + for task in stage.get('tasks',[]): + s=task.get('taskStatus',{});tasks.append({k:s.get(k) for k in ('taskId','state','nodeId','self')}) + for child in stage.get('subStages',[]):visit(child) +visit(value.get('outputStage',{})) +print(json.dumps({'query_id':value.get('queryId'),'state':value.get('state'),'tasks':tasks})) +''' + + +def json_stream(path): + text = pathlib.Path(path).read_text() + decoder, offset, result = json.JSONDecoder(), 0, [] + while offset < len(text): + while offset < len(text) and text[offset].isspace(): + offset += 1 + if offset == len(text): + break + value, offset = decoder.raw_decode(text, offset) + result.append(value) + return result + + +class LifecycleVerifier(RuntimeVerifier): + def persist(self): + self.run.write("lifecycle.json", self.report) + + def ready_pods(self, workers=1): + cr = self.cr() + require(current_condition(cr, "WorkloadsReady"), "workloads not ready") + result = {} + for role, count in (("coordinators", 1), ("workers", workers)): + set_name = self.name + "-" + role + "-default" + sts = self.get("statefulset", set_name) + require(sts and sts["spec"]["replicas"] == count and + sts.get("status", {}).get("readyReplicas", 0) == count, "replicas not converged") + policy = json.loads(sts["metadata"].get("annotations", {}).get("framework.kubedoop.dev/workload-coordination", "null")) + require(policy and policy["shutdownPriority"] == (100 if role == "coordinators" else 0), + "controller coordination receipt missing or role shutdown priority lost") + require(sts["spec"]["podManagementPolicy"] == "OrderedReady" and + sts["spec"]["updateStrategy"]["type"] == "RollingUpdate", "native ordered policy absent") + for ordinal in range(count): + pod = self.get("pod", set_name + "-" + str(ordinal)) + require(pod and not pod["metadata"].get("deletionTimestamp"), "Pod missing or terminating") + states = pod.get("status", {}) + init = named(states.get("initContainerStatuses", []), "initialize-trino") + require(init and init.get("state", {}).get("terminated", {}).get("exitCode") == 0, + "product initialization did not finish before startup") + main = named(states.get("containerStatuses", []), "trino") + require(main and main.get("ready") and main.get("restartCount") == 0, "main not ready or restarted") + result[pod["metadata"]["name"]] = {"uid": pod["metadata"]["uid"], "container_id": main["containerID"]} + return result + + def verify(self): + original = copy.deepcopy(self.cr()["spec"]) + processes, handles = [], [] + self.report["scope"] = "formal initialization, ordinal scale-down, native rolling and active-query coordinated stop" + try: + # This isolated test explicitly authorizes a local management identity. + # The production default does not generate this access-control grant. + rules = {"catalogs": [{"user": ".*", "catalog": ".*", "allow": "all"}], + "system_information": [{"user": ADMIN, "allow": ["read", "write"]}, + {"user": ".*", "allow": ["read"]}]} + files = {"access-control.properties": {"text": "access-control.name=file\nsecurity.config-file=/etc/trino/access-control.json\n"}, + "access-control.json": {"text": json.dumps(rules)}, + "catalog/blackhole.properties": {"text": "connector.name=blackhole\n"}, + "config.properties": {"properties": {"set": {"shutdown.grace-period": "5s"}}}} + self.patch({role: {"config": {"gracefulShutdownTimeout": "120s", **({"shutdownUser": ADMIN} if role == "workers" else {})}, + "configOverrides": files} for role in ("coordinators", "workers")}) + baseline = self.until("formal lifecycle initialization", self.ready_pods) + self.phase("initialization", baseline) + self.patch({"workers": {"roleGroups": {"default": {"replicas": 2}}}}) + two = self.until("two initialized workers", lambda: self.ready_pods(2)) + self.phase("scale-up-initialization", two) + self.patch({"workers": {"roleGroups": {"default": {"replicas": 1}}}}) + scaled = self.until("ordinal scale-down", self.ready_pods) + require(scaled[self.name + "-workers-default-0"] == two[self.name + "-workers-default-0"], + "scale-down replaced the surviving ordinal") + self.phase("scale-down", scaled) + self.patch({"workers": {"roleGroups": {"default": {"envOverrides": {"E04_ROLLOUT": "typed-lifecycle"}}}}}) + def rolled(): + result = self.ready_pods() + require(result[self.name + "-workers-default-0"]["uid"] != scaled[self.name + "-workers-default-0"]["uid"], + "native rolling replacement not observed") + return result + rolled = self.until("native rolling update", rolled) + self.phase("rolling", rolled) + # Re-establish real membership before the single business query. + self.wait_membership({"groups": {name: {"pod_uid": state["uid"]} for name, state in rolled.items()}}) + self.query("CREATE TABLE blackhole.default.e04_rows (id BIGINT) WITH (split_count=2,pages_per_split=4,rows_per_page=8,page_processing_delay='2s')") + # kubectl performs the initial list and resumes its watch at that + # list's version. Wait for the original Pod event before querying; + # `kubectl get` has no --resource-version flag. + + watch_path = self.run.output / "lifecycle-pod-watch.json" + stream = watch_path.open("w"); handles.append(stream) + watch_args = ["kubectl", "--kubeconfig", str(self.run.kubeconfig), "-n", self.namespace, "get", "pods", + "-l", "app.kubernetes.io/instance=" + self.name, "--watch", "--output-watch-events", "-o", "json"] + watch_error = (self.run.output / "lifecycle-pod-watch.stderr").open("w"); handles.append(watch_error) + watch = subprocess.Popen(watch_args, stdout=stream, stderr=watch_error, text=True); processes.append(watch) + def watch_started(): + require(watch.poll() is None, "Pod watch exited before shutdown; see lifecycle-pod-watch.stderr") + target = rolled[self.name + "-workers-default-0"]["uid"] + return any(event.get("object", {}).get("metadata", {}).get("uid") == target + for event in json_stream(watch_path)) + self.until("Pod watch initial worker identity", watch_started, timeout=30) + query_path = self.run.output / "lifecycle-query.jsonl" + stream = query_path.open("w"); handles.append(stream) + args = ["kubectl", "--kubeconfig", str(self.run.kubeconfig), "-n", self.namespace, "exec", + self.name + "-coordinators-default-0", "-c", "trino", "--", "python3", "-c", QUERY, + "SELECT count(*) FROM blackhole.default.e04_rows"] + query = subprocess.Popen(args, stdout=stream, stderr=subprocess.PIPE, text=True); processes.append(query) + self.report["processes"] = [{"pid": watch.pid, "argv": watch_args}, {"pid": query.pid, "argv": args}] + self.persist() + def active(): + require(query.poll() is None, "business query finished before shutdown trigger") + values = json_stream(query_path) + started = next((value for value in values if value.get("started")), None) + if started is None: + return None + tasks = self.exec("coordinators", TASKS, started["query_id"]) + target_uid = rolled[self.name + "-workers-default-0"]["uid"] + return tasks if any(task["state"] == "RUNNING" and task["nodeId"] == target_uid for task in tasks["tasks"]) else None + active = self.until("exact current worker has running query tasks", active, timeout=60) + self.patch({"clusterConfig": {"stopped": True}}) + # Observe the persisted transition before restarting the real operator. + def draining(): + sts = self.get("statefulset", self.name + "-workers-default") + return sts if sts["spec"]["replicas"] == 0 else None + self.until("worker shutdown issued", draining, timeout=30) + self.run.restart_operator() + self.report["tasks_before_stop"] = active + self.persist() + query.wait(timeout=150) + query_error = query.stderr.read() + result = json_stream(query_path) + require(query.returncode == 0 and result[-1].get("complete") and result[-1].get("rows") == [[64]], + "single in-flight query did not succeed: " + query_error + repr(result)) + def stopped(): + cr = self.cr() + require(current_condition(cr, "Stopped"), "coordinated stop is not complete") + for name in rolled: + require(self.get("pod", name) is None, "actual Pod remains") + return cr["status"] + stopped = self.until("all workloads stopped after worker completion", stopped, timeout=180) + watch.terminate(); watch.wait(timeout=10); handles[0].flush() + events = json_stream(watch_path) + target = rolled[self.name + "-workers-default-0"] + exits = [] + for event in events: + pod = event.get("object", {}) + if pod.get("metadata", {}).get("uid") != target["uid"]: + continue + main = named(pod.get("status", {}).get("containerStatuses", []), "trino") + if main and main.get("containerID") == target["container_id"]: + exit_state = main.get("state", {}).get("terminated") + if exit_state and exit_state.get("finishedAt") and exit_state.get("reason") != "ContainerStatusUnknown": + exits.append(exit_state) + require(exits and all(value["exitCode"] == 0 for value in exits), + "no exact original main-process exit=0 proof; Pod absence alone is insufficient") + hook_events = json.loads(self.kube("get", "events", "--field-selector", "involvedObject.uid=" + target["uid"], "-o", "json")) + self.phase("active-query-stop-controller-restart", {"tasks_before": active, "query": result, + "worker_main_exits": exits, "hook_events": hook_events, "stopped": stopped, + "boundary": "main exit and query are proven separately; hook success is not inferred"}) + self.patch({"clusterConfig": {"stopped": False}}) + restored = self.until("resume after business stop", self.ready_pods) + self.phase("resume", restored) + self.report["passed"] = True + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + for handle in handles: + handle.close() + # Replace spec rather than merge so all temporary test authority and + # shutdown fields disappear. This is the caller's disposable CR only. + self.kube("patch", "trinoclusters.trino.kubedoop.dev", self.name, "--type=json", "-p", + json.dumps([{"op": "replace", "path": "/spec", "value": original}])) + self.persist() diff --git a/hack/framework-e2e/platform_csi.py b/hack/framework-e2e/platform_csi.py new file mode 100644 index 00000000..8526f1eb --- /dev/null +++ b/hack/framework-e2e/platform_csi.py @@ -0,0 +1,241 @@ +"""Build unchanged platform operators and verify their actual CSI consumption.""" +import copy +import hashlib +import json +import pathlib +import time + +from runtime import RuntimeVerifier, current_condition, named, require + +BASE = 'registry.access.redhat.com/ubi9/ubi-minimal@sha256:2f06ae0e6d3d9c4f610d32c480338eef474867f435d8d28625f2985e8acde6e8' +SIDECARS = ( + 'quay.io/zncdatadev/sig-storage/csi-node-driver-registrar:v2.12.0', + 'quay.io/zncdatadev/sig-storage/csi-provisioner:v5.1.0', + 'quay.io/zncdatadev/sig-storage/livenessprobe:v2.14.0', +) + + +def resources(stream): + decoder, result = json.JSONDecoder(), [] + while stream.strip(): + value, end = decoder.raw_decode(stream.lstrip()) + result.extend(value['items'] if value.get('kind') == 'List' else [value]) + stream = stream.lstrip()[end:] + return result + + +class PlatformInstaller: + def __init__(self, run): + self.run = run + self.image = 'operator-go/platform:' + run.cluster + self.root = pathlib.Path(run.args.commons).expanduser().resolve().parent + self.output = run.output / 'platform-build' + self.output.mkdir(parents=True, exist_ok=True) + self.sidecars = {} + self.manifests = {} + self.report = {'sources': {}, 'images': {}, 'manifest_adjustments': [ + 'replace manager and CSI executables with unchanged local source builds', + 'pin CSI sidecars to observed immutable registry digests', + 'disable metrics endpoints; use fsGroup 65532 for writable CSI controller socket directory', + 'supply secret manager Deployment commented out by upstream config/default', + 'give node CSI processes bounded 256Mi memory and 500m CPU during mount/key generation', + ]} + + def build(self): + self.run.phase('build-real-secret-and-listener-operators') + for product in ('secret', 'listener'): + repo = self.root / (product + '-operator') + tracked = self.run.command(['git', 'diff', 'HEAD', '--name-only'], cwd=repo).strip() + untracked = self.run.command(['git', 'ls-files', '--others', '--exclude-standard', '--', '.', ':(exclude).worktree/**'], cwd=repo).strip() + require(not tracked and not untracked, 'Platform source must be clean: ' + str(repo)) + source_hashes = {str(path.relative_to(repo)): hashlib.sha256(path.read_bytes()).hexdigest() + for folder in ('api', 'internal', 'pkg', 'cmd', 'config') + for path in (repo / folder).rglob('*') if path.is_file() and path.suffix in ('.go', '.yaml')} + for name in ('go.mod', 'go.sum'): + source_hashes[name] = hashlib.sha256((repo / name).read_bytes()).hexdigest() + self.report['sources'][product] = {'commit': self.run.command(['git', 'rev-parse', 'HEAD'], cwd=repo).strip(), + 'files': source_hashes} + for entry, binary in (('./cmd/main.go', product + '-manager'), ('./cmd/csiplugin', product + '-csi')): + self.run.command(['env', 'CGO_ENABLED=0', 'GOOS=linux', 'GOARCH=arm64', 'go', 'build', '-mod=readonly', + '-trimpath', '-buildvcs=false', '-o', self.output / binary, entry], cwd=repo, timeout=600) + self.report.setdefault('binaries', {})[binary] = hashlib.sha256((self.output / binary).read_bytes()).hexdigest() + self.manifests[product] = self.run.command([repo / 'bin/kustomize', 'build', repo / 'config/default'], cwd=repo) + (self.output / (product + '-upstream.yaml')).write_text(self.manifests[product]) + dockerfile = self.output / 'Dockerfile' + dockerfile.write_text('FROM ' + BASE + '\nUSER 0\nRUN microdnf install -y util-linux openssl krb5-workstation cyrus-sasl && microdnf clean all\n' + 'COPY secret-manager secret-csi listener-manager listener-csi /\nUSER 65532:65532\n') + self.run.images.append(self.image) + self.run.command(['docker', 'build', '--platform=linux/arm64', '-t', self.image, self.output], timeout=900) + self.report['images']['platform'] = json.loads(self.run.command(['docker', 'image', 'inspect', self.image])) + for original in SIDECARS: + self.run.command(['docker', 'image', 'inspect', original], check=False) + if self.run.last_command['returncode'] != 0: + self.pull_image(original) + observed = json.loads(self.run.command(['docker', 'image', 'inspect', original]))[0] + require(observed['Architecture'] == 'arm64' and observed.get('RepoDigests'), 'CSI sidecar lacks pinned arm64 identity') + digest = next((d for d in observed['RepoDigests'] if d.split('@')[0] == original.rsplit(':', 1)[0]), None) + require(digest, 'CSI sidecar digest does not belong to requested registry repository') + self.sidecars[original] = digest + self.report['images'][original] = {'digest': digest, 'id': observed['Id']} + self.run.write('platform-build.json', self.report) + + def pull_image(self, image): + # Only the idempotent registry download is retried. Each command keeps its + # ordinary stdout/stderr receipt, including timeouts and failed attempts. + for attempt in range(1, 4): + self.run.command(['docker', 'pull', '--platform=linux/arm64', image], timeout=600, check=False) + receipt = self.run.last_command + self.report.setdefault('image_pull_attempts', []).append({ + 'image': image, 'attempt': attempt, 'command': f'{self.run.command_index:05d}', + 'returncode': receipt['returncode'], 'timed_out': receipt.get('timed_out', False), + }) + self.run.write('platform-build.json', self.report) + if receipt['returncode'] == 0: + return + if attempt < 3: + time.sleep(2 ** attempt) + require(False, 'Image pull exhausted 3 attempts: ' + image + ': ' + receipt['stderr'][-3000:]) + + def load(self): + transports = [] + for index, digest in enumerate(self.sidecars.values()): + tag = 'operator-go/platform-sidecar:' + self.run.cluster + '-' + str(index) + self.run.command(['docker', 'tag', digest, tag]) + self.run.images.append(tag) + transports.append(tag) + self.run.load_images([self.image, *transports]) + for digest, tag in zip(self.sidecars.values(), transports): + self.run.ensure_image_digest(digest, tag) + + def install(self): + self.run.phase('install-real-secret-listener-CSI') + for product, stream in self.manifests.items(): + rendered = resources(self.run.kube('create', '--dry-run=client', '-f', '-', '-o', 'json', input=stream)) + manager_found = False + for value in rendered: + if value['kind'] in ('Deployment', 'DaemonSet'): + pod = value['spec']['template']['spec'] + pod.setdefault('securityContext', {})['fsGroup'] = 65532 + for container in pod['containers']: + name = container['name'] + if name in ('manager', 'csi-controller', 'csi-node'): + container['image'] = self.image + container['command'] = ['/' + product + ('-manager' if name == 'manager' else '-csi')] + container['args'] = [arg for arg in container.get('args', []) if not arg.startswith('--metrics-bind-address')] + container['args'].append('--metrics-bind-address=0') + container.setdefault('resources', {})['limits'] = {'memory': '256Mi', 'cpu': '500m'} + manager_found = manager_found or name == 'manager' + else: + require(container['image'] in self.sidecars, 'Unpinned platform sidecar: ' + container['image']) + container['image'] = self.sidecars[container['image']] + self.run.apply(value) + if not manager_found: + rendered.append(self.manager(product)) + self.run.apply(rendered[-1]) + self.run.write('platform-' + product + '-deployment.json', rendered) + namespace = product + '-operator-system' + for kind, suffix in (('deployment', 'controller-manager'), ('deployment', 'csi-controller'), ('daemonset', 'csi-node')): + self.run.kube('-n', namespace, 'rollout', 'status', kind + '/' + product + '-operator-' + suffix, + '--timeout=180s', timeout=200) + self.run.wait_crd_established(product + 'classes.' + product + 's.kubedoop.dev') + node = json.loads(self.run.kube('get', 'csinode', self.run.cluster + '-control-plane', '-o', 'json')) + drivers = {d['name'] for d in node['spec']['drivers']} + require({'secrets.kubedoop.dev', 'listeners.kubedoop.dev'} <= drivers, 'Actual kubelet CSI registration missing') + self.run.write('platform-csi-node.json', node) + + def manager(self, product): + name = product + '-operator-controller-manager' + labels = {'app.kubernetes.io/name': product + '-operator', 'control-plane': 'controller-manager'} + return {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {'name': name, 'namespace': product + '-operator-system'}, + 'spec': {'replicas': 1, 'selector': {'matchLabels': labels}, 'template': {'metadata': {'labels': labels}, 'spec': { + 'serviceAccountName': name, 'securityContext': {'runAsNonRoot': True, 'runAsUser': 65532}, + 'containers': [{'name': 'manager', 'image': self.image, 'command': ['/' + product + '-manager'], + 'args': ['--health-probe-bind-address=:8081', '--metrics-bind-address=0'], + 'resources': {'requests': {'cpu': '10m', 'memory': '32Mi'}, 'limits': {'cpu': '500m', 'memory': '256Mi'}}, + 'readinessProbe': {'httpGet': {'path': '/readyz', 'port': 8081}}}]}}}} + + +TLS_QUERY = r'''import hashlib,json,pathlib,ssl,sys,urllib.request,time +root=pathlib.Path('/kubedoop/platform-tls') +listener=pathlib.Path('/kubedoop/listener/default-address') +host=(listener/'address').read_text().strip();port=(listener/'ports'/'https').read_text().strip() +endpoint='https://'+('['+host+']' if ':' in host else host)+':'+port +context=ssl.create_default_context(cafile=str(root/'ca.crt')) +request=urllib.request.Request(endpoint+'/v1/statement',data=b'SELECT count(*) FROM tpch.tiny.nation',headers={'X-Trino-User':'platform-e02'}) +rows=[];query=None;deadline=time.monotonic()+90 +while True: + with urllib.request.urlopen(request,context=context,timeout=15) as response: result=json.load(response) + query=result.get('id',query) + if 'error' in result: raise RuntimeError(json.dumps(result['error'])) + rows.extend(result.get('data',[])) + if not result.get('nextUri'): break + if time.monotonic()>deadline: raise TimeoutError(query) + request=urllib.request.Request(result['nextUri'],headers={'X-Trino-User':'platform-e02'});time.sleep(.2) +print(json.dumps({'rows':rows,'query_id':query,'tls_verified':True,'ca_sha256':hashlib.sha256((root/'ca.crt').read_bytes()).hexdigest(), + 'certificate_sha256':hashlib.sha256((root/'tls.crt').read_bytes()).hexdigest(), + 'listener_address':host,'listener_https':port,'verified_listener_uri':endpoint})) +''' + + +class PlatformVerifier(RuntimeVerifier): + def persist(self): + self.run.write('platform-runtime.json', self.report) + + def group(self): + return next(g for g in self.cr().get('status', {}).get('groups', []) if g['role'] == 'coordinators' and g['name'] == 'default') + + def producer_pending(self): + cr = self.cr() + group = self.group() + pod = self.get('pod', self.name + '-coordinators-default-0') + platform = group.get('platform', {}) + return {'generation': cr['metadata']['generation'], 'pod_uid': pod['metadata']['uid'], 'platform': platform} if ( + pod and platform.get('phase') == 'Observing' and platform.get('diagnostic', {}).get('state') == 'pending') else None + + def verified(self): + self.healthy() + group = self.group() + platform = group.get('platform', {}) + require(platform.get('diagnostic', {}).get('state') == 'resolved', 'post-creation platform facts unresolved') + result = self.exec('coordinators', TLS_QUERY) + require(result.get('rows') == [[25]] and result.get('tls_verified'), 'TLS native SQL failed') + discovery = self.get('configmap', self.name + '-discovery') + expected = result['verified_listener_uri'] + require(discovery['data']['TRINO_URI'] == expected, 'shared discovery did not consume actual CSI listener endpoint') + addresses = platform.get('listeners', []) + require(any(a['address'] == result['listener_address'] and a['ports']['https'] == int(result['listener_https']) for a in addresses), + 'status did not observe actual mounted listener endpoint') + claims = [] + for directory, driver in (('listener', 'listeners.kubedoop.dev'), ('tls-source', 'secrets.kubedoop.dev')): + claim = self.get('pvc', self.name + '-coordinators-default-0-' + directory) + pv = self.get('pv', claim['spec']['volumeName']) + require(pv['spec']['csi']['driver'] == driver, 'directory did not use real platform CSI') + claims.append({'directory': directory, 'pvc_uid': claim['metadata']['uid'], 'pv_uid': pv['metadata']['uid'], 'driver': driver}) + return {'query': result, 'platform': platform, 'claims': claims, 'discovery': discovery['data']} + + def verify(self): + original = copy.deepcopy(self.cr()['spec']) + class_name = self.run.cluster + '-platform' + self.report['scope'] = 'real platform CSI producer creation, mounted AutoTLS credentials, verified HTTPS SQL and observed Listener discovery' + try: + self.run.apply({'apiVersion': 'listeners.kubedoop.dev/v1alpha1', 'kind': 'ListenerClass', + 'metadata': {'name': class_name}, 'spec': {'serviceType': 'ClusterIP'}}) + self.run.apply({'apiVersion': 'secrets.kubedoop.dev/v1alpha1', 'kind': 'SecretClass', 'metadata': {'name': class_name}, + 'spec': {'backend': {'autoTls': {'ca': {'secret': {'name': 'e02-platform-ca', 'namespace': self.namespace}, + 'autoGenerate': True, 'keyGeneration': {'rsa': {'length': 2048}}}}}}}) + self.patch({'clusterConfig': {'listenerClass': class_name, 'tlsSecretClass': class_name}, + 'coordinators': {'podOverrides': {'spec': {'containers': [{'name': 'trino', 'volumeMounts': [ + {'name': 'tls-source', 'mountPath': '/kubedoop/platform-tls', 'readOnly': True}]}]}}}}) + pending = self.until('Pod produces post-creation CSI result', self.producer_pending, timeout=120) + self.phase('producer-created-before-platform-ready', pending) + result = self.until('real CSI Listener and verified AutoTLS SQL', self.verified, timeout=360) + self.phase('real-listener-autotls-consumed', result) + self.report['passed'] = True + finally: + self.kube('patch', 'trinoclusters.trino.kubedoop.dev', self.name, '--type=json', '-p', + json.dumps([{'op': 'replace', 'path': '/spec', 'value': original}])) + self.until('restore platform-free fixture', lambda: self.healthy(), timeout=300) + self.run.kube('delete', 'listenerclass', class_name, '--ignore-not-found') + self.run.kube('delete', 'secretclass', class_name, '--ignore-not-found') + self.kube('delete', 'secret', 'e02-platform-ca', '--ignore-not-found') + self.persist() diff --git a/hack/framework-e2e/run.py b/hack/framework-e2e/run.py new file mode 100644 index 00000000..5521b9f3 --- /dev/null +++ b/hack/framework-e2e/run.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""Build and verify formal delivery artifacts in a disposable, isolated kind cluster.""" +import argparse +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import sys +import tarfile +import tempfile +import time + +from runtime import RuntimeVerifier, require +from lifecycle import LifecycleVerifier +from platform_csi import PlatformInstaller, PlatformVerifier +from authentication import AuthenticationVerifier +from s3 import S3Verifier + +ROOT = pathlib.Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / "examples/trino-operator" +TRINO = "quay.io/zncdatadev/trino@sha256:6c7002a7e6d4f7a738f3e3066dabf06f433c32e57900d5697a7544187f40875f" +VECTOR = "quay.io/zncdatadev/vector@sha256:3b9a99d98905443924bee204bd76c2818ad2da7056388fd524b0ea000eb55682" +NODE = "kindest/node@sha256:4613778f3cfcd10e615029370f5786704559103cf27bef934597ba562b269661" +HIVE = "quay.io/zncdatadev/hive@sha256:b1de1210b9220f79c34e0b9e584b1b6b804ad8b9d9ee67f8e0050661ee0ebaa6" +MINIO = "minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e" +MINIO_MC = "minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727" +RUNTIME_IMAGES = (TRINO, VECTOR, HIVE, MINIO, MINIO_MC) +OPERATOR_NS = "trino-operator-system" +DEPLOYMENT = "trino-operator-controller-manager" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def resource_list(stream): + """kubectl prints one JSON document per input resource, or a List.""" + decoder, items = json.JSONDecoder(), [] + remaining = stream.lstrip() + while remaining: + resource, end = decoder.raw_decode(remaining) + require(isinstance(resource, dict), "kubectl resource must be an object") + items.extend(resource["items"] if resource.get("kind") == "List" else [resource]) + remaining = remaining[end:].lstrip() + require(items, "installer produced no resources") + return {"apiVersion": "v1", "kind": "List", "items": items} + + +def context_snapshot(): + paths = (os.environ.get("KUBECONFIG") or str(pathlib.Path.home() / ".kube/config")).split(os.pathsep) + configs = [digest(pathlib.Path(p)) if pathlib.Path(p).is_file() else None for p in paths] + names = subprocess.check_output(["docker", "ps", "-a", "--filter", "label=io.x-k8s.kind.cluster", + "--format", "{{.Names}}"], text=True, timeout=15).splitlines() + nodes = [] + for name in sorted(names): + value = json.loads(subprocess.check_output(["docker", "inspect", name], text=True, timeout=15))[0] + nodes.append({"name": name, "id": value["Id"], "state": value["State"]["Status"]}) + return {"kubeconfig_hashes": configs, "existing_kind_nodes": nodes} + + +class Run: + def __init__(self, args): + self.args = args + self.output = pathlib.Path(args.output).resolve() + self.output.mkdir(parents=True, exist_ok=True) + require(not any(self.output.iterdir()), "Use a new empty output directory") + os.chmod(self.output, 0o700) + (self.output / "commands").mkdir() + self.cluster = "framework-u05-" + str(os.getpid()) + "-" + str(int(time.time())) + self.kubeconfig = self.output / "kubeconfig" + self.operator_image = "operator-go/trino-operator:" + self.cluster + self.helper_image = "operator-go/materializer:" + self.cluster + self.created = False + self.images, self.processes = [], [] + self.command_index = 0 + self.last_command = None + self.report = {"passed": False, "cluster": self.cluster, "phases": [], "cleanup": False, + "scope": "formal SDK, generated registration, deployed Trino reference operator and Retain fixture"} + self.platform = PlatformInstaller(self) + self.before = context_snapshot() + self.write("context-before.json", self.before) + self.persist() + + def write(self, name, data): + path = self.output / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + def persist(self): + self.write("verification.json", self.report) + + def command(self, argv, timeout=60, input=None, check=True, cwd=ROOT): + self.command_index += 1 + key = f"{self.command_index:05d}" + receipt = {"argv": list(map(str, argv)), "cwd": str(cwd), "started_at": time.time()} + data = input.encode() if isinstance(input, str) else input + if data is not None: + receipt["stdin_sha256"] = hashlib.sha256(data).hexdigest() + started = time.monotonic() + try: + process = subprocess.Popen(receipt["argv"], cwd=cwd, stdin=subprocess.PIPE if data is not None else None, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + try: + stdout, stderr = process.communicate(input=data, timeout=timeout) + receipt["returncode"] = process.returncode + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + stdout, stderr = process.communicate(timeout=20) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + stdout, stderr = process.communicate(timeout=5) + receipt.update(returncode=124, timed_out=True) + receipt.update(stdout=stdout.decode(errors="replace"), stderr=stderr.decode(errors="replace")) + except OSError as error: + receipt.update(returncode=125, stdout="", stderr=repr(error)) + receipt["elapsed_seconds"] = time.monotonic() - started + self.write("commands/" + key + ".json", receipt) + self.last_command = receipt + if check: + require(receipt["returncode"] == 0, "command " + key + " failed: " + receipt["stderr"][-3000:]) + return receipt["stdout"] + + def kube(self, *args, **kwargs): + return self.command(["kubectl", "--kubeconfig", self.kubeconfig, "--request-timeout=20s", *args], **kwargs) + + def apply(self, obj): + self.kube("apply", "-f", "-", input=json.dumps(obj)) + + def wait_crd_established(self, name): + """Observe establishment without kubectl wait's null-conditions accessor race.""" + started = time.monotonic() + deadline = started + 60 + report = {"name": name, "budget_seconds": 60, "established": False, "attempts": []} + evidence = "crd-establishment/" + name + ".json" + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + request_timeout = min(10, remaining) + raw = self.kube("get", "crd", name, "--ignore-not-found", "-o", "json", + f"--request-timeout={request_timeout:.9f}s", + timeout=request_timeout, check=False) + attempt = {"command": f"{self.command_index:05d}", + "returncode": self.last_command["returncode"], "state": "Pending"} + if self.last_command["returncode"] != 0: + attempt.update(state="ReadError", error=self.last_command.get("stderr", "")) + elif not raw.strip(): + attempt["reason"] = "CRD not found" + else: + try: + value = json.loads(raw) + status = value.get("status") or {} + conditions = status.get("conditions") + if conditions is None: + conditions = [] + if not isinstance(conditions, list): + raise ValueError("CRD conditions must be a list or null") + attempt["conditions"] = conditions + if any(isinstance(c, dict) and c.get("type") == "Established" and + c.get("status") == "True" for c in conditions): + attempt["state"] = "Established" + except (ValueError, AttributeError) as error: + attempt.update(state="ReadError", error=str(error)) + report["attempts"].append(attempt) + report["elapsed_seconds"] = time.monotonic() - started + report["established"] = attempt["state"] == "Established" and time.monotonic() <= deadline + self.write(evidence, report) + if report["established"]: + return + time.sleep(min(1, max(0, deadline - time.monotonic()))) + report["elapsed_seconds"] = time.monotonic() - started + self.write(evidence, report) + require(False, "CRD " + name + " was not Established within 60 seconds; evidence: " + evidence + + "; last observation: " + json.dumps(report["attempts"][-1] if report["attempts"] else {})) + + def phase(self, name): + self.report["phases"].append({"name": name, "time": time.time()}) + self.persist() + print("U05:", name, flush=True) + + def restart_operator(self): + self.kube("-n", OPERATOR_NS, "rollout", "restart", "deployment/" + DEPLOYMENT) + self.kube("-n", OPERATOR_NS, "rollout", "status", "deployment/" + DEPLOYMENT, "--timeout=120s", timeout=150) + + def sources(self): + files = [] + for folder in ("pkg/framework", "internal/framework", "cmd/materialize", "cmd/dataops", "config/framework-data", "config/framework-data-executor", "examples/trino-operator", "hack/framework-e2e"): + files.extend(path for path in (ROOT / folder).rglob("*") if path.is_file() and + path.suffix in (".go", ".py", ".yaml", ".mod", ".sum") and + not any(part in ("bin", "__pycache__") for part in path.relative_to(ROOT).parts)) + files += [ROOT / "Makefile", ROOT / "cmd/materialize/Dockerfile", ROOT / "Dockerfile.dataops", EXAMPLE / "Makefile", EXAMPLE / "Dockerfile"] + self.write("sources.json", {str(path.relative_to(ROOT)): digest(path) for path in sorted(files)}) + self.write("revision.json", {"commit": self.command(["git", "rev-parse", "HEAD"]).strip(), + "status": self.command(["git", "status", "--short"])}) + + def build(self): + self.phase("build-formal-artifacts") + self.sources() + architecture = self.command(["docker", "info", "--format", "{{.Architecture}}"]).strip() + require(architecture in ("aarch64", "arm64"), "This pinned runtime fixture is verified for arm64") + self.images.append(self.helper_image) + self.command(["make", "materializer-image", "MATERIALIZER_ARCH=arm64", "MATERIALIZER_IMG=" + self.helper_image], timeout=600) + self.images.append(self.operator_image) + self.command(["make", "docker-build", "IMG=" + self.operator_image], cwd=EXAMPLE, timeout=600) + self.command(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-o", self.output / "storage-controller", + "./test/runtime/storage-controller"], cwd=EXAMPLE, timeout=300) + self.command(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-o", self.output / "logging-controller", + "./test/runtime/logging-controller"], cwd=EXAMPLE, timeout=300) + self.command(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-o", self.output / "data-controller", + "./cmd/dataops"], timeout=300) + commons = pathlib.Path(self.args.commons).expanduser().resolve() + tracked = self.command(["git", "diff", "HEAD", "--name-only"], cwd=commons).strip() + untracked = self.command(["git", "ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).worktree/**"], cwd=commons).strip() + require(not tracked and not untracked, "commons-operator tracked/build inputs must be clean") + self.write("commons-revision.json", {"commit": self.command(["git", "rev-parse", "HEAD"], cwd=commons).strip()}) + self.command(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-o", self.output / "commons-operator", "./cmd"], + cwd=commons, timeout=300) + self.write("binaries.json", {name: digest(self.output / name) for name in ("storage-controller", "logging-controller", "data-controller", "commons-operator")}) + for image in RUNTIME_IMAGES: + self.command(["docker", "image", "inspect", image], check=False) + if self.last_command["returncode"] != 0: + self.command(["docker", "pull", image], timeout=600) + self.write("images.json", json.loads(self.command(["docker", "image", "inspect", self.operator_image, self.helper_image, *RUNTIME_IMAGES]))) + (self.output / "fixture-image.json").write_text(self.command(["docker", "image", "inspect", TRINO])) + self.platform.build() + + def load_images(self, images): + # Docker may cache only arm64 children of a multi-platform index. Export + # and import that explicit platform rather than requesting absent ones. + node = self.cluster + "-control-plane" + with tempfile.TemporaryDirectory(prefix=self.cluster + "-images-") as temporary: + archive = pathlib.Path(temporary) / "arm64.tar" + remote = "/var/lib/containerd/" + pathlib.Path(temporary).name + ".tar" + try: + self.command(["docker", "image", "save", "--platform=linux/arm64", "--output", archive, *images], timeout=600) + identities = getattr(self, "loaded_image_identities", {}) + with tarfile.open(archive) as saved: + entries = json.load(saved.extractfile("manifest.json")) + configs = {tag: "sha256:" + hashlib.sha256(saved.extractfile(entry["Config"]).read()).hexdigest() + for entry in entries for tag in entry["RepoTags"]} + for image in images: + require(image in configs, "Image missing from selected platform archive: " + image) + host = json.loads(self.command(["docker", "image", "inspect", image]))[0] + require(host["Architecture"] == "arm64", "Unexpected host image architecture: " + image) + identities[image] = {"host_id": host["Id"], "host_repo_digests": host.get("RepoDigests", []), + "archive_config_id": configs[image]} + self.loaded_image_identities = identities + self.write("loaded-image-identities.json", identities) + self.command(["docker", "cp", archive, node + ":" + remote], timeout=300) + self.command(["docker", "exec", node, "test", "-s", remote]) + self.command(["docker", "exec", node, "ctr", "--namespace=k8s.io", "images", "import", + "--platform=linux/arm64", remote], timeout=600) + for image in images: + observed = json.loads(self.command(["docker", "exec", node, "crictl", "inspecti", image])) + require(observed["status"]["id"] == identities[image]["archive_config_id"], + "Node image differs from exported host image: " + image) + identities[image]["node_config_id"] = observed["status"]["id"] + self.write("loaded-image-identities.json", identities) + finally: + self.command(["docker", "exec", node, "rm", "-f", remote], check=False) + + def ensure_image_digest(self, image, transport): + node = self.cluster + "-control-plane" + self.command(["docker", "exec", node, "crictl", "inspecti", image], check=False) + if self.last_command["returncode"] != 0: + def qualified(reference): + if "/" not in reference: + return "docker.io/library/" + reference + registry = reference.split("/", 1)[0] + return reference if "." in registry or ":" in registry or registry == "localhost" else "docker.io/" + reference + self.command(["docker", "exec", node, "ctr", "--namespace=k8s.io", "images", "tag", + qualified(transport), qualified(image)]) + observed = json.loads(self.command(["docker", "exec", node, "crictl", "inspecti", image])) + identity = self.loaded_image_identities[transport] + host = json.loads(self.command(["docker", "image", "inspect", image]))[0] + require(host["Id"] == identity["host_id"], "Transport tag no longer represents selected host image: " + image) + require(observed["status"]["id"] == identity["archive_config_id"], "Pinned node image identity differs: " + image) + self.loaded_image_identities[image] = {**identity, "selected_digest": image, "node_config_id": observed["status"]["id"]} + self.write("loaded-image-identities.json", self.loaded_image_identities) + + def start_cluster(self): + self.phase("create-isolated-kind") + self.created = True + self.command(["kind", "create", "cluster", "--name", self.cluster, "--image", NODE, + "--kubeconfig", self.kubeconfig, "--wait", "90s"], timeout=240) + transports = [] + for image in RUNTIME_IMAGES: + transport = image.split("@")[0] + ":" + self.cluster + self.images.append(transport) + self.command(["docker", "tag", image, transport]) + transports.append(transport) + self.load_images([self.helper_image, self.operator_image, *transports]) + for image, transport in zip(RUNTIME_IMAGES, transports): + self.ensure_image_digest(image, transport) + self.kube("create", "namespace", "trino-u05") + self.kube("create", "namespace", "storage-u05") + self.write("kubernetes-version.json", json.loads(self.kube("version", "-o", "json"))) + self.platform.load() + + def deploy(self): + self.phase("deploy-generated-api-rbac-operator") + self.command(["make", "build-installer"], cwd=EXAMPLE, timeout=120) + manifests = (EXAMPLE / "dist/install.yaml").read_text() + resources = resource_list(self.kube("create", "--dry-run=client", "-f", "-", "-o", "json", input=manifests)) + items = resources.get("items", [resources]) + for obj in items: + if obj["kind"] != "Deployment": + continue + container = obj["spec"]["template"]["spec"]["containers"][0] + container["image"] = self.operator_image + container["imagePullPolicy"] = "IfNotPresent" + args = [arg for arg in container.get("args", []) if not arg.startswith(("--materializer-image=", "--vector-image=", "--namespace="))] + container["args"] = args + ["--materializer-image=" + self.helper_image, "--vector-image=" + VECTOR, "--namespace=trino-u05"] + self.write("install.json", resources) + self.apply(resources) + self.wait_crd_established("trinoclusters.trino.kubedoop.dev") + self.kube("-n", OPERATOR_NS, "rollout", "status", "deployment/" + DEPLOYMENT, "--timeout=180s", timeout=210) + stream = (self.output / "commons-operator.log").open("wb") + command = [str(self.output / "commons-operator"), "--kubeconfig=" + str(self.kubeconfig), + "--metrics-bind-address=0", "--health-probe-bind-address=0", "--leader-elect=false"] + process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) + self.processes.append((process, stream)) + self.write("commons-process.json", {"pid": process.pid, "argv": command}) + sample = json.loads(self.kube("create", "--dry-run=client", "-f", EXAMPLE / "config/samples/trino_v1alpha1_trinocluster.yaml", "-o", "json")) + sample["metadata"].update(name="demo-trino", namespace="trino-u05") + sample["spec"]["image"] = {"custom": TRINO, "pullPolicy": "IfNotPresent"} + self.write("input.json", sample) + self.apply(sample) + RuntimeVerifier(self, "trino-u05", "demo-trino").verify() + require(process.poll() is None, "commons restarter exited during runtime acceptance") + self.kube("-n", OPERATOR_NS, "logs", "deployment/" + DEPLOYMENT, "--tail=2000") + + def storage(self): + self.phase("retained-volume-acceptance") + self.kube("-n", "local-path-storage", "rollout", "status", "deployment/local-path-provisioner", "--timeout=90s", timeout=120) + storage_class = self.cluster + "-retain" + self.apply({"apiVersion": "storage.k8s.io/v1", "kind": "StorageClass", "metadata": {"name": storage_class}, + "provisioner": "rancher.io/local-path", "reclaimPolicy": "Retain", "volumeBindingMode": "WaitForFirstConsumer"}) + self.command([sys.executable, "-B", ROOT / "hack/framework-e2e/verify-storage.py", "--kubeconfig", self.kubeconfig, + "--namespace", "storage-u05", "--output-dir", self.output / "storage", "--controller-binary", self.output / "storage-controller", + "--image", TRINO, "--node-name", self.cluster + "-control-plane", "--image-inventory", self.output / "fixture-image.json", + "--storage-class", storage_class, "--capacity", "64Mi", "--timeout", "900"], timeout=950) + require(json.loads((self.output / "storage/verification.json").read_text())["passed"], "storage acceptance failed") + + def dataops(self): + self.phase("explicit-data-identity-adoption-migration-destruction") + self.command([sys.executable, "-B", ROOT / "hack/framework-e2e/verify-dataops.py", + "--kubeconfig", self.kubeconfig, "--output-dir", self.output / "dataops", + "--storage-controller-binary", self.output / "storage-controller", + "--data-controller-binary", self.output / "data-controller", + "--image", TRINO, "--storage-class", self.cluster + "-retain", + "--node-name", self.cluster + "-control-plane"], timeout=1500) + report = json.loads((self.output / "dataops/verification.json").read_text()) + require(report["passed"] and report["cleanup"], "data operation acceptance or cleanup failed") + + def logging(self): + self.phase("central-logging-discovery-and-delivery") + self.command([sys.executable, "-B", ROOT / "hack/framework-e2e/verify-logging.py", + "--kubeconfig", self.kubeconfig, "--namespace", "logging-e03", + "--output-dir", self.output / "logging", "--controller-binary", self.output / "logging-controller", + "--image", TRINO, "--materializer-image", self.helper_image, "--vector-image", VECTOR, + "--timeout", "600"], timeout=780) + logging_report = json.loads((self.output / "logging/verification.json").read_text()) + require(logging_report["passed"] and logging_report["cleanup"], "central logging acceptance or cleanup failed") + + def close(self): + errors, stopped = [], [] + for process, stream in reversed(self.processes): + observation = {"pid": process.pid, "signal": None, "reaped": False} + try: + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + observation["signal"] = "TERM" + except ProcessLookupError: + pass + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + observation["signal"] = "KILL" + process.wait(timeout=5) + observation.update(returncode=process.wait(timeout=1), reaped=True) + except Exception as error: + errors.append("process cleanup: " + repr(error)) + observation["error"] = repr(error) + finally: + stream.close() + stopped.append(observation) + self.write("process-cleanup.json", stopped) + if self.created: + try: + self.kube("get", "pods,statefulsets,services,configmaps,trinoclusters", "-A", "-o", "json", check=False) + self.kube("get", "events", "-A", check=False) + if not self.report["passed"]: + self.kube("-n", OPERATOR_NS, "logs", "deployment/" + DEPLOYMENT, "--tail=500", check=False) + for role in ("coordinators", "workers"): + for container in ("prepare-files", "trino", "vector"): + self.kube("-n", "trino-u05", "logs", "demo-trino-" + role + "-default-0", "-c", container, "--tail=200", check=False) + except Exception as error: + errors.append("capture final state: " + repr(error)) + try: + self.command(["kind", "delete", "cluster", "--name", self.cluster, "--kubeconfig", self.kubeconfig], timeout=180) + except Exception as error: + errors.append("kind cleanup: " + repr(error)) + for image in reversed(self.images): + try: + self.command(["docker", "image", "rm", image], check=False) + self.command(["docker", "image", "inspect", image], check=False) + missing = self.last_command["returncode"] != 0 and "No such image:" in self.last_command["stderr"] + require(missing, "temporary image cleanup unconfirmed: " + image) + except Exception as error: + errors.append("image cleanup: " + repr(error)) + try: + after = context_snapshot() + self.write("context-after.json", after) + require(after == self.before, "default kubeconfig or existing kind cluster state changed") + except Exception as error: + errors.append("context verification: " + repr(error)) + self.report.update(cleanup=not errors, cleanup_errors=errors) + if errors: + self.report["passed"] = False + self.persist() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True) + parser.add_argument("--commons", default="~/workspace/git/github/zncdatadev/commons-operator") + args = parser.parse_args() + run = Run(args) + def interrupted(signum, _frame): + raise RuntimeError("interrupted by signal " + str(signum)) + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + try: + run.build() + run.start_cluster() + run.platform.install() + run.deploy() + LifecycleVerifier(run, "trino-u05", "demo-trino").verify() + AuthenticationVerifier(run, "trino-u05", "demo-trino").verify() + PlatformVerifier(run, "trino-u05", "demo-trino").verify() + S3Verifier(run, "trino-u05", "demo-trino", HIVE, MINIO, MINIO_MC).verify() + run.storage() + run.dataops() + run.logging() + run.report["passed"] = True + except Exception as error: + run.report["error"] = repr(error) + print("U05 failed:", error, file=sys.stderr, flush=True) + finally: + try: + run.close() + except Exception as error: + run.report.update(passed=False, cleanup_error=repr(error)) + run.persist() + print("Evidence:", run.output, "passed:", run.report["passed"], "cleanup:", run.report["cleanup"], flush=True) + raise SystemExit(0 if run.report["passed"] and run.report["cleanup"] else 1) + + +if __name__ == "__main__": + main() diff --git a/hack/framework-e2e/runtime.py b/hack/framework-e2e/runtime.py new file mode 100644 index 00000000..14dc292c --- /dev/null +++ b/hack/framework-e2e/runtime.py @@ -0,0 +1,287 @@ +"""Verify the delivered Trino operator through CR/API/process observations.""" +import copy +import json +import time + +RESOURCE = "trinoclusters.trino.kubedoop.dev" + + +class VerificationError(RuntimeError): + pass + + +def require(value, message): + if not value: + raise VerificationError(message) + + +def named(items, name): + return next((item for item in items if item.get("name") == name), None) + + +def current_condition(cr, name, value="True"): + # Kubernetes conditions identify their kind by type, not name. + condition = next((c for c in cr.get("status", {}).get("conditions", []) if c["type"] == name), None) + return bool(condition and condition.get("status") == value and + condition.get("observedGeneration") == cr["metadata"]["generation"]) + + +QUERY = r'''import json,sys,time,urllib.request +sql=sys.argv[1] +request=urllib.request.Request('http://127.0.0.1:8080/v1/statement',data=sql.encode(),headers={'X-Trino-User':'framework-e2e'}) +rows=[];query_id=None;end=time.monotonic()+90 +while True: + with urllib.request.urlopen(request,timeout=15) as response: result=json.load(response) + query_id=result.get('id',query_id) + if 'error' in result: raise RuntimeError(json.dumps(result['error'])) + rows.extend(result.get('data',[])) + if not result.get('nextUri'): break + if time.monotonic()>end: raise TimeoutError('query '+str(query_id)) + request=urllib.request.Request(result['nextUri'],headers={'X-Trino-User':'framework-e2e'}) + time.sleep(.2) +print(json.dumps({'query_id':query_id,'sql':sql,'rows':rows})) +''' + + +class RuntimeVerifier: + def __init__(self, run, namespace, name): + self.run, self.namespace, self.name = run, namespace, name + self.phases = [] + self.report = {"passed": False, "phases": self.phases, + "scope": "formal operator deployment, API state, Trino SQL and Vector delivery"} + self.persist() + + def persist(self): + self.run.write("runtime.json", self.report) + + def phase(self, name, evidence): + self.phases.append({"name": name, "evidence": evidence}) + self.persist() + print("PASS runtime phase:", name, flush=True) + + def kube(self, *args, **kwargs): + return self.run.kube("-n", self.namespace, *args, **kwargs) + + def get(self, kind, name): + result = self.kube("get", kind, name, "--ignore-not-found", "-o", "json") + return json.loads(result) if result.strip() else None + + def cr(self): + return self.get(RESOURCE, self.name) + + def patch(self, spec): + self.kube("patch", RESOURCE, self.name, "--type=merge", "-p", json.dumps({"spec": spec})) + + def until(self, label, observe, timeout=600): + end, error = time.monotonic() + timeout, None + while time.monotonic() < end: + try: + value = observe() + if value: + return value + except (VerificationError, KeyError, TypeError, ValueError) as caught: + error = str(caught) + time.sleep(2) + raise VerificationError(label + " timed out; last observation: " + str(error)) + + def healthy(self, roles=("coordinators", "workers"), previous=None): + cr = self.cr() + require(cr is not None, "cluster CR missing") + for condition in ("Built", "Applied", "WorkloadsReady", "RoleResourcesApplied"): + require(current_condition(cr, condition), "current " + condition + " is not true") + result = {"generation": cr["metadata"]["generation"], "cr_uid": cr["metadata"]["uid"], "cr_rv": cr["metadata"]["resourceVersion"], "groups": {}} + for role in roles: + name = self.name + "-" + role + "-default" + sts, pod, cm = self.get("statefulset", name), self.get("pod", name + "-0"), self.get("configmap", name) + require(sts and pod and cm, "missing runtime object for " + role) + observed = sts.get("status", {}) + require(not pod["metadata"].get("deletionTimestamp"), "Pod is terminating") + require(observed.get("observedGeneration", 0) >= sts["metadata"]["generation"] and + observed.get("currentRevision") == observed.get("updateRevision") and + all(observed.get(k) == 1 for k in ("replicas", "readyReplicas", "updatedReplicas")), + "StatefulSet has not converged") + require(pod["metadata"]["labels"].get("controller-revision-hash") == observed["updateRevision"], + "Pod has an old revision") + for container in pod.get("status", {}).get("containerStatuses", []): + require(container.get("ready") and container.get("restartCount") == 0, "container unready/restarted") + init = named(pod.get("status", {}).get("initContainerStatuses", []), "prepare-files") + require(init and init.get("state", {}).get("terminated", {}).get("exitCode") == 0, + "formal materializer did not complete") + for obj in (sts, cm): + owners = [o for o in obj["metadata"].get("ownerReferences", []) if o.get("controller")] + require(len(owners) == 1 and owners[0]["uid"] == cr["metadata"]["uid"], "object owner differs") + require("framework.kubedoop.dev/group-slot" in obj["metadata"].get("annotations", {}), + "formal slot receipt missing") + stamp = "configmap.restarter.kubedoop.dev/" + name + expected_stamp = cm["metadata"]["uid"] + "/" + cm["metadata"]["resourceVersion"] + require(sts["metadata"]["labels"].get("restarter.kubedoop.dev/enable") == "true", "restarter opt-in absent") + require(sts["spec"]["template"]["metadata"].get("annotations", {}).get(stamp) == expected_stamp and + pod["metadata"].get("annotations", {}).get(stamp) == expected_stamp, "configuration has not reached Pod") + result["groups"][role] = {"statefulset_uid": sts["metadata"]["uid"], "pod_uid": pod["metadata"]["uid"], + "statefulset_rv": sts["metadata"]["resourceVersion"], "configmap_rv": cm["metadata"]["resourceVersion"], + "pod": name + "-0", "stamp": expected_stamp} + if previous and role in previous["groups"]: + require(pod["metadata"]["uid"] != previous["groups"][role]["pod_uid"], "expected replacement Pod not observed") + return result + + def wait_healthy(self, roles=("coordinators", "workers"), previous=None): + return self.until("healthy " + repr(roles), lambda: self.healthy(roles, previous)) + + def exec(self, role, code, *args): + output = self.kube("exec", self.name + "-" + role + "-default-0", "-c", "trino", "--", "python3", "-c", code, *args, + timeout=120) + return json.loads(output) + + def query(self, sql): + return self.exec("coordinators", QUERY, sql) + + def wait_membership(self, snapshot): + expected = {g["pod_uid"] for g in snapshot["groups"].values()} + probes = [] + def membership(): + observation = self.query("SELECT node_id FROM system.runtime.nodes WHERE state = 'active'") + probes.append(observation) + return observation if {row[0] for row in observation["rows"]} == expected else None + node_ids = self.until("current native node identities", membership, timeout=120) + self.report.setdefault("membership_probes", []).append(probes) + self.persist() + return node_ids + + def prove_processes(self, snapshot): + node_ids = self.wait_membership(snapshot) + count = self.query("SELECT count(*) FROM tpch.tiny.nation") + require(count["rows"] == [[25]], "TPCH did not complete on the recovered cluster") + return {"node_ids": node_ids, "count": count} + + def prove_logs(self): + lines = self.exec("workers", "import json,pathlib; print(json.dumps(pathlib.Path('/kubedoop/log/trino/server.json').read_text().splitlines()))") + native = [line for line in lines if line.startswith("{")] + require(native, "Trino has no native JSON file events") + def collected(): + logs = self.kube("logs", self.name + "-workers-default-0", "-c", "vector", "--tail=1000") + messages = [] + for line in logs.splitlines(): + try: + record = json.loads(line) + except ValueError: + continue + if record.get("message") in native and record.get("file") == "/logs/log/server.json": + messages.append(record) + return messages + events = self.until("Vector native-file delivery", collected, timeout=90) + return events[0] + + def verify(self): + baseline = self.wait_healthy() + self.phase("baseline", {"resources": baseline, "sql": self.prove_processes(baseline), "collected_event": self.prove_logs()}) + previous = baseline + patches = { + "file": {"configOverrides": {"config.properties": {"properties": {"set": {"query.max-memory": "320MB"}}}}}, + "env": {"envOverrides": {"FRAMEWORK_UPDATE_ENV": "env-v2"}}, + "cli": {"cliOverrides": ["--etc-dir=/etc/trino", "-D", "operator.go.update=cli-v2", "run"]}, + "pod": {"podOverrides": {"spec": {"containers": [{"name": "trino", "env": [{"name": "FRAMEWORK_UPDATE_POD", "value": "pod-v2"}]}]}}}, + } + for phase, patch in patches.items(): + self.patch({"workers": {"roleGroups": {"default": patch}}}) + current = self.wait_healthy(previous={"groups": {"workers": previous["groups"]["workers"]}}) + observation = self.exec("workers", r'''import json,os,pathlib +jvm=[] +for process in pathlib.Path('/proc').iterdir(): + if process.name.isdigit(): + try: argv=[part.decode() for part in (process/'cmdline').read_bytes().split(b'\0') if part] + except OSError: continue + if argv and pathlib.Path(argv[0]).name=='java' and 'io.trino.server.TrinoServer' in argv: jvm.append(' '.join(argv)) +print(json.dumps({'config':pathlib.Path('/etc/trino/config.properties').read_text(),'env':{key:os.getenv(key) for key in ('FRAMEWORK_UPDATE_ENV','FRAMEWORK_UPDATE_POD','TRINO_NODE_ID')},'jvm':jvm})) +''') + if phase == "file": require("query.max-memory=320MB\n" in observation["config"], "file update absent") + if phase == "env": require(observation["env"].get("FRAMEWORK_UPDATE_ENV") == "env-v2", "env update absent") + if phase == "cli": require(any("-Doperator.go.update=cli-v2" in line for line in observation["jvm"]), "CLI not consumed by JVM") + if phase == "pod": require(observation["env"].get("FRAMEWORK_UPDATE_POD") == "pod-v2", "Pod env update absent") + self.phase("override-" + phase, {"resources": current, "process": observation}) + previous = current + self.verify_catalogs() + self.verify_pause_stop() + self.verify_retirement() + final = self.wait_healthy() + self.phase("final-business", {"resources": final, "sql": self.prove_processes(final), "collected_event": self.prove_logs()}) + time.sleep(35) # Includes a full default facts refresh interval. + stable = self.healthy() + require(stable == final, "stable resources changed during a full refresh interval") + self.phase("steady", {"seconds": 35, "unchanged": stable}) + self.report["passed"] = True + self.persist() + + def verify_catalogs(self): + catalogs = {"tpch": {"connector.name": "tpch"}} + self.run.apply({"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "runtime-catalogs", "namespace": self.namespace}, + "data": {"catalogs.json": json.dumps(catalogs)}}) + self.patch({role: {"config": {"catalogConfigMapName": "runtime-catalogs"}} for role in ("coordinators", "workers")}) + self.wait_healthy() + before = self.cr()["metadata"]["generation"] + catalogs["tpch_extra"] = {"connector.name": "tpch"} + self.kube("patch", "configmap", "runtime-catalogs", "--type=merge", "-p", json.dumps({"data": {"catalogs.json": json.dumps(catalogs)}})) + def delivered(): + snapshot = self.healthy() + for role in ("coordinators", "workers"): + values = self.exec(role, "import json,pathlib; print(json.dumps([p.name for p in pathlib.Path('/etc/trino/catalog').glob('*.properties')]))") + require("tpch_extra.properties" in values, "catalog not yet materialized in " + role) + return snapshot + current = self.until("external catalog refresh", delivered) + require(self.cr()["metadata"]["generation"] == before, "catalog refresh required a CR spec change") + # HTTP readiness and materialized files precede SQL access-control readiness. + # Wait through explicit membership probes; execute the business query once. + node_ids = self.wait_membership(current) + sql = self.query("SELECT count(*) FROM tpch_extra.tiny.nation") + require(sql["rows"] == [[25]], "new catalog not consumed by coordinator and worker") + self.phase("catalog-refresh", {"generation_unchanged": before, "resources": current, "node_ids": node_ids, "query": sql}) + + def verify_pause_stop(self): + before = self.wait_healthy() + self.patch({"clusterConfig": {"reconciliationPaused": True}}) + self.until("paused", lambda: current_condition(self.cr(), "Paused")) + self.patch({"workers": {"roleGroups": {"default": {"envOverrides": {"FRAMEWORK_PAUSED_EDIT": "latest"}}}}}) + self.until("paused latest generation", lambda: current_condition(self.cr(), "Paused")) + time.sleep(5) + for role, state in before["groups"].items(): + sts = self.get("statefulset", self.name + "-" + role + "-default") + require(sts["metadata"]["resourceVersion"] == state["statefulset_rv"], "paused controller changed workload") + self.patch({"clusterConfig": {"reconciliationPaused": False}}) + resumed = self.wait_healthy(previous={"groups": {"workers": before["groups"]["workers"]}}) + require(self.exec("workers", "import os,json; print(json.dumps(os.getenv('FRAMEWORK_PAUSED_EDIT')))") == "latest", + "resume did not consume latest CR") + self.phase("pause-resume", resumed) + self.patch({"clusterConfig": {"stopped": True}}) + def stopped(): + cr = self.cr() + require(current_condition(cr, "Stopped"), "controller has not observed fully stopped") + pods = json.loads(self.kube("get", "pods", "-l", "app.kubernetes.io/instance=" + self.name, "-o", "json")) + require(not pods["items"], "stopped still has actual Pods") + for role in ("coordinators", "workers"): + require(self.get("statefulset", self.name + "-" + role + "-default")["spec"]["replicas"] == 0, + "stopped workload still has execution replicas") + return cr["status"] + observation = self.until("fully stopped", stopped) + self.patch({"clusterConfig": {"stopped": False}}) + resumed = self.wait_healthy(previous=resumed) + self.phase("stop-resume", {"stopped_status": observation, "resources": resumed, "sql": self.prove_processes(resumed)}) + + def verify_retirement(self): + before = self.wait_healthy() + group = copy.deepcopy(self.cr()["spec"]["workers"]["roleGroups"]["default"]) + self.patch({"workers": {"roleGroups": {"default": None}}}) + name = self.name + "-workers-default" + def retired(): + require(current_condition(self.cr(), "Retired"), "retirement not yet observed") + for kind, resource in (("pod", name + "-0"), ("statefulset", name), ("service", name), + ("service", name + "-headless"), ("configmap", name)): + require(self.get(kind, resource) is None, "retired resource remains: " + kind + "/" + resource) + return True + self.until("worker retirement", retired) + self.run.restart_operator() + self.patch({"workers": {"roleGroups": {"default": group}}}) + after = self.wait_healthy() + require(after["groups"]["workers"]["statefulset_uid"] != before["groups"]["workers"]["statefulset_uid"], + "retired workload was not recreated") + require(after["groups"]["coordinators"]["pod_uid"] == before["groups"]["coordinators"]["pod_uid"], + "unrelated coordinator was recreated") + self.phase("retirement-restart-readd", {"before": before, "after": after}) diff --git a/hack/framework-e2e/s3.py b/hack/framework-e2e/s3.py new file mode 100644 index 00000000..8112dc49 --- /dev/null +++ b/hack/framework-e2e/s3.py @@ -0,0 +1,257 @@ +"""Real Trino 476 -> Hive Metastore -> MinIO acceptance for the S3 domain. + +Uses the delivered Trino CR/operator after platform CSI installation. Saves and +restores that CR's spec; owns only uniquely named experiment dependencies and +bucket bytes. Secret values are sent to kubectl stdin, never written to reports. +""" +import copy +import json +import pathlib +import uuid +import xml.etree.ElementTree as ET + +from runtime import RuntimeVerifier, require + + +class S3Verifier(RuntimeVerifier): + def __init__(self, run, namespace, name, hive_image, minio_image, mc_image): + self.run, self.namespace, self.name = run, namespace, name + self.hive_image, self.minio_image, self.mc_image = hive_image, minio_image, mc_image + self.prefix = "s3-e02-" + uuid.uuid4().hex[:8] + self.phases, self.owned = [], [] + self.report = {"passed": False, "cleanup": False, "phases": self.phases, + "scope": "Trino 476 native S3 writes/reads, inline inheritance, live S3Connection reference and SecretClass CSI"} + self.original_spec = None + self.persist() + + def persist(self): + self.run.write("s3.json", self.report) + + def secretclass_receipt(self, group, class_name): + pod = self.get("pod", group["pod"]) + require(pod and pod["metadata"]["uid"] == group["pod_uid"] and + not pod["metadata"].get("deletionTimestamp"), "SecretClass observation changed Pod identity") + volume = next((v for v in pod["spec"]["volumes"] if v["name"] == "s3-credentials"), {}) + template = volume.get("ephemeral", {}).get("volumeClaimTemplate", {}) + require(template.get("metadata", {}).get("annotations", {}).get("secrets.kubedoop.dev/class") == class_name and + template.get("spec", {}).get("storageClassName") == "secrets.kubedoop.dev", + "reference did not declare the expected SecretClass ephemeral volume") + claim = self.get("pvc", group["pod"] + "-s3-credentials") + require(claim and not claim["metadata"].get("deletionTimestamp") and + claim.get("status", {}).get("phase") == "Bound", "SecretClass PVC is not bound") + owners = [o for o in claim["metadata"].get("ownerReferences", []) if o.get("controller")] + require(len(owners) == 1 and owners[0].get("kind") == "Pod" and owners[0].get("uid") == group["pod_uid"], + "SecretClass PVC does not belong to the observed Pod") + pv = self.get("pv", claim["spec"]["volumeName"]) + require(pv and not pv["metadata"].get("deletionTimestamp") and + pv.get("status", {}).get("phase") == "Bound", "SecretClass PV is not bound") + ref = pv["spec"].get("claimRef", {}) + require(all(ref.get(key) == claim["metadata"][key] for key in ("name", "namespace", "uid")), + "SecretClass PVC/PV binding identity changed") + driver = pv["spec"].get("csi", {}).get("driver") + require(driver == "secrets.kubedoop.dev", "reference did not consume actual SecretClass CSI") + return {"pod_uid": group["pod_uid"], "secret_class": class_name, + "pvc_uid": claim["metadata"]["uid"], "pv_uid": pv["metadata"]["uid"], "driver": driver} + + def create(self, kind, name, body, api_version="v1", labels=None, cluster=False): + require(self.get(kind, name) is None, "Refusing to overwrite pre-existing dependency " + kind + "/" + name) + metadata = {"name": name} + if not cluster: + metadata["namespace"] = self.namespace + if labels: + metadata["labels"] = labels + self.run.apply({"apiVersion": api_version, "kind": kind, "metadata": metadata, **body}) + self.owned.append((kind, name)) + + def secret_env(self, variable, key): + return {"name": variable, "valueFrom": {"secretKeyRef": {"name": self.prefix + "-credentials", "key": key}}} + + def dependency_pod(self, name, image, command, args, env=None, volumes=None, mounts=None, port=None, memory="512Mi"): + container = {"name": "main", "image": image, "command": command, "args": args, + "resources": {"requests": {"cpu": "50m", "memory": "128Mi"}, "limits": {"cpu": "1", "memory": memory}}} + if env: + container["env"] = env + if mounts: + container["volumeMounts"] = mounts + if port: + container["readinessProbe"] = {"tcpSocket": {"port": port}, "periodSeconds": 2} + self.create("Pod", name, {"spec": {"securityContext": {"fsGroup": 1000}, + "containers": [container], "volumes": volumes or [], "restartPolicy": "Never"}}, labels={"fixture": name}) + self.until("dependency " + name, lambda: any(c.get("ready") for c in + self.get("pod", name).get("status", {}).get("containerStatuses", [])), timeout=240) + + def service(self, name, selected, port): + self.create("Service", name, {"spec": {"selector": {"fixture": selected}, + "ports": [{"name": "service", "port": port, "targetPort": port}]}}) + + def mc(self, suffix, operation): + name = self.prefix + "-" + suffix + endpoint = "http://" + self.prefix + "-minio:9000" + self.create("Pod", name, {"spec": {"restartPolicy": "Never", "containers": [{"name": "mc", "image": self.mc_image, + "command": ["/bin/sh", "-ec"], "args": [ + 'mc alias set target ' + endpoint + ' "$ACCESS_KEY" "$SECRET_KEY" >/dev/null\n' + operation], + "env": [self.secret_env("ACCESS_KEY", "ACCESS_KEY"), self.secret_env("SECRET_KEY", "SECRET_KEY")], + "resources": {"requests": {"cpu": "25m", "memory": "32Mi"}, "limits": {"cpu": "500m", "memory": "128Mi"}}}]}}) + def complete(): + pod = self.get("pod", name) + phase = pod.get("status", {}).get("phase") + require(phase != "Failed", "MinIO client failed: " + self.kube("logs", name, "-c", "mc") if phase == "Failed" else "") + return phase == "Succeeded" + self.until("MinIO client " + suffix, complete, timeout=90) + return self.kube("logs", name, "-c", "mc") + + def dependencies(self): + class_name = self.prefix + "-class" + self.create("SecretClass", class_name, {"spec": {"backend": {"k8sSearch": {"searchNamespace": {"pod": {}}}}}}, + api_version="secrets.kubedoop.dev/v1alpha1", cluster=True) + self.create("Secret", self.prefix + "-credentials", {"stringData": { + "ACCESS_KEY": "e02" + uuid.uuid4().hex, "SECRET_KEY": uuid.uuid4().hex + uuid.uuid4().hex}}, + labels={"secrets.kubedoop.dev/class": class_name}) + minio = self.prefix + "-minio" + self.service(minio, minio, 9000) + self.service(self.prefix + "-minio-alt", minio, 9000) + self.dependency_pod(minio, self.minio_image, ["minio"], ["server", "/data", "--console-address", ":9001"], + env=[self.secret_env("MINIO_ROOT_USER", "ACCESS_KEY"), self.secret_env("MINIO_ROOT_PASSWORD", "SECRET_KEY")], + volumes=[{"name": "data", "emptyDir": {}}], mounts=[{"name": "data", "mountPath": "/data"}], port=9000) + self.mc("bucket", "mc mb target/warehouse") + properties = { + "javax.jdo.option.ConnectionURL": "jdbc:derby:;databaseName=/tmp/metastore_db;create=true", + "javax.jdo.option.ConnectionDriverName": "org.apache.derby.jdbc.EmbeddedDriver", + "javax.jdo.option.ConnectionUserName": "APP", "javax.jdo.option.ConnectionPassword": "mine", + "hive.metastore.warehouse.dir": "file:///tmp/warehouse", "hive.metastore.schema.verification": "true", + "fs.s3.impl": "org.apache.hadoop.fs.s3a.S3AFileSystem", "fs.s3a.impl": "org.apache.hadoop.fs.s3a.S3AFileSystem", + "fs.s3a.endpoint": "http://" + minio + ":9000", "fs.s3a.endpoint.region": "us-east-1", + "fs.s3a.path.style.access": "true", "fs.s3a.connection.ssl.enabled": "false", + "fs.s3a.access.key": "__ACCESS_KEY__", "fs.s3a.secret.key": "__SECRET_KEY__", + "fs.s3a.aws.credentials.provider": "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider", + } + root = ET.Element("configuration") + for key, value in properties.items(): + prop = ET.SubElement(root, "property") + ET.SubElement(prop, "name").text = key + ET.SubElement(prop, "value").text = value + config = ET.tostring(root, encoding="unicode") + self.create("ConfigMap", self.prefix + "-hive-config", {"data": {"hive-site.xml": config}}) + metastore = self.prefix + "-metastore" + self.service(metastore, metastore, 9083) + # Experiment keys contain only alphanumerics. The replacement happens + # inside the container's writable config; generated CM has placeholders. + script = ('mkdir -p /tmp/hive-conf\ncp /input/hive-site.xml /tmp/hive-conf/hive-site.xml\n' + 'sed -i "s/__ACCESS_KEY__/$ACCESS_KEY/g;s/__SECRET_KEY__/$SECRET_KEY/g" /tmp/hive-conf/hive-site.xml\n' + 'exec /kubedoop/hive-metastore/bin/start-metastore --config /tmp/hive-conf --db-type derby ' + '--hive-bin-dir /kubedoop/hive-metastore/bin') + self.dependency_pod(metastore, self.hive_image, ["/bin/bash", "-ec"], [script], + env=[self.secret_env("ACCESS_KEY", "ACCESS_KEY"), self.secret_env("SECRET_KEY", "SECRET_KEY"), + {"name": "HADOOP_HEAPSIZE", "value": "256"}], + volumes=[{"name": "config", "configMap": {"name": self.prefix + "-hive-config"}}], + mounts=[{"name": "config", "mountPath": "/input", "readOnly": True}], port=9083, memory="768Mi") + return class_name, minio, metastore + + def replace_spec(self, spec): + self.kube("patch", "trinoclusters", self.name, "--type=json", "-p", + json.dumps([{"op": "replace", "path": "/spec", "value": spec}])) + + def query_ready(self, sql): + return self.until("S3 query " + sql, lambda: self.query(sql), timeout=120) + + def observe_files(self, suffix): + output = self.mc(suffix, "mc ls --json --recursive target/warehouse") + files = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + require(any(record.get("size", 0) > 0 for record in files), "No nonempty S3 object was observed independently") + return files + + def refreshed_write_read(self, previous_objects): + # Hive 4 creates this fixture's tables as non-managed. A new CTAS proves + # fresh S3 writes without enabling external-table modification globally. + self.query("CREATE TABLE hive.e02.marker_refreshed AS SELECT BIGINT '84' AS value") + selected = self.query_ready("SELECT value FROM hive.e02.marker UNION ALL " + "SELECT value FROM hive.e02.marker_refreshed ORDER BY value") + require(selected["rows"] == [[42], [84]], "refreshed reference did not read original and newly written S3 data") + objects = self.observe_files("objects-refreshed") + previous = {o["key"] for o in previous_objects if o.get("size", 0) > 0} + current = {o["key"] for o in objects if o.get("size", 0) > 0} + require(previous and previous.issubset(current), "original S3 objects disappeared after reference refresh") + require(any(key.startswith("e02/marker_refreshed/") for key in current - previous), + "refreshed CTAS did not produce a new independently observed S3 object") + return selected, objects + + def verify(self): + self.original_spec = copy.deepcopy(self.cr()["spec"]) + try: + commons = pathlib.Path(self.run.args.commons).expanduser().resolve() + self.run.kube("apply", "-f", commons / "config/crd/bases/s3.kubedoop.dev_s3connections.yaml") + self.run.wait_crd_established("s3connections.s3.kubedoop.dev") + class_name, minio, metastore = self.dependencies() + baseline = self.wait_healthy() + spec = copy.deepcopy(self.original_spec) + for role in ("coordinators", "workers"): + spec[role].setdefault("config", {})["hive"] = {"metastoreURI": "thrift://" + metastore + ":9083", + "s3": {"type": "inline", "inline": {"host": minio, "port": 9000, "region": "us-east-1", + "pathStyle": False, "credentials": {"secretName": self.prefix + "-credentials"}}}} + spec[role]["roleGroups"]["default"].setdefault("config", {})["hive"] = {"s3": {"inline": {"pathStyle": True}}} + self.replace_spec(spec) + inline = self.wait_healthy(previous=baseline) + self.wait_membership(inline) + self.query("CREATE SCHEMA hive.e02 WITH (location = 's3://warehouse/e02/')") + self.query("CREATE TABLE hive.e02.marker AS SELECT BIGINT '42' AS value") + selected = self.query_ready("SELECT value FROM hive.e02.marker") + require(selected["rows"] == [[42]], "Trino did not read its native S3 write") + inline_objects = self.observe_files("objects-inline") + self.phase("inline-inheritance-native-s3-write-read", {"resources": inline, "query": selected, + "objects": inline_objects}) + connection_name = self.prefix + "-connection" + connection = {"host": minio, "port": 9000, "region": "us-east-1", "pathStyle": True, + "credentials": {"secretClass": class_name}} + self.create("S3Connection", connection_name, {"spec": connection}, api_version="s3.kubedoop.dev/v1alpha1") + for role in ("coordinators", "workers"): + spec[role]["roleGroups"]["default"]["config"]["hive"]["s3"] = {"type": "reference", "reference": connection_name} + self.replace_spec(spec) + reference = self.wait_healthy(previous=inline) + self.wait_membership(reference) + selected = self.query_ready("SELECT value FROM hive.e02.marker") + require(selected["rows"] == [[42]], "SecretClass-backed reference could not read the original object") + csi = {role: self.secretclass_receipt(reference["groups"][role], class_name) + for role in ("coordinators", "workers")} + self.phase("branch-switch-reference-secretclass-read", {"resources": reference, "query": selected, + "connection": self.get("s3connection", connection_name), "csi": csi}) + generation = self.cr()["metadata"]["generation"] + self.kube("patch", "s3connection", connection_name, "--type=merge", "-p", json.dumps({"spec": {"host": self.prefix + "-minio-alt"}})) + refreshed = self.wait_healthy(previous=reference) + self.wait_membership(refreshed) + selected, refreshed_objects = self.refreshed_write_read(inline_objects) + cr, connection_object = self.cr(), self.get("s3connection", connection_name) + require(cr["metadata"]["generation"] == generation, "S3 reference refresh edited the cluster CR") + for group in cr["status"]["groups"]: + observations = group.get("facts", {}).get("observed", []) + require(any(o.get("kind") == "S3Connection" and o.get("uid") == connection_object["metadata"]["uid"] and + o.get("resourceVersion") == connection_object["metadata"]["resourceVersion"] for o in observations), + "S3 dependency refresh lacks actual object provenance") + self.phase("reference-only-refresh-write-read", {"resources": refreshed, "query": selected, + "objects": refreshed_objects, "cr_generation": generation}) + self.report["passed"] = True + finally: + self.close() + require(self.report["passed"] and self.report["cleanup"], "S3 acceptance or cleanup failed") + + def close(self): + errors = [] + if self.original_spec is not None: + try: + self.kube("patch", "trinoclusters", self.name, "--type=json", "-p", + json.dumps([{"op": "replace", "path": "/spec", "value": self.original_spec}])) + restored = self.wait_healthy() + self.report["restored"] = restored + except Exception as error: + errors.append("restore original Trino: " + repr(error)) + for kind, name in reversed(self.owned): + try: + if kind == "Pod": + self.kube("logs", name, "--all-containers=true", check=False) + self.kube("delete", kind, name, "--wait=true", "--timeout=90s", timeout=110) + require(self.get(kind, name) is None, "dependency deletion not confirmed") + except Exception as error: + errors.append(kind + "/" + name + ": " + repr(error)) + self.report.update(cleanup=not errors, cleanup_errors=errors) + if errors: + self.report["passed"] = False + self.persist() diff --git a/hack/framework-e2e/test_lifecycle.py b/hack/framework-e2e/test_lifecycle.py new file mode 100644 index 00000000..1a384b1d --- /dev/null +++ b/hack/framework-e2e/test_lifecycle.py @@ -0,0 +1,65 @@ +"""The business verifier must keep one query identity across transient GET loss.""" +import contextlib +import io +import json +import unittest +from unittest import mock + +from lifecycle import QUERY + + +class QueryReplayTests(unittest.TestCase): + def run_query(self, responses): + calls = [] + + def open_request(request, **options): + calls.append((request.get_method(), request.full_url, options['timeout'])) + value = responses.pop(0) + if isinstance(value, Exception): + raise value + return io.StringIO(json.dumps(value)) + + output = io.StringIO() + with mock.patch('sys.argv', ['query', 'SELECT count(*) FROM original']), \ + mock.patch('urllib.request.urlopen', side_effect=open_request), \ + mock.patch('time.sleep'), contextlib.redirect_stdout(output): + exec(compile(QUERY, 'lifecycle-query', 'exec'), {}) + return calls, [json.loads(line) for line in output.getvalue().splitlines()] + + def test_timeout_replays_only_same_get_and_preserves_rows(self): + uri = 'http://127.0.0.1:8080/v1/statement/executing/original/token/1' + calls, events = self.run_query([ + {'id': 'original', 'nextUri': uri}, + TimeoutError('transient GET loss'), + {'id': 'original', 'data': [[64]]}, + ]) + self.assertEqual([call[:2] for call in calls], [ + ('POST', 'http://127.0.0.1:8080/v1/statement'), ('GET', uri), ('GET', uri)]) + self.assertTrue(all(call[2] <= 10 for call in calls)) + self.assertEqual(events[-1], {'query_id': 'original', 'rows': [[64]], 'complete': True}) + self.assertEqual(sum(event.get('transfer_error') == 'TimeoutError' for event in events), 1) + + def test_uncertain_submit_is_not_repeated(self): + with self.assertRaises(TimeoutError): + self.run_query([TimeoutError('POST result unknown')]) + + def test_server_query_failure_is_not_replaced_with_another_query(self): + with self.assertRaises(SystemExit) as exited: + self.run_query([{'id': 'original', 'error': {'message': 'business failure'}}]) + self.assertEqual(exited.exception.code, 1) + + def test_transport_retry_keeps_total_deadline(self): + # Submission resolves, one GET times out, and the original total budget + # expires. Retrying does not create a new deadline or a new query. + with mock.patch('time.monotonic', side_effect=[0, 0, 0, 0, 0, 0, 1, 121]): + with self.assertRaisesRegex(TimeoutError, 'total deadline'): + self.run_query([{'id': 'original', 'nextUri': 'http://127.0.0.1/original/1'}, + TimeoutError('GET timeout')]) + + def test_changed_query_identity_fails(self): + with self.assertRaisesRegex(RuntimeError, 'changed query identity'): + self.run_query([{'id': 'original', 'nextUri': 'http://127.0.0.1/original/1'}, {'id': 'different'}]) + + +if __name__ == '__main__': + unittest.main() diff --git a/hack/framework-e2e/test_platform_csi.py b/hack/framework-e2e/test_platform_csi.py new file mode 100644 index 00000000..577d0a51 --- /dev/null +++ b/hack/framework-e2e/test_platform_csi.py @@ -0,0 +1,54 @@ +"""Bounded registry retry and evidence retention; no live registry access.""" +import copy +import unittest +from unittest import mock + +from platform_csi import PlatformInstaller +from runtime import VerificationError + + +class PullTests(unittest.TestCase): + def installer(self, outcomes): + run = mock.Mock(command_index=0) + receipts = iter(outcomes) + + def command(argv, **kwargs): + self.assertEqual(argv, ['docker', 'pull', '--platform=linux/arm64', 'registry/image:v1']) + self.assertEqual(kwargs, {'timeout': 600, 'check': False}) + run.command_index += 1 + run.last_command = next(receipts) + + run.command.side_effect = command + installer = PlatformInstaller.__new__(PlatformInstaller) + installer.run, installer.report = run, {} + run.write.side_effect = lambda _, report: snapshots.append(copy.deepcopy(report)) + snapshots = [] + return installer, run, snapshots + + @mock.patch('platform_csi.time.sleep') + def test_transient_failure_preserves_attempts_then_succeeds(self, sleep): + installer, run, snapshots = self.installer([ + {'returncode': 124, 'timed_out': True, 'stderr': 'TLS handshake timeout'}, + {'returncode': 0, 'stderr': ''}, + ]) + installer.pull_image('registry/image:v1') + self.assertEqual(run.command.call_count, 2) + self.assertEqual(sleep.call_args_list, [mock.call(2)]) + self.assertEqual(len(snapshots[0]['image_pull_attempts']), 1) + attempts = snapshots[-1]['image_pull_attempts'] + self.assertEqual([v['command'] for v in attempts], ['00001', '00002']) + self.assertEqual([v['returncode'] for v in attempts], [124, 0]) + self.assertTrue(attempts[0]['timed_out']) + + @mock.patch('platform_csi.time.sleep') + def test_exhaustion_is_bounded_and_failure_remains_visible(self, sleep): + installer, run, snapshots = self.installer([{'returncode': 1, 'stderr': 'registry unavailable'}] * 3) + with self.assertRaisesRegex(VerificationError, 'exhausted 3 attempts.*registry unavailable'): + installer.pull_image('registry/image:v1') + self.assertEqual(run.command.call_count, 3) + self.assertEqual(sleep.call_args_list, [mock.call(2), mock.call(4)]) + self.assertEqual(len(snapshots[-1]['image_pull_attempts']), 3) + + +if __name__ == '__main__': + unittest.main() diff --git a/hack/framework-e2e/test_run.py b/hack/framework-e2e/test_run.py new file mode 100644 index 00000000..aedc0042 --- /dev/null +++ b/hack/framework-e2e/test_run.py @@ -0,0 +1,81 @@ +"""CRD establishment polling handles initial API state without mutating it.""" +import copy +import json +import unittest +from unittest import mock + +from run import Run +from runtime import VerificationError + + +class EstablishmentTests(unittest.TestCase): + name = "trinoclusters.trino.kubedoop.dev" + + def observer(self, responses, duration=0): + observer = Run.__new__(Run) + observer.command_index = 0 + self.now = 0 + self.snapshots = [] + outcomes = iter(responses) + + def kube(*args, **kwargs): + self.assertEqual(args[:7], ("get", "crd", self.name, "--ignore-not-found", "-o", "json", + f"--request-timeout={kwargs['timeout']:.9f}s")) + self.assertFalse(kwargs["check"]) + self.assertLessEqual(kwargs["timeout"], 60 - self.now) + observer.command_index += 1 + code, body, error = next(outcomes) + observer.last_command = {"returncode": code, "stderr": error} + self.now += min(duration, kwargs["timeout"]) + return body + + observer.kube = mock.Mock(side_effect=kube) + observer.write = lambda _, data: self.snapshots.append(copy.deepcopy(data)) + return observer + + def poll(self, observer): + def sleep(seconds): + self.now += seconds + + with mock.patch("run.time.monotonic", side_effect=lambda: self.now), mock.patch("run.time.sleep", side_effect=sleep): + observer.wait_crd_established(self.name) + + def test_absent_null_and_other_conditions_wait_for_exact_established_true(self): + values = ["", {}, {"status": None}, {"status": {"conditions": None}}, + {"status": {"conditions": [{"type": "NamesAccepted", "status": "True"}]}}, + {"status": {"conditions": [{"type": "Established", "status": "False", "reason": "Installing"}]}}, + {"status": {"conditions": [{"type": "Established", "status": "True"}]}}] + observer = self.observer([(0, json.dumps(value) if value != "" else "", "") for value in values]) + self.poll(observer) + report = self.snapshots[-1] + self.assertTrue(report["established"]) + self.assertEqual([a["state"] for a in report["attempts"]], ["Pending"] * 6 + ["Established"]) + self.assertEqual(observer.kube.call_count, 7) + self.assertEqual(report["attempts"][5]["conditions"][0]["reason"], "Installing") + + def test_api_and_malformed_response_errors_are_preserved_before_success(self): + ready = json.dumps({"status": {"conditions": [{"type": "Established", "status": "True"}]}}) + observer = self.observer([(1, "", "apiserver unavailable"), (0, "not json", ""), + (0, '{"status":{"conditions":{}}}', ""), (0, ready, "")]) + self.poll(observer) + attempts = self.snapshots[-1]["attempts"] + self.assertEqual([a["state"] for a in attempts], ["ReadError"] * 3 + ["Established"]) + self.assertEqual(attempts[0]["error"], "apiserver unavailable") + self.assertEqual(attempts[0]["command"], "00001") + self.assertIn("list or null", attempts[2]["error"]) + + def test_deadline_bounds_reads_and_retains_last_failure(self): + observer = self.observer([(1, "", "connection refused")] * 6, duration=10) + with self.assertRaisesRegex(VerificationError, "within 60 seconds.*connection refused"): + self.poll(observer) + self.assertEqual(self.now, 60) + self.assertEqual(observer.kube.call_count, 6) + self.assertEqual(observer.kube.call_args.kwargs["timeout"], 5) + report = self.snapshots[-1] + self.assertFalse(report["established"]) + self.assertEqual(report["elapsed_seconds"], 60) + self.assertEqual(len(report["attempts"]), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/hack/framework-e2e/test_s3.py b/hack/framework-e2e/test_s3.py new file mode 100644 index 00000000..3c4ce10b --- /dev/null +++ b/hack/framework-e2e/test_s3.py @@ -0,0 +1,72 @@ +import copy +import unittest +from unittest import mock + +from runtime import VerificationError +from s3 import S3Verifier + + +class SecretClassReceiptTests(unittest.TestCase): + def setUp(self): + self.group = {"pod": "worker-0", "pod_uid": "current-pod"} + self.pod = {"metadata": {"uid": "current-pod"}, "spec": {"volumes": [{"name": "s3-credentials", + "ephemeral": {"volumeClaimTemplate": {"metadata": {"annotations": {"secrets.kubedoop.dev/class": "s3-class"}}, + "spec": {"storageClassName": "secrets.kubedoop.dev"}}}}]}} + self.claim = {"metadata": {"name": "worker-0-s3-credentials", "namespace": "fixture", "uid": "current-pvc", + "ownerReferences": [{"kind": "Pod", "controller": True, "uid": "current-pod"}]}, + "spec": {"volumeName": "csi-volume"}, "status": {"phase": "Bound"}} + self.pv = {"metadata": {"name": "csi-volume", "uid": "current-pv"}, + "spec": {"claimRef": {"name": "worker-0-s3-credentials", "namespace": "fixture", "uid": "current-pvc"}, + "csi": {"driver": "secrets.kubedoop.dev"}}, "status": {"phase": "Bound"}} + self.verifier = object.__new__(S3Verifier) + self.verifier.get = lambda kind, _name: {"pod": self.pod, "pvc": self.claim, "pv": self.pv}[kind] + + def test_actual_ephemeral_pvc_pv_chain_proves_csi(self): + receipt = self.verifier.secretclass_receipt(self.group, "s3-class") + self.assertEqual(receipt, {"pod_uid": "current-pod", "secret_class": "s3-class", + "pvc_uid": "current-pvc", "pv_uid": "current-pv", "driver": "secrets.kubedoop.dev"}) + self.assertNotIn("csi", self.pod["spec"]["volumes"][0]) + + def test_stale_pod_or_different_pv_does_not_prove_consumption(self): + for kind in ("pod", "pvc", "driver"): + with self.subTest(kind=kind): + pod, claim, pv = copy.deepcopy((self.pod, self.claim, self.pv)) + if kind == "pod": + pod["metadata"]["uid"] = "old-pod" + elif kind == "pvc": + pv["spec"]["claimRef"]["uid"] = "other-pvc" + else: + pv["spec"]["csi"]["driver"] = "unrelated-driver" + self.verifier.get = lambda resource, _name: {"pod": pod, "pvc": claim, "pv": pv}[resource] + with self.assertRaises(VerificationError): + self.verifier.secretclass_receipt(self.group, "s3-class") + + +class RefreshedWriteTests(unittest.TestCase): + def setUp(self): + self.verifier = object.__new__(S3Verifier) + self.previous = [{"key": "e02/marker/original", "size": 246}] + self.objects = self.previous + [{"key": "e02/marker_refreshed/new", "size": 246}] + self.verifier.query = mock.Mock() + self.verifier.query_ready = mock.Mock(return_value={"rows": [[42], [84]]}) + self.verifier.observe_files = mock.Mock(return_value=self.objects) + + def test_single_ctas_reads_both_tables_and_observes_new_object(self): + selected, objects = self.verifier.refreshed_write_read(self.previous) + self.verifier.query.assert_called_once_with("CREATE TABLE hive.e02.marker_refreshed AS SELECT BIGINT '84' AS value") + self.verifier.query_ready.assert_called_once_with("SELECT value FROM hive.e02.marker UNION ALL " + "SELECT value FROM hive.e02.marker_refreshed ORDER BY value") + self.assertEqual(selected["rows"], [[42], [84]]) + self.assertEqual(objects, self.objects) + + def test_ctas_uncertain_failure_is_not_retried(self): + self.verifier.query.side_effect = VerificationError("write outcome unknown") + with self.assertRaises(VerificationError): + self.verifier.refreshed_write_read(self.previous) + self.verifier.query.assert_called_once() + self.verifier.query_ready.assert_not_called() + + def test_existing_objects_alone_do_not_prove_a_new_write(self): + self.verifier.observe_files.return_value = self.previous + with self.assertRaisesRegex(VerificationError, "new independently observed"): + self.verifier.refreshed_write_read(self.previous) diff --git a/hack/framework-e2e/test_verify_logging.py b/hack/framework-e2e/test_verify_logging.py new file mode 100644 index 00000000..d3c16c84 --- /dev/null +++ b/hack/framework-e2e/test_verify_logging.py @@ -0,0 +1,38 @@ +import importlib.util +import pathlib +import time +import unittest +from unittest import mock + +SPEC = importlib.util.spec_from_file_location("verify_logging", pathlib.Path(__file__).with_name("verify-logging.py")) +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class ProvenanceTests(unittest.TestCase): + def test_pod_delivery_may_precede_status_commit(self): + verifier = object.__new__(M.Verification) + verifier.controller = None + verifier.deadline = time.monotonic() + 30 + cm = {"metadata": {"namespace": "logging-e03", "uid": "same-cm", "resourceVersion": "new"}} + + def observation(version): + return {"metadata": {"generation": 1}, "status": {"groups": [{"role": "workers", "name": "default", + "facts": {"state": "resolved", "observed": [{"apiVersion": "v1", "kind": "ConfigMap", + "namespace": "logging-e03", "name": "destination", "uid": "same-cm", "resourceVersion": version}]}}]}} + + verifier.get = mock.Mock(side_effect=[observation("old"), observation("old"), observation("new")]) + with mock.patch.object(M.time, "sleep"): + observed = verifier.until("fresh provenance", lambda: verifier.refreshed_provenance(cm, 1)) + self.assertEqual(verifier.get.call_count, 3) + self.assertEqual(observed[0]["resourceVersion"], "new") + + verifier.get = mock.Mock(return_value=observation("old")) + with self.assertRaisesRegex(RuntimeError, "fresh CM provenance missing"): + verifier.refreshed_provenance(cm, 1) + + def test_cr_generation_change_is_not_reference_only_refresh(self): + verifier = object.__new__(M.Verification) + verifier.get = mock.Mock(return_value={"metadata": {"generation": 2}}) + with self.assertRaisesRegex(RuntimeError, "edited CR spec"): + verifier.refreshed_provenance({}, 1) diff --git a/hack/framework-e2e/test_verify_storage.py b/hack/framework-e2e/test_verify_storage.py new file mode 100644 index 00000000..3a3572a1 --- /dev/null +++ b/hack/framework-e2e/test_verify_storage.py @@ -0,0 +1,208 @@ +import base64 +import copy +import importlib.util +import json +import pathlib +import subprocess +import sys +import tempfile +import types +import unittest +from unittest import mock + +PATH = pathlib.Path(__file__).with_name('verify-storage.py') +SPEC = importlib.util.spec_from_file_location('verify_storage', PATH) +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +def bound_objects(): + cr = {'metadata': {'name': 'example', 'namespace': 'isolated', 'uid': 'cr-original'}} + pvc = {'metadata': {'name': 'data-example-workers-default-0', 'namespace': 'isolated', 'uid': 'pvc-original', + 'annotations': {M.SOURCE: json.dumps(M.source_receipt(cr, 'retained', '64Mi')), + M.BINDING: json.dumps({'version': 1, 'pvcUID': 'pvc-original', 'pvUID': 'pv-original', 'volumeName': 'volume'})}}, + 'spec': {'storageClassName': 'retained', 'accessModes': ['ReadWriteOnce'], 'volumeMode': 'Filesystem', + 'volumeName': 'volume', 'resources': {'requests': {'storage': '64Mi'}}}, 'status': {'phase': 'Bound'}} + pv = {'metadata': {'name': 'volume', 'uid': 'pv-original'}, 'spec': {'storageClassName': 'retained', + 'persistentVolumeReclaimPolicy': 'Retain', 'volumeMode': 'Filesystem', 'accessModes': ['ReadWriteOnce'], + 'capacity': {'storage': '128Mi'}, 'claimRef': {'name': pvc['metadata']['name'], 'namespace': 'isolated', 'uid': 'pvc-original'}, + 'hostPath': {'path': '/var/local-path-provisioner/volume'}}, 'status': {'phase': 'Bound'}} + return cr, pvc, pv + + +class BindingTests(unittest.TestCase): + def check(self, objects): + return M.inspect_binding(*objects, 'retained', '64Mi') + + def test_real_binding_requires_original_uid_but_allows_larger_pv(self): + result = self.check(bound_objects()) + self.assertEqual(result['pvc_uid'], 'pvc-original') + self.assertEqual(result['pv_uid'], 'pv-original') + + def test_same_name_new_cr_is_not_original_source(self): + cr, pvc, pv = bound_objects() + cr['metadata']['uid'] = 'new-cr' + with self.assertRaisesRegex(M.VerificationError, 'provenance'): + self.check((cr, pvc, pv)) + + def test_receipts_do_not_replace_bidirectional_actual_binding(self): + for change in ('pvc-uid', 'pv-uid', 'claim-ref', 'volume-name'): + cr, pvc, pv = bound_objects() + if change == 'pvc-uid': + pvc['metadata']['uid'] = 'another-pvc' + elif change == 'pv-uid': + pv['metadata']['uid'] = 'another-pv' + elif change == 'claim-ref': + pv['spec']['claimRef']['uid'] = 'another-pvc' + else: + pvc['spec']['volumeName'] = 'another-volume' + with self.subTest(change=change), self.assertRaises(M.VerificationError): + self.check((cr, pvc, pv)) + + def test_unbound_or_reclaiming_data_is_not_proved_retained(self): + for change in ('pending', 'delete', 'owner', 'terminating', 'small-pv'): + cr, pvc, pv = bound_objects() + if change == 'pending': + pvc['status']['phase'] = 'Pending' + elif change == 'delete': + pv['spec']['persistentVolumeReclaimPolicy'] = 'Delete' + elif change == 'owner': + pvc['metadata']['ownerReferences'] = [{'uid': 'live-guard', 'controller': False}] + elif change == 'terminating': + pvc['metadata']['deletionTimestamp'] = '2026-09-14T00:00:00Z' + else: + pv['spec']['capacity']['storage'] = '1Mi' + with self.subTest(change=change), self.assertRaises(M.VerificationError): + self.check((cr, pvc, pv)) + + def test_missing_or_changed_provenance_is_rejected(self): + for key in (M.SOURCE, M.BINDING): + cr, pvc, pv = bound_objects() + pvc['metadata']['annotations'].pop(key) + with self.subTest(key=key), self.assertRaises(M.VerificationError): + self.check((cr, pvc, pv)) + cr, pvc, pv = bound_objects() + pvc['spec']['resources']['requests']['storage'] = '65Mi' + with self.assertRaises(M.VerificationError): + self.check((cr, pvc, pv)) + + def test_same_uid_does_not_hide_replaced_data_path(self): + values = bound_objects() + prior = self.check(values) + values[2]['spec']['hostPath']['path'] = '/another-path' + with self.assertRaisesRegex(M.VerificationError, 'identity, source or binding changed'): + M.inspect_binding(*values, 'retained', '64Mi', prior) + + +class MarkerAndMountTests(unittest.TestCase): + def test_exclusive_fsync_write_cannot_recreate_existing_marker(self): + with tempfile.TemporaryDirectory() as root: + path = pathlib.Path(root) / 'marker.json' + original = b'{"random-nonce":"original"}\n' + run = lambda code, data=None: subprocess.run([sys.executable, '-c', code, str(path)], input=data, + capture_output=True, timeout=10) + written = run(M.WRITE_MARKER, original) + self.assertEqual(written.returncode, 0, written.stderr) + record = json.loads(written.stdout) + self.assertTrue(record['fsync_file'] and record['fsync_parent_directory']) + self.assertEqual(base64.b64decode(record['base64']), original) + refused = run(M.WRITE_MARKER, b'replacement') + self.assertNotEqual(refused.returncode, 0) + self.assertEqual(path.read_bytes(), original) + read = run(M.READ_MARKER) + self.assertEqual(read.returncode, 0) + self.assertEqual(base64.b64decode(json.loads(read.stdout)['base64']), original) + + def test_mount_to_same_named_emptydir_does_not_prove_pvc_consumption(self): + pod = {'spec': {'containers': [{'name': 'trino', 'volumeMounts': [{'name': 'data', 'mountPath': '/data'}]}], + 'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'original'}}]}} + M.validate_mount(pod, 'original') + empty = copy.deepcopy(pod) + empty['spec']['volumes'][0] = {'name': 'data', 'emptyDir': {}} + with self.assertRaises(M.VerificationError): + M.validate_mount(empty, 'original') + changed = copy.deepcopy(pod) + changed['spec']['containers'][0]['volumeMounts'][0]['subPath'] = 'different' + with self.assertRaises(M.VerificationError): + M.validate_mount(changed, 'original') + + def test_watch_keeps_complete_records_before_partial_tail(self): + first = {'type': 'ADDED', 'object': {'metadata': {'uid': 'actual'}}} + text = json.dumps(first) + '\n{"type":"MOD' + values, partial = M.json_stream(text) + self.assertEqual(values, [first]) + self.assertTrue(partial) + self.assertEqual(M.json_stream(json.dumps(first) + '\n'), ([first], False)) + + +class ImageTests(unittest.TestCase): + def test_tag_and_declared_digest_are_not_actual_image_proof(self): + with tempfile.TemporaryDirectory() as root: + path = pathlib.Path(root) / 'image.json' + expected = 'example/image@sha256:manifest' + path.write_text(json.dumps([{'Id': 'sha256:configuration', 'RepoDigests': [expected]}])) + verifier = object.__new__(M.Verifier) + verifier.args = types.SimpleNamespace(image=expected, image_inventory=str(path), node_name='dedicated-node') + verifier.checked_images = set() + verifier.report = {'image_observations': []} + verifier.persist = lambda: None + def command(argv): + status = ({'id': 'sha256:configuration', 'repoDigests': [expected]} if 'inspecti' in argv else + {'id': 'container', 'metadata': {'name': 'trino'}, 'labels': {'io.kubernetes.pod.uid': 'pod'}, + 'imageRef': expected}) + return {'index': 1, 'stdout': json.dumps({'status': status})} + verifier.command = command + pod = {'metadata': {'name': 'worker', 'uid': 'pod'}, 'spec': {'nodeName': 'dedicated-node', + 'containers': [{'name': 'trino', 'image': expected}]}, 'status': {'containerStatuses': [ + {'name': 'trino', 'containerID': 'containerd://container', 'imageID': 'another/image@sha256:wrong'}]}} + with self.assertRaisesRegex(M.VerificationError, 'actual Pod imageID'): + verifier.image_proof(pod) + pod['status']['containerStatuses'][0]['imageID'] = expected + verifier.image_proof(pod) + self.assertEqual(len(verifier.report['image_observations']), 1) + pod['spec']['containers'][0]['image'] = 'mutable:tag' + with self.assertRaisesRegex(M.VerificationError, 'requests another fixture image'): + verifier.image_proof(pod) + + +class ControllerCleanupTests(unittest.TestCase): + def verifier(self, root): + binary = pathlib.Path(root) / 'controller' + binary.write_bytes(b'frozen-binary') + value = object.__new__(M.Verifier) + value.output = pathlib.Path(root) + value.args = types.SimpleNamespace(controller_binary=str(binary), kubeconfig='isolated', namespace='isolated', + image='fixed', storage_class='retained') + value.controller, value.controller_stream, value.capacity = None, None, '64Mi' + value.report = {'controllers': [{'pid': 42, 'reaped': True, 'binary_sha256': M.sha(binary.read_bytes())}]} + value.persist = lambda: None + return value + + def test_unreaped_previous_child_blocks_before_spawn(self): + with tempfile.TemporaryDirectory() as root: + value = self.verifier(root) + value.report['controllers'][0]['reaped'] = False + with mock.patch.object(M.subprocess, 'Popen') as spawn, self.assertRaises(M.VerificationError): + value.start_controller() + spawn.assert_not_called() + self.assertFalse((pathlib.Path(root) / 'controller-1.log').exists()) + + def test_post_spawn_rejection_still_records_and_reaps_new_child(self): + with tempfile.TemporaryDirectory() as root: + value = self.verifier(root) + original = copy.deepcopy(value.report['controllers'][0]) + child = mock.Mock(pid=42) + child.poll.return_value = None + child.wait.return_value = 0 + with mock.patch.object(M.subprocess, 'Popen', return_value=child), self.assertRaises(M.VerificationError): + value.start_controller() + self.assertEqual(len(value.report['controllers']), 2) + value.stop_controller() + self.assertEqual(value.report['controllers'][0], original) + self.assertTrue(value.report['controllers'][1]['reaped']) + self.assertEqual(value.report['controllers'][1]['exit_code'], 0) + child.terminate.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/hack/framework-e2e/verify-dataops.py b/hack/framework-e2e/verify-dataops.py new file mode 100644 index 00000000..5503569f --- /dev/null +++ b/hack/framework-e2e/verify-dataops.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Run E05 against disposable data through the real framework and data controllers. + +Requires a disposable cluster, generated Trino and data CRDs, a Retain-capable +provisioner, and the same pinned Python-capable image used by the storage harness. +Creates its own namespace and refuses to touch any pre-existing namespace. +""" +import argparse +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import time +import uuid + +RESOURCE = 'trinoclusters.trino.kubedoop.dev' +ASSETS = 'dataassets.data.framework.kubedoop.dev' +OPERATIONS = 'dataoperations.data.framework.kubedoop.dev' + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def source(value): + return {key: value[key] for key in ('version', 'crUID', 'role', 'group', 'slot', 'storageClass', 'capacity')} + + +def identity(value): + return {'cluster': {key:value['cluster'][key] for key in ('apiVersion','kind','name','uid')}, 'claimName': value['claimName'], + 'binding': {key: value['binding'][key] for key in ('version', 'pvcUID', 'pvUID', 'volumeName')}, + 'source': source(value['source'])} + + +def cluster(value): + return {'apiVersion': value['apiVersion'], 'kind': value['kind'], + 'name': value['metadata']['name'], 'uid': value['metadata']['uid']} + + +class Verification: + def __init__(self, args): + self.args = args + self.output = pathlib.Path(args.output_dir).resolve() + self.output.mkdir(parents=True, exist_ok=True) + require(not any(self.output.iterdir()), 'Use a new empty evidence directory') + (self.output / 'commands').mkdir() + self.index = 0 + self.processes = [] + self.created = False + self.volumes = {} + self.backends = {} + self.report = {'passed': False, 'cleanup': False, 'phases': [], 'controllers': []} + self.persist() + + def persist(self): + (self.output / 'verification.json').write_text(json.dumps(self.report, indent=2) + '\n') + + def command(self, argv, input=None, timeout=40, check=True): + self.index += 1 + started = time.monotonic() + result = subprocess.run(list(map(str, argv)), input=input, capture_output=True, text=True, timeout=timeout, check=False) + receipt = {'argv': list(map(str, argv)), 'returncode': result.returncode, 'stdout': result.stdout, + 'stderr': result.stderr, 'elapsed_seconds': time.monotonic() - started} + if input is not None: + receipt['stdin'] = input + (self.output / 'commands' / f'{self.index:05d}.json').write_text(json.dumps(receipt, indent=2) + '\n') + if check: + require(result.returncode == 0, 'command failed: ' + result.stderr[-2000:]) + return result.stdout + + def kube(self, *args, **kwargs): + return self.command(['kubectl', '--kubeconfig', self.args.kubeconfig, '--request-timeout=25s', + '-n', self.args.namespace, *args], **kwargs) + + def get(self, kind, name): + value = self.kube('get', kind, name, '--ignore-not-found', '-o', 'json') + return json.loads(value) if value.strip() else None + + def apply(self, value): + self.kube('apply', '-f', '-', input=json.dumps(value)) + + def until(self, label, fn, seconds=180): + end = time.monotonic() + seconds + last = None + while time.monotonic() < end: + for process, _, _ in self.processes: + require(process.poll() is None, 'controller exited') + try: + result = fn() + if result: + return result + except (RuntimeError, TypeError, KeyError) as error: + last = str(error) + time.sleep(1) + raise RuntimeError(label + ' timed out; last=' + str(last)) + + def phase(self, name, evidence): + self.report['phases'].append({'name': name, 'evidence': evidence}) + self.persist() + print('PASS dataops:', name, flush=True) + + def launch(self, binary, arguments, name): + path = pathlib.Path(binary).resolve() + stream = (self.output / (name + '.log')).open('wb') + env = dict(os.environ, KUBECONFIG=self.args.kubeconfig) + process = subprocess.Popen([str(path), *arguments], stdout=stream, stderr=subprocess.STDOUT, + env=env, start_new_session=True) + self.processes.append((process, stream, name)) + self.report['controllers'].append({'name': name, 'pid': process.pid, + 'binary_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) + self.persist() + + def create_cr(self, name, active=False, capacity='64Mi'): + cr = {'apiVersion': 'trino.kubedoop.dev/v1alpha1', 'kind': 'TrinoCluster', + 'metadata': {'name': name, 'namespace': self.args.namespace}, + 'spec': {'clusterConfig': {'reconciliationPaused': not active}, + 'workers': {'config': {'resources': {'storage': {'type': 'persistent', + 'storageClassName': self.args.storage_class, 'capacity': capacity}}}, + 'roleGroups': {'default': {'replicas': 1}} if active else {}}}} + self.apply(cr) + return self.get(RESOURCE, name) + + def ready(self, name): + pod = self.get('pod', name + '-workers-default-0') + if pod and any(c['type'] == 'Ready' and c['status'] == 'True' for c in pod.get('status', {}).get('conditions', [])): + return pod + return None + + def retire(self, name): + self.kube('patch', RESOURCE, name, '--type=merge', '-p', json.dumps({'spec': {'workers': {'roleGroups': {}}}})) + # Merge patch retains keys in an object; explicitly remove the sole group. + self.kube('patch', RESOURCE, name, '--type=json', '-p', json.dumps([{'op': 'remove', 'path': '/spec/workers/roleGroups/default'}])) + self.until('retire ' + name, lambda: not self.get('statefulset', name + '-workers-default') + and not self.get('pod', name + '-workers-default-0')) + self.kube('patch', RESOURCE, name, '--type=merge', '-p', json.dumps({'spec': {'clusterConfig': {'reconciliationPaused': True}}})) + return self.get(RESOURCE, name) + + def operation(self, name, action, asset, from_identity, source_cr, target_cr=None, capacity=None): + spec = {'action': action, 'assetName': asset['metadata']['name'], 'assetUID': asset['metadata']['uid'], + 'source': identity(from_identity), 'sourceCluster': cluster(source_cr), 'workerIdentity': {'uid':1000, 'gid':1000}} + if target_cr: + next_source = source(from_identity['source']) + next_source['crUID'] = target_cr['metadata']['uid'] + if capacity: + next_source['capacity'] = capacity + spec['target'] = {'cluster': cluster(target_cr), + 'claimName': 'data-' + target_cr['metadata']['name'] + '-workers-default-0', 'source': next_source} + spec['approval'] = '' + spec['approval'] = hashlib.sha256(json.dumps(spec, separators=(',', ':'), ensure_ascii=False).encode()).hexdigest() + self.apply({'apiVersion': 'data.framework.kubedoop.dev/v1alpha1', 'kind': 'DataOperation', + 'metadata': {'name': name, 'namespace': self.args.namespace}, 'spec': spec}) + def completed(): + op = self.get(OPERATIONS, name) + if op.get('status', {}).get('phase') == 'Complete': + return op + self.report['pending'] = op.get('status', {}) + self.persist() + return None + result = self.until(action, completed, 300) + self.phase(action + '-' + name, result) + return self.get(ASSETS, asset['metadata']['name']) + + def remember(self, data): + name = data['binding']['volumeName'] + self.volumes[name] = data['binding']['pvUID'] + pv = self.get('pv', name) + path = pv['spec'].get('hostPath', pv['spec'].get('local', {})).get('path') + require(path and path.startswith('/'), 'Runtime fixture requires observable local-path backend storage') + self.backends[name] = {'path': path, 'pvUID': pv['metadata']['uid'], 'node': self.args.node_name} + self.report['backends'] = self.backends + self.persist() + + def backend_removed(self, data): + backend = self.backends[data['binding']['volumeName']] + require(backend['pvUID'] == data['binding']['pvUID'], 'backend proof identity changed') + self.command(['docker', 'exec', backend['node'], 'test', '!', '-e', backend['path']]) + self.phase('backend-path-reclaimed', backend) + + def marker(self, name): + return self.kube('exec', name + '-workers-default-0', '-c', 'trino', '--', + 'python3', '-c', "from pathlib import Path; print(Path('/data/marker').read_text())").strip() + + def run(self): + require(self.get('namespace', self.args.namespace) is None, 'Refusing an existing namespace') + self.apply({'apiVersion': 'v1', 'kind': 'Namespace', 'metadata': {'name': self.args.namespace}}) + self.created = True + self.launch(self.args.storage_controller_binary, ['--kubeconfig', self.args.kubeconfig, '--namespace', self.args.namespace, + '--image', self.args.image, '--storage-class', self.args.storage_class], 'storage-controller') + self.launch(self.args.data_controller_binary, ['--worker-image', self.args.image], 'data-controller') + old = self.create_cr('source', active=True) + pod = self.until('source ready', lambda: self.ready('source')) + require(pod['spec']['nodeName'] == self.args.node_name, 'Fixture scheduled outside dedicated kind node') + claim_name = 'data-source-workers-default-0' + claim = self.until('automatic data asset', lambda: (p if (p := self.get('pvc', claim_name)) and + p['metadata'].get('annotations', {}).get('framework.kubedoop.dev/data-asset') else None)) + asset = self.get(ASSETS, claim['metadata']['annotations']['framework.kubedoop.dev/data-asset']) + self.remember(asset['spec']) + marker = 'data-operation-' + str(uuid.uuid4()) + self.kube('exec', 'source-workers-default-0', '-c', 'trino', '--', 'python3', '-c', + "import os; f=open('/data/marker','x'); f.write(" + repr(marker) + "); f.flush(); os.fsync(f.fileno()); f.close()") + self.phase('framework-automatic-identity', asset) + self.kube('delete', RESOURCE, 'source', '--wait=true') + self.until('source CR and workloads gone', lambda: not self.get('pod', 'source-workers-default-0') + and not self.get('statefulset', 'source-workers-default')) + adopted = self.create_cr('adopted') + asset = self.operation('adopt', 'adopt', asset, asset['spec'], old, adopted) + adopted_data = asset['status']['current'] + require(adopted_data['binding']['pvUID'] == asset['spec']['binding']['pvUID'], 'adoption changed physical volume') + self.create_cr('adopted', active=True) + self.until('adopted framework consumption', lambda: self.ready('adopted')) + require(self.marker('adopted') == marker, 'adopted marker differs') + self.phase('adopted-framework-consumed-original-bytes', adopted_data) + adopted = self.retire('adopted') + migrated = self.create_cr('migrated', capacity='128Mi') + asset = self.operation('migrate', 'migrate', asset, adopted_data, adopted, migrated, '128Mi') + migrated_data = asset['status']['current'] + self.remember(migrated_data) + require(migrated_data['binding']['pvUID'] != adopted_data['binding']['pvUID'], 'migration reused source volume') + self.create_cr('migrated', active=True, capacity='128Mi') + self.until('migrated framework consumption', lambda: self.ready('migrated')) + require(self.marker('migrated') == marker, 'migrated marker differs') + self.phase('migrated-framework-consumed-verified-copy', migrated_data) + migrated = self.retire('migrated') + asset = self.operation('destroy-current', 'destroy', asset, migrated_data, migrated) + require(not self.get('pvc', migrated_data['claimName']) and not self.get('pv', migrated_data['binding']['volumeName']), + 'destroyed current binding remains') + self.backend_removed(migrated_data) + asset = self.operation('destroy-retired-copy', 'destroy', asset, adopted_data, adopted) + self.backend_removed(adopted_data) + require(asset['status']['destroyed'] and not asset['status'].get('retiredCopies'), 'asset still has live copies') + require(len(asset['status']['history']) == 4, 'missing independent operation history') + self.phase('all-authorized-copies-destroyed-history-retained', asset) + self.report['passed'] = True + self.persist() + + def cleanup(self): + for process, stream, name in reversed(self.processes): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + code = process.wait(timeout=20) + stream.close() + self.report['controllers'].append({'name': name, 'exit_code': code, 'reaped': True}) + self.processes = [] + if self.created: + inventory = json.loads(self.kube('get', 'pv', '-o', 'json')) + for pv in inventory['items']: + if pv.get('spec', {}).get('claimRef', {}).get('namespace') == self.args.namespace: + self.volumes[pv['metadata']['name']] = pv['metadata']['uid'] + self.kube('delete', 'namespace', self.args.namespace, '--wait=true', '--timeout=90s', timeout=100) + # Only exact PV identities first observed in our newly created namespace. + for name, uid in self.volumes.items(): + pv = self.get('pv', name) + if pv: + require(pv['metadata']['uid'] == uid and pv['spec']['claimRef']['namespace'] == self.args.namespace, + 'cleanup PV identity or source namespace changed') + self.kube('delete', 'pv', name, '--wait=true', '--timeout=30s') + self.report['cleanup'] = True + self.persist() + + +def main(): + parser = argparse.ArgumentParser() + for name in ('kubeconfig', 'storage-controller-binary', 'data-controller-binary', 'image', 'storage-class', 'output-dir', 'node-name'): + parser.add_argument('--' + name, required=True) + parser.add_argument('--namespace', default='framework-data-e2e-' + uuid.uuid4().hex[:8]) + args = parser.parse_args() + require(args.namespace.startswith('framework-data-e2e-'), 'Use the reserved disposable namespace prefix') + verification = Verification(args) + try: + verification.run() + except BaseException as error: + verification.report['error'] = repr(error) + verification.persist() + raise + finally: + verification.cleanup() + + +if __name__ == '__main__': + main() diff --git a/hack/framework-e2e/verify-logging.py b/hack/framework-e2e/verify-logging.py new file mode 100644 index 00000000..747bfe74 --- /dev/null +++ b/hack/framework-e2e/verify-logging.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Verify standard Vector discovery and native Python events in an isolated namespace. + +Requires an existing disposable cluster with the generated Trino CRD and the +pinned product/Vector images plus the built materializer loaded by run.py. +Owns only its newly created namespace and its local fixture controller process. +""" +import argparse +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import sys +import time +import uuid + +RESOURCE = "trinoclusters.trino.kubedoop.dev" +NAME = "logging" + + +def require(value, message): + if not value: + raise RuntimeError(message) + + +class Verification: + def __init__(self, args): + self.args = args + self.output = pathlib.Path(args.output_dir).resolve() + self.output.mkdir(parents=True, exist_ok=True) + require(not any(self.output.iterdir()), "Use a new empty output directory") + (self.output / "commands").mkdir() + self.deadline = time.monotonic() + args.timeout + self.index = 0 + self.controller = self.stream = None + self.created = False + self.report = {"passed": False, "cleanup": False, "phases": [], + "scope": "generated CR reference, live controller refresh, native Python log file and Vector protocol receivers"} + self.persist() + + def persist(self): + (self.output / "verification.json").write_text(json.dumps(self.report, indent=2) + "\n") + + def command(self, argv, input=None, timeout=30, check=True): + self.index += 1 + started = time.monotonic() + result = subprocess.run(list(map(str, argv)), input=input, capture_output=True, text=True, timeout=timeout, check=False) + receipt = {"argv": list(map(str, argv)), "returncode": result.returncode, "stdout": result.stdout, + "stderr": result.stderr, "elapsed_seconds": time.monotonic() - started} + if input is not None: + receipt["stdin_sha256"] = hashlib.sha256(input.encode()).hexdigest() + (self.output / "commands" / f"{self.index:05d}.json").write_text(json.dumps(receipt, indent=2) + "\n") + if check: + require(result.returncode == 0, "command failed: " + result.stderr[-1500:]) + return result.stdout + + def kube(self, *args, **kwargs): + return self.command(["kubectl", "--kubeconfig", self.args.kubeconfig, "--request-timeout=20s", "-n", self.args.namespace, + *args], **kwargs) + + def get(self, kind, name): + output = self.kube("get", kind, name, "--ignore-not-found", "-o", "json") + return json.loads(output) if output.strip() else None + + def apply(self, value): + self.kube("apply", "-f", "-", input=json.dumps(value)) + + def until(self, label, fn, seconds=150): + end, last = min(self.deadline, time.monotonic() + seconds), None + while time.monotonic() < end: + if self.controller is not None: + require(self.controller.poll() is None, "fixture controller exited") + try: + value = fn() + if value: + return value + except (RuntimeError, KeyError, TypeError, ValueError) as error: + last = str(error) + time.sleep(1) + raise RuntimeError(label + " timed out: " + str(last)) + + def phase(self, name, evidence): + self.report["phases"].append({"name": name, "evidence": evidence}) + self.persist() + print("PASS logging:", name, flush=True) + + def start(self): + require(self.get("namespace", self.args.namespace) is None, "Namespace already exists; refusing to use it") + self.apply({"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": self.args.namespace}}) + self.created = True + binary = pathlib.Path(self.args.controller_binary).resolve() + argv = [str(binary), "--kubeconfig", self.args.kubeconfig, "--namespace", self.args.namespace, + "--image", self.args.image, "--materializer-image", self.args.materializer_image, + "--vector-image", self.args.vector_image] + self.stream = (self.output / "controller.log").open("wb") + self.controller = subprocess.Popen(argv, stdout=self.stream, stderr=subprocess.STDOUT, start_new_session=True) + self.report["controller"] = {"argv": argv, "pid": self.controller.pid, + "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest()} + self.persist() + + def group(self, cr, name="default"): + return next((g for g in cr.get("status", {}).get("groups", []) if g["role"] == "workers" and g["name"] == name), {}) + + def workload_name(self, group="default"): + return NAME + "-workers-" + group + + def refreshed_provenance(self, cm, generation): + cr = self.get(RESOURCE, NAME) + require(cr and cr["metadata"]["generation"] == generation, "refresh unexpectedly edited CR spec") + facts = self.group(cr).get("facts", {}) + observed = facts.get("observed", []) + require(facts.get("state") == "resolved" and any( + o.get("apiVersion") == "v1" and o.get("kind") == "ConfigMap" and + o.get("namespace") == cm["metadata"]["namespace"] and o.get("name") == "destination" and + o.get("uid") == cm["metadata"]["uid"] and o.get("resourceVersion") == cm["metadata"]["resourceVersion"] + for o in observed), "fresh CM provenance missing") + return observed + + def ready(self, group="default", previous=None): + cr = self.get(RESOURCE, NAME) + require(cr and cr.get("status", {}).get("observedGeneration") == cr["metadata"]["generation"], "current CR not observed") + observation = self.group(cr, group) + require(observation.get("applied"), "group not applied") + if group == "default": + require(observation.get("facts", {}).get("state") == "resolved", "destination not resolved") + name = self.workload_name(group) + sts, pod = self.get("statefulset", name), self.get("pod", name + "-0") + require(sts and pod and not pod["metadata"].get("deletionTimestamp"), "missing or terminating workload") + status = sts.get("status", {}) + require(status.get("observedGeneration", 0) >= sts["metadata"]["generation"] and + status.get("currentRevision") == status.get("updateRevision") and status.get("readyReplicas") == 1, + "StatefulSet not converged") + require(all(c.get("ready") and c.get("restartCount") == 0 for c in pod.get("status", {}).get("containerStatuses", [])), + "container not ready or restarted") + if previous: + require(pod["metadata"]["uid"] != previous["metadata"]["uid"], "destination refresh did not replace Pod") + return pod + + def destination(self, address): + self.apply({"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "destination", "namespace": self.args.namespace}, + "data": {"ADDRESS": address}}) + return self.get("configmap", "destination") + + def receiver(self, name): + config = {"data_dir": "/var/lib/vector", "sources": {"upstream": {"type": "vector", "address": "0.0.0.0:6000"}}, + "sinks": {"proof": {"type": "console", "inputs": ["upstream"], "target": "stdout", "encoding": {"codec": "json"}}}} + metadata = {"name": name, "namespace": self.args.namespace} + self.apply({"apiVersion": "v1", "kind": "ConfigMap", "metadata": metadata, "data": {"vector.json": json.dumps(config)}}) + self.apply({"apiVersion": "v1", "kind": "Service", "metadata": metadata, + "spec": {"selector": {"receiver": name}, "ports": [{"name": "vector", "port": 6000, "targetPort": 6000}]}}) + self.apply({"apiVersion": "v1", "kind": "Pod", "metadata": {**metadata, "labels": {"receiver": name}}, + "spec": {"securityContext": {"fsGroup": 1000}, "containers": [{"name": "vector", "image": self.args.vector_image, + "command": ["vector"], "args": ["--config", "/config/vector.json"], + "resources": {"requests": {"cpu": "25m", "memory": "64Mi"}, "limits": {"cpu": "500m", "memory": "192Mi"}}, + "readinessProbe": {"tcpSocket": {"port": 6000}, "periodSeconds": 1}, + "volumeMounts": [{"name": "config", "mountPath": "/config", "readOnly": True}, + {"name": "data", "mountPath": "/var/lib/vector"}]}], + "volumes": [{"name": "config", "configMap": {"name": name}}, {"name": "data", "emptyDir": {}}]}}) + self.until("receiver " + name, lambda: any(c.get("ready") for c in + self.get("pod", name).get("status", {}).get("containerStatuses", []))) + + def emit(self, marker): + code = "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8080/?marker=" + marker + "',timeout=3).read().decode())" + result = self.kube("exec", self.workload_name() + "-0", "-c", "python", "--", "python3", "-c", code) + require(marker in result, "native process did not acknowledge marker emission") + return True + + def delivered(self, receiver, marker): + self.until("native marker emission", lambda: self.emit(marker), seconds=30) + filename = "/logs/server.log" + native = self.kube("exec", self.workload_name() + "-0", "-c", "python", "--", "cat", filename).splitlines() + line = next((line for line in native if marker + "-debug" in line), None) + require(line, "native Python file did not consume DEBUG threshold") + console = self.kube("logs", self.workload_name() + "-0", "-c", "python") + require(marker + "-warning" in console and marker + "-debug" not in console, "native console threshold mismatch") + def observed(): + records = [] + for value in self.kube("logs", receiver, "-c", "vector").splitlines(): + try: + record = json.loads(value) + except ValueError: + continue + if record.get("message") == line: + records.append(record) + return records + events = self.until("exact native event at " + receiver, observed, seconds=60) + return {"marker": marker, "native_file": filename, "native_line": line, "received_event": events[0], + "receiver": receiver, "receiver_pod_uid": self.get("pod", receiver)["metadata"]["uid"]} + + def verify(self): + self.start() + self.apply({"apiVersion": "trino.kubedoop.dev/v1alpha1", "kind": "TrinoCluster", + "metadata": {"name": NAME, "namespace": self.args.namespace}, + "spec": {"clusterConfig": {"vectorAgentConfigMap": "destination"}, + "workers": {"roleGroups": {"default": {"replicas": 1}, + "quiet": {"replicas": 1, "config": {"logging": {"enableVectorAgent": False}}}, + "nofiles": {"replicas": 1, "config": {"logging": {"containers": {"python": {"file": {"level": "OFF"}}}}}}}}}}) + def missing(): + cr = self.get(RESOURCE, NAME) + require(cr and self.group(cr).get("facts", {}).get("state") == "pending", "missing CM not Pending") + require(self.get("statefulset", self.workload_name()) is None, "unresolved collector producer was created") + return self.ready("quiet") + quiet = self.until("missing destination isolates enabled group", missing) + nofiles = self.until("file-OFF group does not wait", lambda: self.ready("nofiles")) + require(not any(c["name"] == "vector" for c in quiet["spec"]["containers"]), "disabled group has a collector") + require(not any(c["name"] == "vector" for c in nofiles["spec"]["containers"]), "file-OFF group has a collector") + self.phase("missing-reference-isolated", {"cr": self.get(RESOURCE, NAME), "quiet_pod_uid": quiet["metadata"]["uid"], + "nofiles_pod_uid": nofiles["metadata"]["uid"]}) + self.receiver("receiver-a") + self.receiver("receiver-b") + cm = self.destination("receiver-a." + self.args.namespace + ".svc:6000") + first = self.until("destination resolved", self.ready) + event = self.delivered("receiver-a", "e03-a-" + uuid.uuid4().hex) + self.phase("native-file-to-central-vector", {"event": event, "pod": first, + "destination_uid": cm["metadata"]["uid"], "destination_rv": cm["metadata"]["resourceVersion"]}) + generation = self.get(RESOURCE, NAME)["metadata"]["generation"] + cm = self.destination("receiver-b." + self.args.namespace + ".svc:6000") + second = self.until("CM-only refresh replaced enabled Pod", lambda: self.ready(previous=first)) + event = self.delivered("receiver-b", "e03-b-" + uuid.uuid4().hex) + # Pod replacement and log delivery can finish before this reconcile has + # observed the sibling groups and persisted its final CR status. + observed = self.until("fresh destination provenance persisted", lambda: self.refreshed_provenance(cm, generation)) + require(self.ready("quiet")["metadata"]["uid"] == quiet["metadata"]["uid"], "disabled group rolled on destination update") + require(self.ready("nofiles")["metadata"]["uid"] == nofiles["metadata"]["uid"], "file-OFF group rolled on destination update") + self.phase("cm-only-destination-refresh", {"event": event, "pod": second, "cr_generation": generation, "observed": observed}) + self.destination("") + def invalid(): + cr = self.get(RESOURCE, NAME) + require(self.group(cr).get("facts", {}).get("state") == "invalid", "empty ADDRESS not Invalid") + require(self.get("pod", self.workload_name() + "-0")["metadata"]["uid"] == second["metadata"]["uid"], + "invalid reference destroyed the previous running Pod") + return cr + self.phase("invalid-destination-preserves-runtime", self.until("invalid discovery", invalid)) + self.report["passed"] = True + + def close(self): + failures = [] + if self.controller is not None: + try: + if self.controller.poll() is None: + os.killpg(self.controller.pid, signal.SIGTERM) + code = self.controller.wait(timeout=20) + self.report["controller"].update(returncode=code, reaped=True) + require(code == 0, "controller shutdown was not graceful") + except Exception as error: + failures.append(repr(error)) + if self.controller.poll() is None: + os.killpg(self.controller.pid, signal.SIGKILL) + self.controller.wait(timeout=5) + finally: + self.stream.close() + if self.created: + try: + for name in (self.workload_name() + "-0", "receiver-a", "receiver-b"): + self.kube("logs", name, "-c", "vector", check=False) + self.kube("get", "pods,configmaps,services,statefulsets," + RESOURCE, "-o", "json") + self.kube("delete", "namespace", self.args.namespace, "--wait=true", "--timeout=120s", timeout=140) + require(self.get("namespace", self.args.namespace) is None, "namespace cleanup not confirmed") + except Exception as error: + failures.append(repr(error)) + self.report.update(cleanup=not failures, cleanup_errors=failures) + if failures: + self.report["passed"] = False + self.persist() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("kubeconfig", "namespace", "output-dir", "controller-binary", "image", "materializer-image", "vector-image"): + parser.add_argument("--" + name, required=True) + parser.add_argument("--timeout", type=int, default=600) + run = Verification(parser.parse_args()) + def interrupted(signum, _frame): + raise RuntimeError("interrupted by signal " + str(signum)) + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + try: + run.verify() + except Exception as error: + run.report.update(passed=False, error=repr(error)) + print("Logging acceptance failed:", error, file=sys.stderr, flush=True) + finally: + run.close() + print("Evidence:", run.output, "passed:", run.report["passed"], "cleanup:", run.report["cleanup"], flush=True) + raise SystemExit(0 if run.report["passed"] and run.report["cleanup"] else 1) + + +if __name__ == "__main__": + main() diff --git a/hack/framework-e2e/verify-storage.py b/hack/framework-e2e/verify-storage.py new file mode 100644 index 00000000..691ae9b6 --- /dev/null +++ b/hack/framework-e2e/verify-storage.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python3 +"""Observe real Retain/Retain volumes in one explicitly isolated namespace.""" +import argparse +import base64 +import copy +import datetime +import decimal +import hashlib +import json +import os +import pathlib +import re +import signal +import subprocess +import sys +import time +import uuid + +SOURCE = 'framework.kubedoop.dev/retained-data' +BINDING = 'framework.kubedoop.dev/retained-binding' +FINALIZER = 'storage-experiment.design.kubedoop.dev/hold' +RESOURCE = 'trinoclusters.trino.kubedoop.dev' + + +class VerificationError(RuntimeError): + pass + + +def require(value, message): + if not value: + raise VerificationError(message) + + +def now(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def capacity_bytes(value): + match = re.fullmatch(r'([0-9]+(?:\.[0-9]+)?)([KMGTPE]i|[kMGTPE])?', value) + require(match, 'unsupported capacity representation in experiment: ' + repr(value)) + suffix = match[2] or '' + powers = {'': 1, **{unit + 'i': 1024 ** index for index, unit in enumerate('KMGTPE', 1)}, + **{unit: 1000 ** index for index, unit in enumerate('kMGTPE', 1)}} + return decimal.Decimal(match[1]) * powers[suffix] + + +def identity(pod): + main = next((c for c in pod.get('status', {}).get('containerStatuses', []) if c['name'] == 'trino'), {}) + return {'pod_name': pod['metadata']['name'], 'pod_uid': pod['metadata']['uid'], + 'container_id': main.get('containerID'), 'restart_count': main.get('restartCount'), + 'image_id': main.get('imageID'), 'pod_ip': pod.get('status', {}).get('podIP')} + + +def json_stream(text): + decoder, values, position = json.JSONDecoder(), [], 0 + while position < len(text): + while position < len(text) and text[position].isspace(): + position += 1 + if position == len(text): + return values, False + try: + value, position = decoder.raw_decode(text, position) + values.append(value) + except json.JSONDecodeError: + return values, True + return values, False + + +def validate_mount(pod, pvc_name): + main = next((c for c in pod['spec']['containers'] if c['name'] == 'trino'), None) + require(main is not None, 'main container is missing') + mounted = [m for m in main.get('volumeMounts', []) if m.get('mountPath') == '/data'] + require(len(mounted) == 1 and not mounted[0].get('subPath') and not mounted[0].get('subPathExpr'), 'main data mount changed') + volume = next((v for v in pod['spec']['volumes'] if v['name'] == mounted[0]['name']), {}) + require(volume.get('persistentVolumeClaim', {}).get('claimName') == pvc_name, 'main /data does not mount the observed retained PVC') + + +def source_receipt(cr, storage_class, capacity): + return {'version': 1, 'crUID': cr['metadata']['uid'], 'role': 'workers', 'group': 'default', + 'slot': 'data', 'storageClass': storage_class, 'capacity': capacity} + + +def inspect_binding(cr, pvc, pv, storage_class, capacity, expected=None): + require(pvc and pv, 'PVC and PV must both exist') + meta, spec = pvc['metadata'], pvc['spec'] + require(not meta.get('deletionTimestamp') and not pv['metadata'].get('deletionTimestamp'), 'data objects are deleting') + require(not meta.get('ownerReferences') and not pv['metadata'].get('ownerReferences'), 'data object has an owner reference') + annotations = meta.get('annotations', {}) + source = json.loads(annotations.get(SOURCE, 'null')) + require(source == source_receipt(cr, storage_class, capacity), 'retained provenance differs from the original CR/slot/spec') + require(meta['name'] == 'data-' + cr['metadata']['name'] + '-workers-default-0', 'PVC name has a different slot or ordinal') + require(meta['namespace'] == cr['metadata']['namespace'], 'PVC namespace differs') + require(spec.get('storageClassName') == storage_class and spec.get('accessModes') == ['ReadWriteOnce'] and + spec.get('volumeMode', 'Filesystem') == 'Filesystem', 'PVC shape differs') + require(capacity_bytes(spec['resources']['requests']['storage']) == capacity_bytes(capacity), 'PVC requested capacity differs') + require(pvc.get('status', {}).get('phase') == 'Bound' and pv.get('status', {}).get('phase') == 'Bound', 'binding is not Bound') + require(spec.get('volumeName') == pv['metadata']['name'], 'PVC points to another PV') + pvs = pv['spec'] + require(pvs.get('persistentVolumeReclaimPolicy') == 'Retain' and pvs.get('storageClassName') == storage_class, + 'PV class/reclaim policy differs') + require(pvs.get('volumeMode', 'Filesystem') == 'Filesystem' and pvs.get('accessModes') == ['ReadWriteOnce'], 'PV mode differs') + require(capacity_bytes(pvs['capacity']['storage']) >= capacity_bytes(capacity), 'PV capacity is too small') + claim = pvs.get('claimRef', {}) + require(all(claim.get(k) == meta[k] for k in ('name', 'namespace', 'uid')), 'PV claimRef differs from the actual PVC UID') + binding = json.loads(annotations.get(BINDING, 'null')) + require(binding == {'version': 1, 'pvcUID': meta['uid'], 'pvUID': pv['metadata']['uid'], 'volumeName': pv['metadata']['name']}, + 'retained binding receipt differs from live PVC/PV identities') + result = {'cr_uid': cr['metadata']['uid'], 'pvc_name': meta['name'], 'pvc_uid': meta['uid'], + 'pv_name': pv['metadata']['name'], 'pv_uid': pv['metadata']['uid'], + 'source': source, 'binding': binding, 'pv_source': copy.deepcopy({k: pvs[k] for k in ('hostPath', 'local', 'csi', 'nodeAffinity') if k in pvs})} + if expected: + require(result == expected, 'the retained data identity, source or binding changed') + return result + + +def fixture(name, namespace, present=True): + group = {'replicas': 1, 'config': {'logging': {'enableVectorAgent': False}}, + 'podOverrides': {'spec': {'terminationGracePeriodSeconds': 10, 'containers': [{ + 'name': 'trino', 'readinessProbe': {'httpGet': {'path': '/healthz', 'port': 8080}, + 'periodSeconds': 1, 'failureThreshold': 30}}]}}} + return {'apiVersion': 'trino.kubedoop.dev/v1alpha1', 'kind': 'TrinoCluster', + 'metadata': {'name': name, 'namespace': namespace}, + 'spec': {'workers': {'roleGroups': {'default': group} if present else {}}}} + + +WRITE_MARKER = r'''import base64,hashlib,json,os,pathlib,sys +path=pathlib.Path(sys.argv[1]); data=sys.stdin.buffer.read() +fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) +with os.fdopen(fd,'wb') as stream: + stream.write(data); stream.flush(); os.fsync(stream.fileno()) +directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) +try: os.fsync(directory) +finally: os.close(directory) +actual=path.read_bytes() +assert actual==data +print(json.dumps({'bytes':len(actual),'sha256':hashlib.sha256(actual).hexdigest(),'base64':base64.b64encode(actual).decode(),'uid':os.getuid(),'gid':os.getgid(),'fsync_file':True,'fsync_parent_directory':True})) +''' + +READ_MARKER = r'''import base64,hashlib,json,os,pathlib,sys +data=pathlib.Path(sys.argv[1]).read_bytes() +print(json.dumps({'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest(),'base64':base64.b64encode(data).decode(),'uid':os.getuid(),'gid':os.getgid()})) +''' + +HTTP_MARKER = r'''import base64,hashlib,json,urllib.request +with urllib.request.urlopen('http://127.0.0.1:8080/marker',timeout=3) as response: + data=response.read() + print(json.dumps({'status':response.status,'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest(),'base64':base64.b64encode(data).decode(),'content_length':response.headers.get('Content-Length')})) +''' + + +class Verifier: + def __init__(self, args): + self.args = args + self.output = pathlib.Path(args.output_dir) + self.output.mkdir(parents=True, exist_ok=True) + require(not any(self.output.iterdir()), 'verifier output directory must be empty') + (self.output / 'commands').mkdir() + self.deadline = time.monotonic() + args.timeout + self.command_index = 0 + self.controller = None + self.controller_stream = None + self.storage_watch = None + self.watch_streams = [] + self.checked_images = set() + self.capacity = args.capacity + self.closing = False + self.inspectors = [] + self.report = {'scope': 'one-slot Retain/Retain and same-source reuse with a synthetic Python marker process; no Trino SQL', + 'started_at': now(), 'passed': False, 'phases': [], 'controllers': [], 'mutations': [], + 'snapshots': [], 'marker_observations': [], 'negative_cases': [], + 'image_observations': [], 'provisioner_observations': [], + 'clock_note': 'command at/elapsed use host UTC/monotonic; Kubernetes timestamps retain their original server/node clock'} + self.persist() + + def persist(self): + temporary = self.output / 'verification.json.tmp' + temporary.write_text(json.dumps(self.report, indent=2) + '\n') + temporary.replace(self.output / 'verification.json') + + def command(self, argv, timeout=20, input_bytes=None, check=True): + if not self.closing: + remaining = self.deadline - time.monotonic() + require(remaining > 0, 'experiment deadline exhausted before command') + timeout = min(timeout, remaining) + self.command_index += 1 + index = self.command_index + receipt = {'index': index, 'command': argv, 'at': now(), 'stdin_bytes': len(input_bytes) if input_bytes is not None else None, + 'stdin_sha256': sha(input_bytes) if input_bytes is not None else None} + started = time.monotonic() + try: + result = subprocess.run(argv, input=input_bytes, capture_output=True, timeout=timeout, check=False) + receipt.update(returncode=result.returncode, stdout=result.stdout.decode(errors='replace'), stderr=result.stderr.decode(errors='replace')) + except subprocess.TimeoutExpired as error: + receipt.update(returncode=None, timed_out=True, stdout=(error.stdout or b'').decode(errors='replace'), + stderr=(error.stderr or b'').decode(errors='replace')) + except OSError as error: + receipt.update(returncode=None, command_error=repr(error), stdout='', stderr='') + finally: + receipt.update(completed_at=now(), elapsed_seconds=time.monotonic() - started) + (self.output / 'commands' / f'{index:05d}.json').write_text(json.dumps(receipt, indent=2) + '\n') + if check: + require(receipt.get('returncode') == 0, 'command failed: ' + repr(receipt)) + return receipt + + def kubectl(self, *argv, input_bytes=None, timeout=20, check=True, namespaced=True): + command = ['kubectl', '--kubeconfig', self.args.kubeconfig, '--request-timeout=15s'] + if namespaced: + command += ['-n', self.args.namespace] + return self.command(command + list(argv), timeout, input_bytes, check) + + def get(self, kind, name=None): + argv = ['get', kind] + ([name, '--ignore-not-found'] if name else []) + ['-o', 'json'] + result = self.kubectl(*argv, namespaced=kind not in {'pv', 'storageclass'}) + return json.loads(result['stdout']) if result['stdout'].strip() else None + + def apply(self, value, phase): + encoded = (json.dumps(value, sort_keys=True) + '\n').encode() + result = self.kubectl('apply', '-f', '-', input_bytes=encoded) + self.report['mutations'].append({'phase': phase, 'at': now(), 'intent': value, 'command_index': result['index']}) + self.persist() + + def phase(self, name): + self.report['phases'].append({'name': name, 'at': now()}) + self.persist() + + def until(self, name, fn, seconds=120): + end = min(self.deadline, time.monotonic() + seconds) + last = None + while time.monotonic() < end: + last = fn() + if last: + return last + time.sleep(.5) + raise VerificationError(name + ' exceeded its bounded observation deadline; last=' + repr(last)) + + def start_controller(self, capacity=None): + require(self.controller is None, 'controller is already running') + if capacity is not None: + self.capacity = capacity + index = len(self.report['controllers']) + log_name = f'controller-{index}.log' + binary_hash = sha(pathlib.Path(self.args.controller_binary).read_bytes()) + previous = self.report['controllers'][-1] if self.report['controllers'] else None + if previous: + require(previous.get('reaped') and self.report['controllers'][0]['binary_sha256'] == binary_hash, + 'controller restart did not preserve the binary or reap its previous process') + argv = [self.args.controller_binary, '--kubeconfig', self.args.kubeconfig, '--namespace', self.args.namespace, + '--image', self.args.image, '--storage-class', self.args.storage_class, '--capacity', self.capacity] + self.controller_stream = (self.output / log_name).open('wb') + try: + self.controller = subprocess.Popen(argv, stdout=self.controller_stream, stderr=subprocess.STDOUT) + except OSError: + self.controller_stream.close() + self.controller_stream = None + raise + self.report['controllers'].append({'index': index, 'pid': self.controller.pid, 'argv': argv, 'started_at': now(), + 'binary_sha256': binary_hash, 'log_path': log_name}) + self.persist() + if previous: + require(previous['pid'] != self.controller.pid, 'new controller reused the old PID; observation is ambiguous') + time.sleep(.2) + require(self.controller.poll() is None, 'controller exited at startup') + + def stop_controller(self): + if self.controller is None: + return + record = self.report['controllers'][-1] + record.update(stop_requested_at=now(), forced=False) + if self.controller.poll() is None: + self.controller.terminate() + try: + code = self.controller.wait(timeout=20) + except subprocess.TimeoutExpired: + record['forced'] = True + self.controller.kill() + code = self.controller.wait(timeout=5) + record.update(exited_at=now(), exit_code=code, reaped=True) + self.controller_stream.close() + self.controller, self.controller_stream = None, None + self.persist() + require(not record['forced'], 'controller required forced termination') + + def image_proof(self, pod): + main = next(c for c in pod['spec']['containers'] if c['name'] == 'trino') + require(main['image'] == self.args.image, 'Pod requests another fixture image') + process = identity(pod) + require(process['container_id'] and process['image_id'], 'Pod has no actual container/image identity') + if process['container_id'] in self.checked_images: + return + require(pod['spec']['nodeName'] == self.args.node_name, 'Pod is running outside the dedicated kind node') + inventory = json.loads(pathlib.Path(self.args.image_inventory).read_text()) + require(len(inventory) == 1 and self.args.image in inventory[0].get('RepoDigests', []), 'host image inventory lacks the pinned digest') + resolved = self.command(['docker', 'exec', self.args.node_name, 'crictl', 'inspecti', self.args.image]) + image = json.loads(resolved['stdout'])['status'] + normalize = lambda value: value.removeprefix('docker-pullable://').removeprefix('containerd://') + identities = {normalize(image['id']), *map(normalize, image.get('repoDigests', []))} + require(normalize(process['image_id']) in identities, 'actual Pod imageID differs from the pinned runtime image resolution') + container_id = process['container_id'].split('://', 1)[-1] + inspected = self.command(['docker', 'exec', self.args.node_name, 'crictl', 'inspect', container_id]) + container = json.loads(inspected['stdout'])['status'] + require(container['id'] == container_id and container.get('labels', {}).get('io.kubernetes.pod.uid') == process['pod_uid'] and + container.get('metadata', {}).get('name') == 'trino', 'CRI container observation belongs to another process') + require(normalize(container['imageRef']) in identities, 'actual CRI container did not resolve to the pinned image artifact') + self.report['image_observations'].append({'at': now(), 'process': process, 'requested_image': self.args.image, + 'host_image_id': inventory[0]['Id'], 'host_repo_digests': inventory[0]['RepoDigests'], + 'host_image_inventory_sha256': sha(pathlib.Path(self.args.image_inventory).read_bytes()), + 'runtime_image': image, 'cri_container_image_ref': container['imageRef'], + 'image_command_index': resolved['index'], 'container_command_index': inspected['index']}) + self.checked_images.add(process['container_id']) + self.persist() + + def start_storage_watch(self): + argv = ['kubectl', '--kubeconfig', self.args.kubeconfig, '--request-timeout=900s', '-n', 'local-path-storage', + 'get', 'pods', '--watch', '--output-watch-events', '-o', 'json'] + self.watch_streams = [(self.output / 'provisioner-pod-watch.json').open('wb'), + (self.output / 'provisioner-pod-watch.stderr').open('wb')] + self.storage_watch = subprocess.Popen(argv, stdout=self.watch_streams[0], stderr=self.watch_streams[1]) + self.report['storage_watch'] = {'pid': self.storage_watch.pid, 'command': argv, 'started_at': now()} + self.persist() + def attached(): + require(self.storage_watch.poll() is None, 'storage Pod watch exited before attachment') + values, _ = json_stream((self.output / 'provisioner-pod-watch.json').read_text()) + return any(v.get('type') == 'ADDED' and v.get('object', {}).get('metadata', {}).get('namespace') == 'local-path-storage' + and v['object']['metadata'].get('uid') for v in values) + self.until('initial storage Pod watch', attached, 20) + self.report['storage_watch']['attached_at'] = now() + self.persist() + + def record_provisioner(self, pv_name): + def observed(): + values, _ = json_stream((self.output / 'provisioner-pod-watch.json').read_text()) + for index, event in enumerate(values): + pod = event.get('object', {}) + if pod.get('metadata', {}).get('name') != 'helper-pod-create-' + pv_name: + continue + for state in pod.get('status', {}).get('containerStatuses', []): + if state.get('imageID') and state.get('containerID'): + return {'watch_event_index': index, 'watch_event': event, 'actual_image_id': state['imageID'], + 'actual_container_id': state['containerID']} + return None + helper = self.until('actual local-path provisioning helper image', observed, 30) + images = self.command(['docker', 'exec', self.args.node_name, 'crictl', 'images', '-o', 'json']) + inventory = json.loads(images['stdout']) + actual = helper['actual_image_id'].removeprefix('docker-pullable://').removeprefix('containerd://') + matched = [item for item in inventory['images'] if actual == item['id'] or actual in item.get('repoDigests', [])] + require(len(matched) == 1, 'actual provisioning helper imageID not found in the post-provision runtime inventory') + helper.update(at=now(), pv_name=pv_name, runtime_image=matched[0], runtime_inventory_command_index=images['index']) + self.report['provisioner_observations'].append(helper) + self.persist() + + def live_bundle(self, name): + cr = self.get(RESOURCE, name) + pvc = self.get('pvc', 'data-' + name + '-workers-default-0') + pv = self.get('pv', pvc['spec']['volumeName']) if pvc and pvc.get('spec', {}).get('volumeName') else None + return cr, pvc, pv + + def snapshot(self, name, phase, expected=None, capacity=None): + cr, pvc, pv = self.live_bundle(name) + observed = inspect_binding(cr, pvc, pv, self.args.storage_class, capacity or self.args.capacity, expected) + pod = self.get('pod', name + '-workers-default-0') + sts = self.get('statefulset', name + '-workers-default') + if pod: + validate_mount(pod, pvc['metadata']['name']) + self.image_proof(pod) + record = {'phase': phase, 'at': now(), 'identity': observed, 'cr': cr, 'pvc': pvc, 'pv': pv, 'pod': pod, 'statefulset': sts} + self.report['snapshots'].append(record) + self.persist() + return observed + + def wait_ready(self, name): + def ready(): + require(self.controller and self.controller.poll() is None, 'controller stopped during convergence') + cr, pvc, pv = self.live_bundle(name) + pod = self.get('pod', name + '-workers-default-0') + sts = self.get('statefulset', name + '-workers-default') + if not cr or not pvc or not pv or not pod or not sts: + return None + annotations = pvc['metadata'].get('annotations', {}) + if not annotations.get(BINDING) or pvc.get('status', {}).get('phase') != 'Bound' or pv.get('status', {}).get('phase') != 'Bound': + return None + observed = inspect_binding(cr, pvc, pv, self.args.storage_class, self.capacity) + conditions = {c['type']: c for c in cr.get('status', {}).get('conditions', [])} + if not (conditions.get('Applied', {}).get('status') == 'True' and + conditions.get('WorkloadsReady', {}).get('status') == 'True' and + cr.get('status', {}).get('observedGeneration') == cr['metadata']['generation']): + return None + require(sts['spec'].get('persistentVolumeClaimRetentionPolicy') == {'whenDeleted': 'Retain', 'whenScaled': 'Retain'}, + 'StatefulSet did not retain claims on both transitions') + status = next((c for c in pod.get('status', {}).get('containerStatuses', []) if c['name'] == 'trino'), {}) + if not status.get('ready') or not status.get('containerID') or pod['metadata'].get('deletionTimestamp'): + return None + owner = next((o for o in pod['metadata'].get('ownerReferences', []) if o.get('controller')), {}) + require(owner.get('uid') == sts['metadata']['uid'], 'Pod does not belong to the observed StatefulSet') + validate_mount(pod, pvc['metadata']['name']) + self.image_proof(pod) + return {'binding': observed, 'process': identity(pod), 'statefulset_uid': sts['metadata']['uid']} + result = self.until(name + ' ready and binding receipt', ready, 180) + self.snapshot(name, name + '-ready', result['binding']) + return result + + def set_group(self, name, present, phase): + value = fixture(name, self.args.namespace, present) + # The public CR owns storage. The role group overrides capacity only; + # class and type must survive role inheritance all the way to the PVC. + value['spec']['workers']['config'] = {'resources': {'storage': { + 'type': 'persistent', 'storageClassName': self.args.storage_class, 'capacity': '32Mi'}}} + if present: + value['spec']['workers']['roleGroups']['default']['config']['resources'] = { + 'storage': {'capacity': self.capacity}} + self.apply(value, phase) + + def absent_group(self, name): + def absent(): + slots = [('statefulset', name + '-workers-default'), ('service', name + '-workers-default'), + ('service', name + '-workers-default-headless'), ('configmap', name + '-workers-default')] + if any(self.get(kind, slot) is not None for kind, slot in slots): + return False + pods = self.get('pods')['items'] + if any(p['metadata']['name'].startswith(name + '-workers-default-') for p in pods): + return False + cr = self.get(RESOURCE, name) + retired = next((c for c in cr.get('status', {}).get('conditions', []) if c['type'] == 'Retired'), {}) + return retired.get('status') == 'True' and cr.get('status', {}).get('observedGeneration') == cr['metadata']['generation'] + self.until(name + ' fixed slots and original Pods absent', absent) + self.report['phases'].append({'name': name + '-retired-slots-absent', 'at': now()}) + self.persist() + + def exec_python(self, pod, code, *args, data=None): + result = self.kubectl('exec', '-i', pod, '-c', 'trino', '--', 'python3', '-c', code, *args, input_bytes=data) + return json.loads(result['stdout']), result['index'] + + def write_marker(self, name, ready): + payload = (json.dumps({'nonce': str(uuid.uuid4()), 'initial_binding': ready['binding']}, sort_keys=True) + '\n').encode() + before = identity(self.get('pod', ready['process']['pod_name'])) + require(before == ready['process'], 'process changed before exclusive marker write') + written, command = self.exec_python(before['pod_name'], WRITE_MARKER, '/data/marker.json', data=payload) + require(written.get('uid') == 1000 and written.get('gid') == 1000, 'marker writer is not the expected non-root identity') + require(written['sha256'] == sha(payload) and base64.b64decode(written['base64']) == payload and + written.get('fsync_file') and written.get('fsync_parent_directory'), 'fsync marker write did not match the known bytes') + require(identity(self.get('pod', before['pod_name'])) == before, 'process changed during marker write') + record = {'phase': name + '-exclusive-write-fsync', 'at': now(), 'process': before, 'command_index': command, 'result': written} + self.report['marker_observations'].append(record) + self.persist() + return payload + + def read_marker(self, pod, payload, phase, http=True): + original_pod = self.get('pod', pod) + before = identity(original_pod) + self.image_proof(original_pod) + result, command = self.exec_python(pod, HTTP_MARKER if http else READ_MARKER, *([] if http else ['/data/marker.json'])) + require(result['sha256'] == sha(payload) and result['bytes'] == len(payload) and base64.b64decode(result['base64']) == payload, + 'original marker bytes were not retained') + if http: + require(result.get('status') == 200 and int(result['content_length']) == len(payload), 'HTTP marker response did not match') + require(identity(self.get('pod', pod)) == before, 'reader process changed during data observation') + self.report['marker_observations'].append({'phase': phase, 'at': now(), 'process': before, 'read_only': True, + 'command_index': command, 'result': result}) + self.persist() + + def inspect_retired_marker(self, name, payload, expected): + reader_name = 'retained-reader-' + str(len(self.inspectors)) + reader = {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': reader_name, 'namespace': self.args.namespace, + 'labels': {'operator-go.design/fixture': 'read-only-observer'}}, + 'spec': {'restartPolicy': 'Never', 'terminationGracePeriodSeconds': 1, + 'securityContext': {'runAsUser': 1000, 'runAsGroup': 1000, 'fsGroup': 1000, 'runAsNonRoot': True}, + 'containers': [{'name': 'trino', 'image': self.args.image, 'command': ['python3', '-c', 'import time;time.sleep(180)'], + 'volumeMounts': [{'name': 'data', 'mountPath': '/data', 'readOnly': True}]}], + 'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': expected['pvc_name'], 'readOnly': True}}]}} + self.inspectors.append(reader_name) + self.apply(reader, name + '-read-only-inspector') + self.until('read-only inspector running', lambda: any(c.get('ready') for c in (self.get('pod', reader_name) or {}).get('status', {}).get('containerStatuses', [])), 90) + self.read_marker(reader_name, payload, name + '-retired-volume-read', http=False) + self.delete_object('pod', reader_name, name + '-delete-inspector') + self.until('inspector absent before re-add', lambda: self.get('pod', reader_name) is None, 45) + self.snapshot(name, name + '-retired-binding-after-read', expected) + + def delete_object(self, kind, name, phase): + live = self.get(kind, name) + require(live, 'explicit deletion target does not exist: ' + name) + # kubectl delete --raw supports Kubernetes DeleteOptions preconditions. + if kind == 'pod': + path = '/api/v1/namespaces/' + self.args.namespace + '/pods/' + name + elif kind == 'pvc': + path = '/api/v1/namespaces/' + self.args.namespace + '/persistentvolumeclaims/' + name + elif kind == RESOURCE: + path = '/apis/trino.kubedoop.dev/v1alpha1/namespaces/' + self.args.namespace + '/trinoclusters/' + name + else: + raise VerificationError('unsupported explicit delete kind') + options = {'apiVersion': 'v1', 'kind': 'DeleteOptions', 'preconditions': {'uid': live['metadata']['uid'], + 'resourceVersion': live['metadata']['resourceVersion']}, 'propagationPolicy': 'Background'} + result = self.kubectl('delete', '--raw', path, '-f', '-', input_bytes=json.dumps(options).encode()) + self.report['mutations'].append({'phase': phase, 'at': now(), 'kind': kind, 'name': name, 'delete_options': options, + 'command_index': result['index']}) + self.persist() + + def patch_pvc(self, name, patch, phase): + live = self.get('pvc', name) + operations = [{'op': 'test', 'path': '/metadata/uid', 'value': live['metadata']['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': live['metadata']['resourceVersion']}] + patch + result = self.kubectl('patch', 'pvc', name, '--type=json', '-p', json.dumps(operations)) + self.report['mutations'].append({'phase': phase, 'at': now(), 'pvc_name': name, 'operations': operations, + 'command_index': result['index']}) + self.persist() + + def wait_blocked(self, name, expected_text, original, phase): + def blocked(): + cr = self.get(RESOURCE, name) + require(self.get('statefulset', name + '-workers-default') is None, 'unsafe claim was consumed by a new StatefulSet') + pods = self.get('pods')['items'] + require(not any(v.get('persistentVolumeClaim', {}).get('claimName') == original['pvc_name'] + for p in pods for v in p.get('spec', {}).get('volumes', [])), 'unsafe claim was consumed by a Pod') + if cr.get('status', {}).get('observedGeneration') != cr['metadata']['generation']: + return None + groups = cr.get('status', {}).get('groups', []) + message = '\n'.join(g.get('message', '') for g in groups) + return cr if expected_text in message else None + observed = self.until(phase + ' reported storage conflict', blocked, 60) + # Repeated fresh observations ensure a reported error did not race workload creation. + for _ in range(3): + time.sleep(.5) + require(blocked(), 'storage conflict disappeared or did not hold') + pvc = self.get('pvc', original['pvc_name']) + pv = self.get('pv', original['pv_name']) + require(pvc and pv and pvc['metadata']['uid'] == original['pvc_uid'] and pv['metadata']['uid'] == original['pv_uid'], + 'negative scenario changed the data resource identity') + self.report['negative_cases'].append({'phase': phase, 'at': now(), 'expected_message_fragment': expected_text, + 'cr': observed, 'pvc': pvc, 'pv': pv, 'no_dependent_statefulset_or_pod': True}) + self.persist() + + def prepare_retired(self, name): + self.set_group(name, True, name + '-create') + ready = self.wait_ready(name) + self.record_provisioner(ready['binding']['pv_name']) + payload = self.write_marker(name, ready) + self.set_group(name, False, name + '-remove-group') + self.absent_group(name) + self.snapshot(name, name + '-retained', ready['binding']) + return ready, payload + + def run(self): + self.start_storage_watch() + self.start_controller() + name = 'retained-main' + self.phase('same-source-core') + ready, payload = self.prepare_retired(name) + self.inspect_retired_marker(name, payload, ready['binding']) + self.stop_controller() + self.start_controller() + self.set_group(name, True, 'same-source-readd-after-controller-restart') + restored = self.wait_ready(name) + require(restored['binding'] == ready['binding'], 're-add replaced the original data binding') + require(restored['process']['pod_uid'] != ready['process']['pod_uid'] and + restored['statefulset_uid'] != ready['statefulset_uid'], 're-add did not create new workload identities') + self.read_marker(restored['process']['pod_name'], payload, 'restored-original-bytes') + self.snapshot(name, 'restored-binding', ready['binding']) + self.set_group(name, False, 'remove-restored-main-before-negative') + self.absent_group(name) + + self.phase('negative-same-name-new-cr-uid') + self.delete_object(RESOURCE, name, 'delete-original-cr-after-retirement') + self.until('original CR absent', lambda: self.get(RESOURCE, name) is None, 45) + self.set_group(name, True, 'recreate-same-name-cr') + require(self.get(RESOURCE, name)['metadata']['uid'] != ready['binding']['cr_uid'], 'new CR did not get a new UID') + self.wait_blocked(name, 'provenance', ready['binding'], 'same-name-new-cr-uid') + self.set_group(name, False, 'remove-blocked-new-cr-group') + + for case in ('missing-provenance', 'dangerous-ownerref', 'claim-deleting', 'spec-diff'): + self.phase('negative-' + case) + current = 'negative-' + case + state, marker = self.prepare_retired(current) + claim = state['binding']['pvc_name'] + if case == 'missing-provenance': + self.patch_pvc(claim, [{'op': 'remove', 'path': '/metadata/annotations/' + SOURCE.replace('~', '~0').replace('/', '~1')}], case) + expected = 'provenance' + elif case == 'dangerous-ownerref': + guard = {'apiVersion': 'v1', 'kind': 'ConfigMap', 'metadata': {'name': 'ownerref-guard', 'namespace': self.args.namespace}} + self.apply(guard, 'create-live-ownerref-guard') + guard = self.get('configmap', 'ownerref-guard') + self.patch_pvc(claim, [{'op': 'add', 'path': '/metadata/ownerReferences', 'value': [{'apiVersion': 'v1', + 'kind': 'ConfigMap', 'name': guard['metadata']['name'], 'uid': guard['metadata']['uid'], 'controller': False}]}], case) + expected = 'owner references' + elif case == 'claim-deleting': + pvc = self.get('pvc', claim) + finalizers = pvc['metadata'].get('finalizers', []) + [FINALIZER] + self.patch_pvc(claim, [{'op': 'add', 'path': '/metadata/finalizers', 'value': finalizers}], case + '-hold-finalizer') + self.delete_object('pvc', claim, case + '-delete-held-claim') + self.until('claim deletion timestamp', lambda: self.get('pvc', claim)['metadata'].get('deletionTimestamp'), 30) + expected = 'deleting' + else: + self.capacity = '128Mi' + expected = 'provenance' + self.set_group(current, True, case + '-readd') + self.wait_blocked(current, expected, state['binding'], case) + self.set_group(current, False, case + '-remove-blocked-group') + if case == 'spec-diff': + self.capacity = self.args.capacity + # The held/decorated claims remain untouched until dedicated kind cleanup. + require(sha(marker), 'negative marker evidence missing') + self.report['passed'] = True + + def close(self): + self.closing = True + cleanup_errors = [] + try: + self.stop_controller() + except Exception as error: + cleanup_errors.append(repr(error)) + for name in self.inspectors: + try: + self.kubectl('delete', 'pod', name, '--ignore-not-found', '--wait=false', timeout=20) + except Exception as error: + cleanup_errors.append(repr(error)) + if self.storage_watch is not None: + record = self.report['storage_watch'] + record['forced'] = False + if self.storage_watch.poll() is None: + self.storage_watch.terminate() + try: + code = self.storage_watch.wait(timeout=10) + except subprocess.TimeoutExpired: + record['forced'] = True + self.storage_watch.kill() + code = self.storage_watch.wait(timeout=5) + record.update(exit_code=code, reaped=True, exited_at=now()) + for stream in self.watch_streams: + stream.close() + _, partial = json_stream((self.output / 'provisioner-pod-watch.json').read_text()) + record['incomplete_final_json_record'] = partial + if record['forced']: + cleanup_errors.append('storage watch required forced termination') + self.report.update(completed_at=now(), command_count=self.command_index, cleanup_errors=cleanup_errors, + all_owned_processes_stopped=all(c.get('reaped') and not c.get('forced') for c in self.report['controllers'])) + if cleanup_errors or not self.report['all_owned_processes_stopped']: + self.report['passed'] = False + self.persist() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('kubeconfig', 'namespace', 'output-dir', 'controller-binary', 'image', 'storage-class', 'node-name', 'image-inventory'): + parser.add_argument('--' + name, required=True) + parser.add_argument('--capacity', default='64Mi') + parser.add_argument('--timeout', type=float, default=900) + args = parser.parse_args() + if os.getpgrp() != os.getpid(): + os.setsid() + verifier = Verifier(args) + verifier.report['process_group'] = {'pid': os.getpid(), 'pgid': os.getpgrp(), 'owned_controller_children_share_this_group': True} + def interrupted(signum, _frame): + raise VerificationError('experiment interrupted by signal ' + str(signum)) + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + try: + verifier.run() + except Exception as error: + verifier.report.update(passed=False, error=repr(error)) + print(repr(error), file=sys.stderr) + finally: + verifier.close() + raise SystemExit(0 if verifier.report['passed'] else 1) + + +if __name__ == '__main__': + main() diff --git a/internal/framework/controller/AGENTS.md b/internal/framework/controller/AGENTS.md new file mode 100644 index 00000000..526b12aa --- /dev/null +++ b/internal/framework/controller/AGENTS.md @@ -0,0 +1,140 @@ +# Internal framework controller + +Parent instructions: [AGENTS.md](../../../AGENTS.md). +Authoritative design: [framework design, sections 5–7](../../../docs/architecture.md#framework-design). + +This package executes the formal pipeline. It imports framework/input/pipeline and +Kubernetes/controller-runtime; production files import neither the discussion +prototype nor product adapters. Products use generated registration through +`pkg/framework/operator`, not this mutable internal Reconciler. + +## Files and execution + +- `reconciler.go` reads the generated object, gates operation before Project, + combines Projection with separate registered Facts, prepares/builds each group, + applies independent role/group/shared outputs, retires withdrawn groups and + conditionally publishes status for the observed UID/generation. +- `dependencies.go` provides exact Get with per-pass GVK/namespace/name caching, + caller copies and framework-owned UID/resourceVersion observations. Classified + pending/invalid/readError results withhold only their group. Non-NotFound reader + failures cannot be converted into a resolved value by a resolver ignoring errors. +- `apply.go` validates ownership, fixed source slots, reserved metadata and storage + before writes. Server dry-run normalizes updates; unchanged desired state keeps + resourceVersion. API allocations, status and undeclared external metadata survive. +- `shared_configmap.go` owns the complete shared ConfigMap set. Ready applies that + set and retires absent trusted slots; Ready/empty withdraws all. Pending preserves + previous output and its reason; invalid output or generation errors preserve old + output and fail. No callback is Ready/empty. Live receipts survive controller + restart/status loss. Custom same-owner ConfigMaps are neither adopted nor deleted. +- `role_pdb.go` applies enabled role budgets independently of group config/facts and + retires disabled/removed role slots. Budgets use full declared replicas. +- `retirement.go` reconstructs fixed slots from live sources; scales to zero, + requires current controller zero observations plus actual Pod absence, then + deletes and confirms one slot per pass. Re-add waits for terminating objects. + Fully validated shared ConfigMaps are excluded before group-name/label candidate + heuristics, so shared output can retain user-supplied group-looking metadata. +- `storage.go` checks single RWO/Filesystem Retain data, StorageClass/PV reclaim, + PVC source and binding receipts, all historical ordinals and actual consumers. + It permits first-consumer creation while unbound, never deletes PVC/PV, and + refuses conflicting surviving data rather than adopting or silently migrating it. +- `stop.go` independently stops trusted live workloads even when projection or + config fails. It reobserves StatefulSet UID/RV and actual Pods; incomplete + inventory cannot become a successful all-stopped observation. +- `operation_client.go` wraps each pass and rechecks current CR identity, + generation, deletion and operation before every API request/retry. Checks and + child writes are not an atomic multi-resource transaction. + +`Client` must be a direct API client. The public registration implementation +constructs it; manager caching is used for scheduling watches. Setup watches the +CR plus owned ConfigMaps, Services, StatefulSets and role PDBs. Resolvers refresh +at 30 seconds by default, pending work at min(refresh, 2 seconds), including built-in references when no product resolver is registered. Per-key error backoff is bounded; this is a queue +scheduling bound, not a wall-clock guarantee during API stalls or queue backlog. + +Pause reads Operation before full Project/facts/resource access. It updates only +Paused and the top observed generation, preserving execution condition generations +and group/role observations. Stable pause does not write or poll. Stop preserves +all declared replicas and PDB budgets while execution targets become zero; stopped +workload readiness remains Unknown rather than asserting application availability. + +## Internal provenance + +All formal receipts use `framework.kubedoop.dev/`; old prototype annotations are +not authority and receive no compatibility adoption: + +| Suffix | Payload | +| --- | --- | +| `managed-metadata` | Object/template labels and annotations declared by the controller | +| `group-slot` | role, group, one of four fixed slots | +| `role-pdb` | role and fixed pdb slot | +| `shared-configmap` | version 1, crUID and exact ConfigMap name | +| `retained-data` | version 1, crUID, role/group/slot, class and canonical capacity | +| `retained-binding` | version 1, exact PVC UID, PV UID and volumeName | + +Receipts alone are insufficient: current owner UID/name/GVK, namespace/name, +expected slot and managed identity metadata must agree. Source checks run again +inside conflict retries; deletes carry exact UID/resourceVersion preconditions. +The controller does not manufacture proof of a previously lost PVC or data bytes. + +## Tests and proof boundaries + +Fake-client tests exercise API-call ordering, identity conflicts, metadata/no-op +logic, partial failure isolation, facts refresh, operation races, retirement and +Retain safety. `fixture_test.go` provides a minimal test model and consumes U02's +formally generated test input. It does not import a production product adapter. + +`TestControllerAPIConvergence` starts an isolated real API server and manager. It +checks shared output create/no-op/Pending/error/replacement/empty withdrawal, +pause/stop/latest-replica recovery, actual API Pod presence blocking retirement, +and persisted Retain VCT/PVC/PV source and binding identity across re-add. +Its zero-workload and Bound statuses are explicit fixture observations: envtest +runs no StatefulSet controller, provisioner, kubelet or product process. Mounts, +filesystem contents, actual shutdown and product health require deployment evidence. + +Focused commands from repository root: + +```sh +go test ./internal/framework/controller -count=1 +go test ./internal/framework/controller -run '^TestControllerAPIConvergence$' -count=1 -v +./bin/golangci-lint run ./internal/framework/controller/... +``` + +API tests require KUBEBUILDER_ASSETS or the repository's Kubernetes 1.35 binaries; +missing assets fail the test. They never connect to an existing Kubernetes cluster. + +`coordination.go` consumes WorkloadCoordination receipts. Normal scale-down, stop and retirement +share one-ordinal stepping with direct UID-authenticated Pod observations. Stop/retirement honor +ascending live shutdown priority. Native OrderedReady/RollingUpdate owns rolling replacement. +Versioned workload-progress annotations preserve progress deadlines across controller restart; +timeout reports an error without force-deleting or asserting graceful success. + +## E05 independent data identity + +Normal `preflightStorage` records a `dataops.DataAsset` after binding checks. +Shared checks used by stop/retirement do not create assets. Existing recorded +claims and the independent asset history are checked before reuse; a missing +original PVC, active operation lock or transferred data owner blocks implicit +fresh data creation. `dataops` types and CRDs are part of the formal persistent +storage installation. Product reconciliation does not execute DataOperations: +a separately deployed executor owns approved copy/rebind/erase and backend +reclamation. See [data-operation protocol](../../../docs/architecture.md#framework-data-operations). + +## Platform observations + +`platform_references.go` reads declared platform sources and Secret env references +from the final Pod, including optional references. `platform_observation.go` keeps +pre-apply readiness separate from post-apply current Pod/PVC/PV/Listener identity. +Native Secret UID/RV and class UID/generation produce a template digest; Listener +status refresh updates shared output without changing the template. Pending +post-creation results never withhold their producer. Groups expose a separate +PlatformObservation and the cluster exposes PlatformReady. Shared generation is +refreshed after observations and preserves the prior valid output while Pending. + +Platform generic ephemeral volumes are admitted only from the typed runtime declaration. +`platform_storage.go` reconstructs exact sources, compares final Pod volumes, and stamps a +reserved StatefulSet `framework.kubedoop.dev/platform-claims` receipt bound to the CR UID and +role/group. Storage preflight, apply, stop, and retirement validate that source alongside existing +slot ownership. A matching platform StorageClass alone never admits an arbitrary Pod override +claim. These temporary platform volumes coexist with a separately declared retained data slot; +Kubernetes ephemeral ownership and the platform provisioner own their cleanup, not DataOperation. +Preparing references cannot publish Observing readiness, and zero active producers keep prior +shared outputs pending rather than fabricating an address. diff --git a/internal/framework/controller/apply.go b/internal/framework/controller/apply.go new file mode 100644 index 00000000..4feeff2d --- /dev/null +++ b/internal/framework/controller/apply.go @@ -0,0 +1,545 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "slices" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + kubernetesjson "sigs.k8s.io/json" +) + +// ManagedMetadataAnnotation records only keys previously declared by this +// controller, so withdrawing one does not remove another controller's metadata. +const ManagedMetadataAnnotation = "framework.kubedoop.dev/managed-metadata" + +type metadataKeys struct { + Labels []string `json:"labels,omitempty"` + Annotations []string `json:"annotations,omitempty"` +} + +type managedMetadata struct { + Object metadataKeys `json:"object"` + Template metadataKeys `json:"template"` +} + +// ApplyObject supports ConfigMap, Service, StatefulSet and PodDisruptionBudget. The client MUST +// read directly from the API server: every conflict retry starts with a fresh +// Get; an informer cache cannot provide that contract. +// +// Existing objects are canonicalized with a dry-run Update before comparison. +// Public client-go schemes do not contain the server's complete defaulting +// functions. The controller pays one additional request per existing +// object per pass rather than maintain a partial copy of Kubernetes defaults. +// A no-op performs no persisted Update and leaves resourceVersion unchanged. +func ApplyObject( + ctx context.Context, c client.Client, owner client.Object, desired client.Object, scheme *runtime.Scheme, +) (bool, error) { + return applyObject(ctx, c, owner, desired, scheme, nil, nil) +} + +func applyObject(ctx context.Context, c client.Client, owner client.Object, desired client.Object, + scheme *runtime.Scheme, slot *groupSlot, retained *pipeline.RetainedDataSlot, +) (bool, error) { + return applyScopedObject(ctx, c, owner, desired, scheme, slot, retained, nil, nil, nil) +} + +func applyScopedObject(ctx context.Context, c client.Client, owner client.Object, desired client.Object, + scheme *runtime.Scheme, slot *groupSlot, retained *pipeline.RetainedDataSlot, role *rolePDBSlot, + shared *sharedConfigMapSlot, platformRuntime *framework.RuntimeDescription, + policies ...*framework.WorkloadCoordination, +) (bool, error) { + if err := validateApplyRequest(c, owner, desired, scheme); err != nil { + return false, err + } + desired, err := stampPlatformClaims(owner, desired, slot, platformRuntime) + if err != nil { + return false, err + } + desired = stampCoordination(desired, policies) + desired, err = prepareScopedObject(owner, desired, slot, retained, role, shared) + if err != nil { + return false, err + } + ownerKind, err := apiutil.GVKForObject(owner, scheme) + if err != nil { + return false, err + } + changed := false + err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { + live := desired.DeepCopyObject().(client.Object) + if err := c.Get(ctx, client.ObjectKeyFromObject(desired), live); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + if err := checkApplyStorage(ctx, c, owner, desired, nil, slot, retained); err != nil { + return err + } + created, err := createOwnedObject(ctx, c, owner, desired, scheme) + changed = created + return err + } + ownerCopy := live.DeepCopyObject().(client.Object) + ownerCopy.SetDeletionTimestamp(nil) + if err := checkOwnership(owner, ownerCopy, ownerKind); err != nil { + return err + } + if err := checkApplyShared(owner, live, ownerKind, shared); err != nil { + return err + } + if err := checkApplyRolePDB(owner, live, ownerKind, role); err != nil { + return err + } + if err := checkOwnership(owner, live, ownerKind); err != nil { + return err + } + if err := checkApplyGroupSlot(owner, live, ownerKind, slot); err != nil { + return err + } + if err := checkApplyStorage(ctx, c, owner, desired, live, slot, retained); err != nil { + return err + } + next, err := desiredObject(desired, live) + if err != nil { + return err + } + if err := coordinateDesired(ctx, c, next, live); err != nil { + return err + } + if err := c.Update(ctx, next, client.DryRunAll); err != nil { + if apierrors.IsInvalid(err) { + if immutable := checkImmutableStatefulSet(next, live); immutable != nil { + return fmt.Errorf("%v: %w", immutable, err) + } + } + return fmt.Errorf("canonicalize %T %s: %w", desired, desired.GetName(), err) + } + if err := checkImmutableStatefulSet(next, live); err != nil { + return err + } + if equalAppliedState(next, live) { + return nil + } + // Dry-run response metadata/status are not another write authority. + next.SetResourceVersion(live.GetResourceVersion()) + next.SetGeneration(live.GetGeneration()) + next.SetUID(live.GetUID()) + next.SetOwnerReferences(live.GetOwnerReferences()) + next.SetManagedFields(live.GetManagedFields()) + preserveStatus(next, live) + if err := c.Update(ctx, next); err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +func prepareScopedObject(owner, desired client.Object, slot *groupSlot, + retained *pipeline.RetainedDataSlot, role *rolePDBSlot, shared *sharedConfigMapSlot, +) (client.Object, error) { + if shared != nil { + var err error + desired, err = stampSharedSlot(owner, desired, *shared) + if err != nil { + return nil, err + } + } + if slot != nil { + desired = desired.DeepCopyObject().(client.Object) + data, err := json.Marshal(slot) + if err != nil { + return nil, err + } + annotations := maps.Clone(desired.GetAnnotations()) + if annotations == nil { + annotations = map[string]string{} + } + annotations[GroupSlotAnnotation] = string(data) + desired.SetAnnotations(annotations) + } + if role != nil { + var err error + desired, err = stampRolePDB(desired, owner, *role) + if err != nil { + return nil, err + } + } + if sts, ok := desired.(*appsv1.StatefulSet); ok { + if retained != nil && slot == nil { + return nil, fmt.Errorf("retained storage requires a declared group slot") + } + group := groupSlot{} + if slot != nil { + group = *slot + } + next, err := stampRetainedSource(sts, owner, group, retained) + if err != nil { + return nil, err + } + desired = next + } + return desired, nil +} + +func nilObject(object client.Object) bool { + return object == nil || (reflect.ValueOf(object).Kind() == reflect.Pointer && reflect.ValueOf(object).IsNil()) +} + +func validateApplyRequest(c client.Client, owner, desired client.Object, scheme *runtime.Scheme) error { + if c == nil || scheme == nil || nilObject(owner) || nilObject(desired) { + return fmt.Errorf("apply requires a client, scheme, owner and desired object") + } + if owner.GetUID() == "" || owner.GetName() == "" || !owner.GetDeletionTimestamp().IsZero() { + return fmt.Errorf("apply owner requires a live name and UID") + } + if desired.GetName() == "" || desired.GetNamespace() == "" || + (owner.GetNamespace() != "" && owner.GetNamespace() != desired.GetNamespace()) { + return fmt.Errorf("apply requires a named object in its owner's namespace") + } + switch desired.(type) { + case *corev1.ConfigMap, *corev1.Service, *appsv1.StatefulSet, *policyv1.PodDisruptionBudget: + default: + return fmt.Errorf("unsupported apply object %T", desired) + } + metadata := []metav1.Object{desired} + if statefulSet, ok := desired.(*appsv1.StatefulSet); ok { + metadata = append(metadata, &statefulSet.Spec.Template) + for i := range statefulSet.Spec.VolumeClaimTemplates { + metadata = append(metadata, &statefulSet.Spec.VolumeClaimTemplates[i]) + } + } + for _, item := range metadata { + for _, key := range []string{ManagedMetadataAnnotation, GroupSlotAnnotation, RolePDBAnnotation, + SharedConfigMapAnnotation, + retainedSourceAnnotation, retainedBindingAnnotation, platformClaimsAnnotation, + coordinationAnnotation, coordinationProgressAnnotation} { + _, annotation := item.GetAnnotations()[key] + _, label := item.GetLabels()[key] + if annotation || label { + return fmt.Errorf("desired metadata contains reserved key %q", key) + } + } + } + return nil +} + +func checkOwnership(owner, live client.Object, ownerKind schema.GroupVersionKind) error { + controller := metav1.GetControllerOf(live) + if controller == nil || controller.UID != owner.GetUID() || controller.Name != owner.GetName() || + controller.APIVersion != ownerKind.GroupVersion().String() || controller.Kind != ownerKind.Kind { + return fmt.Errorf("refusing to adopt %T %s/%s: matching controller owner UID, name and GVK are required", + live, live.GetNamespace(), live.GetName()) + } + if !live.GetDeletionTimestamp().IsZero() { + return fmt.Errorf("cannot apply terminating %T %s/%s", live, live.GetNamespace(), live.GetName()) + } + return nil +} + +func createOwnedObject( + ctx context.Context, c client.Client, owner, desired client.Object, scheme *runtime.Scheme, +) (bool, error) { + next := desired.DeepCopyObject().(client.Object) + next.SetUID("") + next.SetResourceVersion("") + next.SetGeneration(0) + next.SetCreationTimestamp(metav1.Time{}) + next.SetManagedFields(nil) + clearStatus(next) + if err := mergeMetadata(desired, nil, next); err != nil { + return false, err + } + if err := controllerutil.SetControllerReference(owner, next, scheme); err != nil { + return false, err + } + if err := c.Create(ctx, next); err != nil { + if apierrors.IsAlreadyExists(err) { + // Race with another creator: retry through Get and ownership validation. + return false, apierrors.NewConflict(schema.GroupResource{Resource: "objects"}, desired.GetName(), err) + } + return false, err + } + return true, nil +} + +func desiredObject(desired, live client.Object) (client.Object, error) { + next := live.DeepCopyObject().(client.Object) + switch target := next.(type) { + case *corev1.ConfigMap: + want := desired.(*corev1.ConfigMap).DeepCopy() + target.Data, target.BinaryData, target.Immutable = want.Data, want.BinaryData, want.Immutable + case *corev1.Service: + target.Spec = desired.(*corev1.Service).DeepCopy().Spec + preserveServiceAllocations(&target.Spec, &live.(*corev1.Service).Spec) + case *appsv1.StatefulSet: + target.Spec = desired.(*appsv1.StatefulSet).DeepCopy().Spec + case *policyv1.PodDisruptionBudget: + target.Spec = desired.(*policyv1.PodDisruptionBudget).DeepCopy().Spec + } + if err := mergeMetadata(desired, live, next); err != nil { + return nil, err + } + return next, nil +} + +func metadataDeclaration(object metav1.Object) metadataKeys { + return metadataKeys{Labels: slices.Sorted(maps.Keys(object.GetLabels())), + Annotations: slices.Sorted(maps.Keys(object.GetAnnotations()))} +} + +func decodeManagedMetadata(live client.Object) (managedMetadata, error) { + var previous managedMetadata + if live == nil { + return previous, nil + } + text, present := live.GetAnnotations()[ManagedMetadataAnnotation] + if !present { + return previous, fmt.Errorf("existing controlled object is missing its managed metadata record") + } + // Strict decoding rejects duplicate keys as well as unknown fields. Null is + // not an empty record; a damaged ownership record must never silently prune. + strict, err := kubernetesjson.UnmarshalStrict([]byte(text), &previous) + if err != nil || len(strict) != 0 { + return previous, fmt.Errorf("invalid managed metadata record: %w", errors.Join(append(strict, err)...)) + } + var shape map[string]any + if err := json.Unmarshal([]byte(text), &shape); err != nil { + return previous, err + } + if _, ok := shape["object"].(map[string]any); !ok { + return previous, fmt.Errorf("managed metadata record requires an object key set") + } + if _, ok := shape["template"].(map[string]any); !ok || containsNull(shape) { + return previous, fmt.Errorf("managed metadata record requires a template key set and forbids null") + } + for _, keys := range []metadataKeys{previous.Object, previous.Template} { + for _, list := range [][]string{keys.Labels, keys.Annotations} { + seen := map[string]bool{} + for _, key := range list { + if len(validation.IsQualifiedName(key)) != 0 || seen[key] || key == ManagedMetadataAnnotation { + return previous, fmt.Errorf("invalid managed metadata key %q", key) + } + seen[key] = true + } + } + } + return previous, nil +} + +func containsNull(value any) bool { + switch item := value.(type) { + case nil: + return true + case map[string]any: + for _, child := range item { + if containsNull(child) { + return true + } + } + case []any: + for _, child := range item { + if containsNull(child) { + return true + } + } + } + return false +} + +func mergeDeclaredMap(live, desired map[string]string, previous []string) map[string]string { + out := maps.Clone(live) + if out == nil { + out = map[string]string{} + } + for _, key := range previous { + delete(out, key) + } + maps.Copy(out, desired) + if len(out) == 0 { + return nil + } + return out +} + +func mergeMetadata(desired, live, next client.Object) error { + previous, err := decodeManagedMetadata(live) + if err != nil { + return err + } + var labels, annotations map[string]string + if live != nil { + labels, annotations = live.GetLabels(), live.GetAnnotations() + } + next.SetLabels(mergeDeclaredMap(labels, desired.GetLabels(), previous.Object.Labels)) + next.SetAnnotations(mergeDeclaredMap(annotations, desired.GetAnnotations(), previous.Object.Annotations)) + declared := managedMetadata{Object: metadataDeclaration(desired)} + if target, ok := next.(*appsv1.StatefulSet); ok { + want := desired.(*appsv1.StatefulSet) + var liveLabels, liveAnnotations map[string]string + if live != nil { + stored := live.(*appsv1.StatefulSet) + liveLabels, liveAnnotations = stored.Spec.Template.Labels, stored.Spec.Template.Annotations + } + target.Spec.Template.Labels = mergeDeclaredMap(liveLabels, want.Spec.Template.Labels, previous.Template.Labels) + target.Spec.Template.Annotations = mergeDeclaredMap( + liveAnnotations, want.Spec.Template.Annotations, previous.Template.Annotations) + declared.Template = metadataDeclaration(&want.Spec.Template) + } + data, err := json.Marshal(declared) + if err != nil { + return err + } + annotations = next.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[ManagedMetadataAnnotation] = string(data) + next.SetAnnotations(annotations) + return nil +} + +func serviceType(spec *corev1.ServiceSpec) corev1.ServiceType { + if spec.Type == "" { + return corev1.ServiceTypeClusterIP + } + return spec.Type +} + +func preserveServiceAllocations(next, live *corev1.ServiceSpec) { + if serviceType(next) != corev1.ServiceTypeExternalName { + if next.ClusterIP == "" { + next.ClusterIP = live.ClusterIP + } + if len(next.ClusterIPs) == 0 { + next.ClusterIPs = slices.Clone(live.ClusterIPs) + } + if len(next.IPFamilies) == 0 { + next.IPFamilies = slices.Clone(live.IPFamilies) + } + if next.IPFamilyPolicy == nil && live.IPFamilyPolicy != nil { + value := *live.IPFamilyPolicy + next.IPFamilyPolicy = &value + } + } + if serviceType(next) == corev1.ServiceTypeLoadBalancer && + next.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal && next.HealthCheckNodePort == 0 { + next.HealthCheckNodePort = live.HealthCheckNodePort + } + if serviceType(next) == corev1.ServiceTypeNodePort || serviceType(next) == corev1.ServiceTypeLoadBalancer { + for i := range next.Ports { + if next.Ports[i].NodePort != 0 { + continue + } + for _, old := range live.Ports { + if sameServicePort(next.Ports[i], old) { + next.Ports[i].NodePort = old.NodePort + } + } + } + } +} + +func sameServicePort(a, b corev1.ServicePort) bool { + protocol := func(port corev1.ServicePort) corev1.Protocol { + if port.Protocol == "" { + return corev1.ProtocolTCP + } + return port.Protocol + } + return a.Name == b.Name && protocol(a) == protocol(b) && (a.Name != "" || a.Port == b.Port) +} + +func checkImmutableStatefulSet(next, live client.Object) error { + want, ok := next.(*appsv1.StatefulSet) + if !ok { + return nil + } + stored := live.(*appsv1.StatefulSet) + fields := []string{} + if !apiequality.Semantic.DeepEqual(want.Spec.Selector, stored.Spec.Selector) { + fields = append(fields, "spec.selector") + } + if want.Spec.ServiceName != stored.Spec.ServiceName { + fields = append(fields, "spec.serviceName") + } + policy := func(value appsv1.PodManagementPolicyType) appsv1.PodManagementPolicyType { + if value == "" { + return appsv1.OrderedReadyPodManagement + } + return value + } + if policy(want.Spec.PodManagementPolicy) != policy(stored.Spec.PodManagementPolicy) { + fields = append(fields, "spec.podManagementPolicy") + } + if !apiequality.Semantic.DeepEqual(want.Spec.VolumeClaimTemplates, stored.Spec.VolumeClaimTemplates) { + fields = append(fields, "spec.volumeClaimTemplates") + } + if len(fields) != 0 { + return fmt.Errorf("StatefulSet %s has immutable changes %v; delete/recreate is not performed", want.Name, fields) + } + return nil +} + +func equalAppliedState(a, b client.Object) bool { + if !apiequality.Semantic.DeepEqual(a.GetLabels(), b.GetLabels()) || + !apiequality.Semantic.DeepEqual(a.GetAnnotations(), b.GetAnnotations()) { + return false + } + switch left := a.(type) { + case *corev1.ConfigMap: + right := b.(*corev1.ConfigMap) + return apiequality.Semantic.DeepEqual(left.Data, right.Data) && + apiequality.Semantic.DeepEqual(left.BinaryData, right.BinaryData) && + apiequality.Semantic.DeepEqual(left.Immutable, right.Immutable) + case *corev1.Service: + return apiequality.Semantic.DeepEqual(left.Spec, b.(*corev1.Service).Spec) + case *appsv1.StatefulSet: + return apiequality.Semantic.DeepEqual(left.Spec, b.(*appsv1.StatefulSet).Spec) + case *policyv1.PodDisruptionBudget: + return apiequality.Semantic.DeepEqual(left.Spec, b.(*policyv1.PodDisruptionBudget).Spec) + } + return false +} + +func clearStatus(object client.Object) { + switch item := object.(type) { + case *corev1.Service: + item.Status = corev1.ServiceStatus{} + case *appsv1.StatefulSet: + item.Status = appsv1.StatefulSetStatus{} + case *policyv1.PodDisruptionBudget: + item.Status = policyv1.PodDisruptionBudgetStatus{} + } +} + +func preserveStatus(next, live client.Object) { + switch item := next.(type) { + case *corev1.Service: + item.Status = *live.(*corev1.Service).Status.DeepCopy() + case *appsv1.StatefulSet: + item.Status = *live.(*appsv1.StatefulSet).Status.DeepCopy() + case *policyv1.PodDisruptionBudget: + item.Status = *live.(*policyv1.PodDisruptionBudget).Status.DeepCopy() + } +} diff --git a/internal/framework/controller/apply_test.go b/internal/framework/controller/apply_test.go new file mode 100644 index 00000000..5254c0ef --- /dev/null +++ b/internal/framework/controller/apply_test.go @@ -0,0 +1,360 @@ +package controller + +import ( + "context" + "errors" + "reflect" + "slices" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func applyTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + for _, register := range []func(*runtime.Scheme) error{corev1.AddToScheme, appsv1.AddToScheme} { + if err := register(scheme); err != nil { + t.Fatal(err) + } + } + return scheme +} + +func applyTestOwner() *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "test", UID: "owner-uid"}} +} + +func applyTestConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "workload", Namespace: "test", + Labels: map[string]string{"owned": "v1"}, Annotations: map[string]string{"owned-note": "v1"}}, + Data: map[string]string{"keep": "old", "withdraw": "old"}, BinaryData: map[string][]byte{"old": {1, 2}}} +} + +func applyTestStatefulSet() *appsv1.StatefulSet { + return &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "workload", Namespace: "test"}, + Spec: appsv1.StatefulSetSpec{ServiceName: "headless", Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}}, Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test", "withdraw": "old"}, + Annotations: map[string]string{"withdraw": "old"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main", Image: "example.invalid/test:1", + ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(8080)}}}}}}, + }}} +} + +func applyTestGet(t *testing.T, c client.Client, object client.Object) { + t.Helper() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(object), object); err != nil { + t.Fatal(err) + } +} + +func applyTestChanged(t *testing.T, c client.Client, desired client.Object, scheme *runtime.Scheme, want bool) { + t.Helper() + changed, err := ApplyObject(t.Context(), c, applyTestOwner(), desired, scheme) + if err != nil || changed != want { + t.Fatalf("apply changed=%v want=%v error=%v", changed, want, err) + } +} + +func TestApplyConfigMapOwnershipMetadataAndFieldWithdrawal(t *testing.T) { + scheme := applyTestScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + desired := applyTestConfigMap() + before := desired.DeepCopy() + applyTestChanged(t, c, desired, scheme, true) + if !reflect.DeepEqual(desired, before) { + t.Fatal("apply mutated the desired object") + } + live := desired.DeepCopy() + applyTestGet(t, c, live) + if !metav1.IsControlledBy(live, applyTestOwner()) { + t.Fatal("create did not set the controller reference") + } + live.Labels["foreign"] = "keep" + live.Annotations["foreign"] = "keep" + live.Finalizers = []string{"foreign/finalizer"} + if err := c.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + desired.Labels = nil + desired.Annotations = map[string]string{"foreign": "desired-wins"} + desired.Data = map[string]string{"keep": "new"} + desired.BinaryData = nil + applyTestChanged(t, c, desired, scheme, true) + applyTestGet(t, c, live) + if live.Labels["foreign"] != "keep" || live.Labels["owned"] != "" || + live.Annotations["owned-note"] != "" || live.Annotations["foreign"] != "desired-wins" || + len(live.Data) != 1 || live.Data["keep"] != "new" || len(live.BinaryData) != 0 || + !reflect.DeepEqual(live.Finalizers, []string{"foreign/finalizer"}) { + t.Fatalf("withdrawal or foreign metadata preservation failed: %+v", live) + } + rv := live.ResourceVersion + applyTestChanged(t, c, desired, scheme, false) + applyTestGet(t, c, live) + if live.ResourceVersion != rv { + t.Fatal("no-op apply persisted another resource version") + } +} + +// This is a fake admission boundary, not a reimplementation used by apply. +// Real API defaulting and unchanged resourceVersion are covered by envtest. +func applyFakeDefaults(object client.Object) { + if item, ok := object.(*appsv1.StatefulSet); ok { + if item.Spec.PodManagementPolicy == "" { + item.Spec.PodManagementPolicy = appsv1.OrderedReadyPodManagement + } + if item.Spec.Replicas == nil { + value := int32(1) + item.Spec.Replicas = &value + } + if item.Spec.Template.Spec.DNSPolicy == "" { + item.Spec.Template.Spec.DNSPolicy = corev1.DNSClusterFirst + } + for i := range item.Spec.Template.Spec.Containers { + if item.Spec.Template.Spec.Containers[i].TerminationMessagePath == "" { + item.Spec.Template.Spec.Containers[i].TerminationMessagePath = "/dev/termination-log" + } + } + } +} + +func applyDryRun(options []client.UpdateOption) bool { + settings := &client.UpdateOptions{} + settings.ApplyOptions(options) + return slices.Contains(settings.DryRun, metav1.DryRunAll) +} + +func TestApplyCanonicalDefaultsPreserveStatusAndRemovePodOverrides(t *testing.T) { + scheme := applyTestScheme(t) + dryRuns, writes := 0, 0 + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&appsv1.StatefulSet{}). + WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error { + applyFakeDefaults(object) + return c.Create(ctx, object, opts...) + }, + Update: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.UpdateOption) error { + applyFakeDefaults(object) + if applyDryRun(opts) { + dryRuns++ + } else { + writes++ + } + return c.Update(ctx, object, opts...) + }, + }).Build() + desired := applyTestStatefulSet() + applyTestChanged(t, c, desired, scheme, true) + live := desired.DeepCopy() + applyTestGet(t, c, live) + rv := live.ResourceVersion + applyTestChanged(t, c, desired, scheme, false) + applyTestGet(t, c, live) + if live.ResourceVersion != rv || dryRuns != 1 || writes != 0 { + t.Fatal("API defaults must not cause repeated persisted updates") + } + live.Spec.Template.Labels["foreign"] = "keep" + live.Spec.Template.Annotations["restarter.example/change"] = "keep" + if err := c.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + live.Status.ReadyReplicas = 7 + if err := c.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + delete(desired.Spec.Template.Labels, "withdraw") + desired.Spec.Template.Annotations = nil + desired.Spec.Template.Spec.Containers[0].ReadinessProbe = nil + desired.Status.ReadyReplicas = 999 + applyTestChanged(t, c, desired, scheme, true) + applyTestGet(t, c, live) + if live.Status.ReadyReplicas != 7 || live.Spec.Template.Spec.Containers[0].ReadinessProbe != nil || + live.Spec.Template.Labels["withdraw"] != "" || live.Spec.Template.Labels["foreign"] != "keep" || + live.Spec.Template.Annotations["withdraw"] != "" || + live.Spec.Template.Annotations["restarter.example/change"] != "keep" { + t.Fatalf("owned withdrawal or status/foreign preservation failed: %+v", live) + } +} + +func TestApplyPreservesServiceAllocations(t *testing.T) { + scheme := applyTestScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&corev1.Service{}).Build() + desired := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "workload", Namespace: "test"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, + ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyLocal, + Ports: []corev1.ServicePort{{Name: "http", Port: 8080, TargetPort: intstr.FromInt32(8080)}}}} + applyTestChanged(t, c, desired, scheme, true) + live := desired.DeepCopy() + applyTestGet(t, c, live) + policy := corev1.IPFamilyPolicySingleStack + live.Spec.ClusterIP, live.Spec.ClusterIPs = "10.0.0.42", []string{"10.0.0.42"} + live.Spec.IPFamilies, live.Spec.IPFamilyPolicy = []corev1.IPFamily{corev1.IPv4Protocol}, &policy + live.Spec.HealthCheckNodePort, live.Spec.Ports[0].NodePort = 32042, 31042 + if err := c.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + live.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{Hostname: "allocated.example"}} + if err := c.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + applyTestChanged(t, c, desired, scheme, false) + desired.Spec.Selector = map[string]string{"changed": "yes"} + desired.Spec.Ports[0].Port = 9090 + applyTestChanged(t, c, desired, scheme, true) + applyTestGet(t, c, live) + if live.Spec.ClusterIP != "10.0.0.42" || !reflect.DeepEqual(live.Spec.ClusterIPs, []string{"10.0.0.42"}) || + live.Spec.IPFamilyPolicy == nil || *live.Spec.IPFamilyPolicy != policy || + !reflect.DeepEqual(live.Spec.IPFamilies, []corev1.IPFamily{corev1.IPv4Protocol}) || + live.Spec.Ports[0].NodePort != 31042 || live.Spec.HealthCheckNodePort != 32042 || + len(live.Status.LoadBalancer.Ingress) != 1 { + t.Fatalf("Service allocations or status changed: %+v", live) + } +} + +func TestApplyRefusesAdoptionAndImmutableChanges(t *testing.T) { + scheme := applyTestScheme(t) + controller := true + for _, reference := range []*metav1.OwnerReference{ + nil, + {APIVersion: "v1", Kind: "ConfigMap", Name: "other", UID: "other", Controller: &controller}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "owner", UID: "owner-uid"}, + {APIVersion: "v1", Kind: "Service", Name: "owner", UID: "owner-uid", Controller: &controller}, + {APIVersion: "wrong/v1", Kind: "ConfigMap", Name: "owner", UID: "owner-uid", Controller: &controller}, + } { + live := applyTestConfigMap() + if reference != nil { + live.OwnerReferences = []metav1.OwnerReference{*reference} + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(live).Build() + changed, err := ApplyObject(t.Context(), c, applyTestOwner(), applyTestConfigMap(), scheme) + if err == nil || changed || !strings.Contains(err.Error(), "refusing to adopt") { + t.Fatalf("unowned/foreign object was adopted: changed=%v error=%v", changed, err) + } + } + for field, mutate := range map[string]func(*appsv1.StatefulSet){ + "spec.serviceName": func(s *appsv1.StatefulSet) { s.Spec.ServiceName = "other" }, + "spec.selector": func(s *appsv1.StatefulSet) { + s.Spec.Selector.MatchLabels["app"] = "other" + }, + "spec.podManagementPolicy": func(s *appsv1.StatefulSet) { s.Spec.PodManagementPolicy = appsv1.ParallelPodManagement }, + "spec.volumeClaimTemplates": func(s *appsv1.StatefulSet) { + s.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{Name: "data"}}} + }, + } { + t.Run(field, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + desired := applyTestStatefulSet() + applyTestChanged(t, c, desired, scheme, true) + mutate(desired) + changed, err := ApplyObject(t.Context(), c, applyTestOwner(), desired, scheme) + if err == nil || changed || !strings.Contains(err.Error(), field) { + t.Fatalf("immutable change was not explicit: changed=%v error=%v", changed, err) + } + }) + } +} + +func TestApplyRetriesFreshReadsAndRechecksOwnership(t *testing.T) { + for _, changeOwner := range []bool{false, true} { + scheme := applyTestScheme(t) + base := fake.NewClientBuilder().WithScheme(scheme).Build() + desired := applyTestConfigMap() + applyTestChanged(t, base, desired, scheme, true) + attempts, gets := 0, 0 + c := interceptor.NewClient(base, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, + opts ...client.GetOption) error { + gets++ + return c.Get(ctx, key, obj, opts...) + }, + Update: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if applyDryRun(opts) { + return c.Update(ctx, obj, opts...) + } + attempts++ + if attempts == 1 { + concurrent := applyTestConfigMap() + if err := c.Get(ctx, client.ObjectKeyFromObject(obj), concurrent); err != nil { + return err + } + concurrent.Annotations["concurrent"] = "preserve" + if changeOwner { + concurrent.OwnerReferences[0].UID = types.UID("replacement-owner") + } + if err := c.Update(ctx, concurrent); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, obj.GetName(), errors.New("race")) + } + return c.Update(ctx, obj, opts...) + }, + }) + desired.Data["keep"] = "new" + changed, err := ApplyObject(t.Context(), c, applyTestOwner(), desired, scheme) + if gets != 2 || changed == changeOwner || (err != nil) != changeOwner { + t.Fatalf("retry failed: changed=%v gets=%d changeOwner=%v error=%v", changed, gets, changeOwner, err) + } + live := applyTestConfigMap() + applyTestGet(t, base, live) + want := "new" + if changeOwner { + want = "old" + } + if live.Data["keep"] != want || live.Annotations["concurrent"] != "preserve" { + t.Fatal("retry ignored a newer object or overwrote its metadata") + } + } +} + +func TestApplyRejectsDamagedMetadataRecordsAndReservedKeys(t *testing.T) { + scheme := applyTestScheme(t) + for _, record := range []string{ + `missing`, `null`, `{}`, `{"object":null,"template":{}}`, `{"object":{"labels":null},"template":{}}`, + `{"object":{"unknown":[]},"template":{}}`, `{"object":{},"object":{},"template":{}}`, + `{"object":{"labels":["owned","owned"]},"template":{}}`, + `{"object":{},"template":{}} {}`, `{"object":{"labels":["bad key"]},"template":{}}`, + } { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + desired := applyTestConfigMap() + applyTestChanged(t, c, desired, scheme, true) + live := desired.DeepCopy() + applyTestGet(t, c, live) + if record == "missing" { + delete(live.Annotations, ManagedMetadataAnnotation) + } else { + live.Annotations[ManagedMetadataAnnotation] = record + } + if err := c.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if changed, err := ApplyObject(t.Context(), c, applyTestOwner(), desired, scheme); err == nil || changed { + t.Fatalf("damaged record accepted: %s changed=%v error=%v", record, changed, err) + } + } + for _, template := range []bool{false, true} { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + desired := applyTestStatefulSet() + if template { + desired.Spec.Template.Annotations[ManagedMetadataAnnotation] = "user-data" + } else { + desired.Annotations = map[string]string{ManagedMetadataAnnotation: "user-data"} + } + if changed, err := ApplyObject(t.Context(), c, applyTestOwner(), desired, scheme); err == nil || changed || + !strings.Contains(err.Error(), "reserved") { + t.Fatalf("reserved key accepted: changed=%v error=%v", changed, err) + } + } +} diff --git a/internal/framework/controller/coordination.go b/internal/framework/controller/coordination.go new file mode 100644 index 00000000..3515c6ef --- /dev/null +++ b/internal/framework/controller/coordination.go @@ -0,0 +1,236 @@ +package controller + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + kubernetesjson "sigs.k8s.io/json" +) + +const coordinationAnnotation = "framework.kubedoop.dev/workload-coordination" +const coordinationProgressAnnotation = "framework.kubedoop.dev/workload-progress" + +type workloadProgress struct { + Version int `json:"version"` + UID types.UID `json:"uid"` + Target string `json:"target"` + Observation string `json:"observation"` + Since metav1.Time `json:"since"` +} + +func workloadPolicy(set *appsv1.StatefulSet) (*framework.WorkloadCoordination, error) { + raw := set.Annotations[coordinationAnnotation] + if raw == "" { + return nil, nil + } + var value framework.WorkloadCoordination + strict, err := kubernetesjson.UnmarshalStrict([]byte(raw), &value) + if err != nil || len(strict) != 0 || value.ProgressDeadline.Duration < time.Second || + value.ProgressDeadline.Duration > time.Hour { + return nil, fmt.Errorf("StatefulSet %s has an invalid workload coordination receipt", set.Name) + } + return &value, nil +} + +func stampCoordination(desired client.Object, policies []*framework.WorkloadCoordination) client.Object { + set, ok := desired.(*appsv1.StatefulSet) + if !ok || len(policies) == 0 || policies[0] == nil { + return desired + } + out := set.DeepCopy() + if out.Annotations == nil { + out.Annotations = map[string]string{} + } + raw, _ := json.Marshal(policies[0]) + out.Annotations[coordinationAnnotation] = string(raw) + return out +} + +// This query authenticates every canonical Pod against the live StatefulSet UID. +// A same-name foreign Pod blocks a transition rather than becoming deletion authority. +func coordinationPods(ctx context.Context, c client.Client, set *appsv1.StatefulSet) ([]corev1.Pod, error) { + var list corev1.PodList + if err := c.List(ctx, &list, client.InNamespace(set.Namespace)); err != nil { + return nil, err + } + var pods []corev1.Pod + for _, pod := range list.Items { + suffix, ok := strings.CutPrefix(pod.Name, set.Name+"-") + if !ok { + continue + } + ordinal, err := strconv.ParseUint(suffix, 10, 32) + if err != nil || strconv.FormatUint(ordinal, 10) != suffix { + continue + } + owner := metav1.GetControllerOf(&pod) + if owner == nil || owner.UID != set.UID || owner.Name != set.Name || + owner.Kind != statefulSetKind || owner.APIVersion != appsv1.SchemeGroupVersion.String() { + return nil, fmt.Errorf("workload coordination: Pod %s lacks the current StatefulSet ownership identity", pod.Name) + } + pods = append(pods, pod) + } + slices.SortFunc(pods, func(a, b corev1.Pod) int { return strings.Compare(a.Name, b.Name) }) + return pods, nil +} + +func replicas(set *appsv1.StatefulSet) int32 { + if set.Spec.Replicas == nil { + return 1 + } + return *set.Spec.Replicas +} + +// nextScaleDown is shared by ordinary changes, stop and retirement. A new step +// requires direct Pod absence for the previous ordinal, not status counts alone. +func nextScaleDown(ctx context.Context, c client.Client, live *appsv1.StatefulSet, want int32) (int32, error) { + policy, err := workloadPolicy(live) + if err != nil || policy == nil || replicas(live) <= want { + return want, err + } + pods, err := coordinationPods(ctx, c, live) + if err != nil { + return replicas(live), err + } + for _, pod := range pods { + ordinal, _ := strconv.ParseInt(strings.TrimPrefix(pod.Name, live.Name+"-"), 10, 32) + if !pod.DeletionTimestamp.IsZero() || ordinal >= int64(replicas(live)) { + return replicas(live), nil + } + } + if live.Status.ObservedGeneration < live.Generation || live.Status.Replicas > replicas(live) { + return replicas(live), nil + } + return replicas(live) - 1, nil +} + +func coordinateDesired(ctx context.Context, c client.Client, next, previous client.Object) error { + set, ok := next.(*appsv1.StatefulSet) + if !ok { + return nil + } + live := previous.(*appsv1.StatefulSet) + policy, err := workloadPolicy(set) + if err != nil || policy == nil { + return err + } + // Enabling coordination takes one observed pass before a scale step; the live + // receipt is the execution authority used equally by stop and retirement. + if live.Annotations[coordinationAnnotation] == "" && replicas(live) > replicas(set) { + set.Spec.Replicas = live.Spec.Replicas + return nil + } + count, err := nextScaleDown(ctx, c, live, replicas(set)) + if err != nil { + return err + } + set.Spec.Replicas = &count + return nil +} + +// Persist an observation deadline on the live StatefulSet. Neither a controller +// restart nor repeating a failed initializer resets it. A real progress milestone +// or new target starts a new budget. Timeout never issues force-deletion. +func (r *Reconciler[CR, C, S, F]) observeCoordination( + ctx context.Context, cr CR, set *appsv1.StatefulSet, want int32, ready bool, +) error { + policy, err := workloadPolicy(set) + if err != nil || policy == nil { + return err + } + raw := set.Annotations[coordinationProgressAnnotation] + if ready { + if raw == "" { + return nil + } + patch := client.MergeFrom(set.DeepCopy()) + delete(set.Annotations, coordinationProgressAnnotation) + return r.Client.Patch(ctx, set, patch) + } + pods, err := coordinationPods(ctx, r.Client, set) + if err != nil { + return err + } + // Do not include resourceVersion, failure/restart counters or wall clock in + // progress: those change without advancing initialization or shutdown. + observation := fmt.Sprintf("%d/%d/%d/%d/%s/%s", replicas(set), set.Status.Replicas, set.Status.ReadyReplicas, + set.Status.UpdatedReplicas, set.Status.CurrentRevision, set.Status.UpdateRevision) + for _, pod := range pods { + observation += fmt.Sprintf(";%s/%s/%s/%t", pod.Name, pod.UID, pod.Status.Phase, !pod.DeletionTimestamp.IsZero()) + for _, status := range pod.Status.InitContainerStatuses { + if status.State.Terminated != nil && status.State.Terminated.ExitCode == 0 { + observation += "/initialized:" + status.Name + } + } + } + digest := sha256.Sum256([]byte(observation)) + template, _ := json.Marshal(set.Spec.Template) + target := fmt.Sprintf("%d/%x", want, sha256.Sum256(template)) + var progress workloadProgress + if raw != "" { + strict, decodeErr := kubernetesjson.UnmarshalStrict([]byte(raw), &progress) + if decodeErr != nil || len(strict) != 0 || progress.Version != 1 || + progress.UID != set.UID || progress.Since.IsZero() { + return fmt.Errorf("StatefulSet %s has an invalid workload progress receipt", set.Name) + } + } + token := fmt.Sprintf("%x", digest) + if raw == "" || progress.Target != target || progress.Observation != token { + progress = workloadProgress{Version: 1, UID: set.UID, Target: target, Observation: token, Since: metav1.Now()} + data, _ := json.Marshal(progress) + patch := client.MergeFrom(set.DeepCopy()) + set.Annotations = maps.Clone(set.Annotations) + if set.Annotations == nil { + set.Annotations = map[string]string{} + } + set.Annotations[coordinationProgressAnnotation] = string(data) + if err := r.currentInput(ctx, cr); err != nil { + return err + } + return r.Client.Patch(ctx, set, patch) + } + if time.Since(progress.Since.Time) >= policy.ProgressDeadline.Duration { + return fmt.Errorf("workload coordination deadline exceeded for %s; target replicas=%d; "+ + "initialization, exit or rollout has not progressed; no forced deletion was issued", set.Name, want) + } + return nil +} + +// Priority comes from authenticated live slots, so removal or invalid product +// configuration cannot discard a workload's previously declared shutdown order. +func (r *Reconciler[CR, C, S, F]) shutdownPriorities( + ctx context.Context, cr CR, groups map[string]groupSlot, +) (map[string]int32, error) { + priorities := map[string]int32{} + for key, group := range groups { + group.Slot = slotStatefulset + object, err := r.readSlot(ctx, cr, group) + if err != nil { + return nil, err + } + if object == nil { + continue + } + policy, err := workloadPolicy(object.(*appsv1.StatefulSet)) + if err != nil { + return nil, err + } + if policy != nil { + priorities[key] = policy.ShutdownPriority + } + } + return priorities, nil +} diff --git a/internal/framework/controller/coordination_reconcile_test.go b/internal/framework/controller/coordination_reconcile_test.go new file mode 100644 index 00000000..2a0ae3ac --- /dev/null +++ b/internal/framework/controller/coordination_reconcile_test.go @@ -0,0 +1,113 @@ +package controller + +import ( + "context" + "encoding/json" + "testing" + "time" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestCoordinationSurvivesFinalAssemblyAndOrdersClusterStop(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + if err := json.Unmarshal([]byte(`{"spec":{"coordinators":{"roleGroups":{"default":{"replicas":1}}}, + "workers":{"roleGroups":{"default":{"replicas":1}}}}}`), cr); err != nil { + t.Fatal(err) + } + c := retirementClient(scheme, cr, nil, interceptor.Funcs{Create: func(ctx context.Context, + c client.WithWatch, object client.Object, opts ...client.CreateOption, + ) error { + object.SetUID(types.UID(object.GetName() + "-uid")) + object.SetGeneration(1) + return c.Create(ctx, object, opts...) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + r.Definition.GenerateGroup = func(in framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + priority := int32(0) + if in.Group.Role == "coordinators" { + priority = 100 + } + return framework.RuntimeDescription{Main: framework.Process{Name: "main", Command: []string{"run"}}, + Initializers: []framework.Process{{Name: "initialize", Command: []string{"initialize"}}}, + Coordination: &framework.WorkloadCoordination{ShutdownPriority: priority, + ProgressDeadline: metav1.Duration{Duration: time.Minute}}}, nil + } + reconcile := func() { + t.Helper() + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}); err != nil { + t.Fatal(err) + } + } + reconcile() + sets, pods := map[string]*appsv1.StatefulSet{}, map[string]*corev1.Pod{} + for _, role := range []string{"coordinators", "workers"} { + sts := &appsv1.StatefulSet{} + key := client.ObjectKey{Namespace: cr.Namespace, Name: cr.Name + "-" + role + "-default"} + if err := c.Get(t.Context(), key, sts); err != nil { + t.Fatal(err) + } + if policy, err := workloadPolicy(sts); err != nil || policy == nil { + t.Fatalf("assembled/applied workload lost coordination: %s %+v %v", role, policy, err) + } + sts.Status = appsv1.StatefulSetStatus{ObservedGeneration: sts.Generation, Replicas: 1, + ReadyReplicas: 1, UpdatedReplicas: 1, CurrentRevision: "current", UpdateRevision: "current"} + if err := c.Status().Update(t.Context(), sts); err != nil { + t.Fatal(err) + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sts.Name + "-0", Namespace: sts.Namespace, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: statefulSetKind, + Name: sts.Name, UID: sts.UID, Controller: ptr.To(true)}}}} + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + sets[role], pods[role] = sts, pod + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), cr); err != nil { + t.Fatal(err) + } + cr.Spec.ClusterConfig = &generatedtrino.ClusterConfigInput{Stopped: ptr.To(true)} + cr.Generation++ + if err := c.Update(t.Context(), cr); err != nil { + t.Fatal(err) + } + reconcile() + assertReplicas := func(role string, want int32) { + t.Helper() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(sets[role]), sets[role]); err != nil { + t.Fatal(err) + } + if replicas(sets[role]) != want { + t.Fatalf("%s replicas=%d want=%d", role, replicas(sets[role]), want) + } + } + assertReplicas("workers", 0) + assertReplicas("coordinators", 1) + // A new controller and zero status counts cannot bypass the remaining worker Pod. + replacement := newTestReconciler(c, scheme, testFacts{}) + replacement.Definition = r.Definition + r = replacement + worker := sets["workers"] + worker.Status = appsv1.StatefulSetStatus{ObservedGeneration: worker.Generation} + if err := c.Status().Update(t.Context(), worker); err != nil { + t.Fatal(err) + } + reconcile() + assertReplicas("coordinators", 1) + if err := c.Delete(t.Context(), pods["workers"]); err != nil { + t.Fatal(err) + } + reconcile() + assertReplicas("coordinators", 0) +} diff --git a/internal/framework/controller/coordination_test.go b/internal/framework/controller/coordination_test.go new file mode 100644 index 00000000..6bc78dd1 --- /dev/null +++ b/internal/framework/controller/coordination_test.go @@ -0,0 +1,121 @@ +package controller + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestCoordinationScaleDownWaitsForOriginalPodAndSurvivesRestart(t *testing.T) { + ctx := context.Background() + cr, scheme := controllerInput(), controllerScheme(t) + objects := retirementObjects(t, cr, "default", 3) + set := objects[0].(*appsv1.StatefulSet) + policy, _ := json.Marshal(framework.WorkloadCoordination{ProgressDeadline: metav1.Duration{Duration: time.Minute}}) + set.Annotations[coordinationAnnotation] = string(policy) + controller := true + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: set.Name + "-2", Namespace: set.Namespace, + UID: "old-pod", OwnerReferences: []metav1.OwnerReference{{ + APIVersion: appsv1.SchemeGroupVersion.String(), Kind: statefulSetKind, Name: set.Name, + UID: set.UID, Controller: &controller}}}} + objects = append(objects, pod) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + slot := groupSlot{Role: "workers", Group: "default", Slot: slotStatefulset} + if waiting, err := r.stopWorkload(ctx, cr, slot); err != nil || !waiting { + t.Fatalf("first stop: %v %v", waiting, err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal(err) + } + if replicas(set) != 2 { + t.Fatalf("jumped ordinals: %d", replicas(set)) + } + // A new controller cannot assume the previous process finished from the count. + r = newTestReconciler(c, scheme, testFacts{}) + if _, err := r.stopWorkload(ctx, cr, slot); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal(err) + } + if replicas(set) != 2 { + t.Fatal("remaining original Pod was bypassed after restart") + } + if err := c.Delete(ctx, pod); err != nil { + t.Fatal(err) + } + set.Status.Replicas = 2 + set.Status.ReadyReplicas = 2 + set.Status.UpdatedReplicas = 2 + if err := c.Status().Update(ctx, set); err != nil { + t.Fatal(err) + } + if _, err := r.stopWorkload(ctx, cr, slot); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal(err) + } + if replicas(set) != 1 { + t.Fatalf("did not continue after observed disappearance: %d", replicas(set)) + } +} + +func TestCoordinationInitializationDeadlinePersistsAndDoesNotForceDelete(t *testing.T) { + ctx := context.Background() + cr, scheme := controllerInput(), controllerScheme(t) + objects := retirementObjects(t, cr, "default", 1) + set := objects[0].(*appsv1.StatefulSet) + policy, _ := json.Marshal(framework.WorkloadCoordination{ProgressDeadline: metav1.Duration{Duration: time.Second}}) + set.Annotations[coordinationAnnotation] = string(policy) + set.Status.ReadyReplicas = 0 + set.Status.UpdatedReplicas = 0 + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + if err := r.observeCoordination(ctx, cr, set, 1, false); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal(err) + } + var receipt workloadProgress + if err := json.Unmarshal([]byte(set.Annotations[coordinationProgressAnnotation]), &receipt); err != nil { + t.Fatal(err) + } + receipt.Since = metav1.NewTime(time.Now().Add(-time.Minute)) + raw, _ := json.Marshal(receipt) + set.Annotations[coordinationProgressAnnotation] = string(raw) + if err := c.Update(ctx, set); err != nil { + t.Fatal(err) + } + r = newTestReconciler(c, scheme, testFacts{}) + err := r.observeCoordination(ctx, cr, set, 1, false) + if err == nil || !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("restart reset deadline: %v", err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal("timed-out workload was removed", err) + } + if replicas(set) != 1 { + t.Fatal("timeout forced a replica change") + } + if err := r.observeCoordination(ctx, cr, set, 1, true); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(set), set); err != nil { + t.Fatal(err) + } + if set.Annotations[coordinationProgressAnnotation] != "" { + t.Fatal("observed recovery retained stale deadline") + } +} diff --git a/internal/framework/controller/dependencies.go b/internal/framework/controller/dependencies.go new file mode 100644 index 00000000..21f4b268 --- /dev/null +++ b/internal/framework/controller/dependencies.go @@ -0,0 +1,212 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "time" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller" +) + +const defaultFactRefresh = 30 * time.Second + +type factReadKey struct { + GVK schema.GroupVersionKind + Key client.ObjectKey +} + +type factRead struct { + object map[string]any + stamp framework.FactObject + err error +} + +// A cache belongs to one reconcile, never to a controller or CR across passes. +type factReadCache struct { + client client.Reader + scheme *runtime.Scheme + reads map[factReadKey]factRead +} + +type trackedFactsReader struct { + cache *factReadCache + observed map[factReadKey]framework.FactObject + failure error +} + +func (r *trackedFactsReader) Get(ctx context.Context, key client.ObjectKey, object framework.FactResource) (err error) { + defer func() { + if err != nil && !apierrors.IsNotFound(err) && r.failure == nil { + r.failure = err + } + }() + if object == nil || reflect.ValueOf(object).Kind() != reflect.Pointer || reflect.ValueOf(object).IsNil() { + return fmt.Errorf("facts Get requires a non-nil object pointer") + } + gvk, err := apiutil.GVKForObject(object, r.cache.scheme) + if err != nil { + return fmt.Errorf("facts Get object kind: %w", err) + } + if key.Name == "" || gvk.Empty() { + return fmt.Errorf("facts Get requires an object kind and exact name") + } + cacheKey := factReadKey{GVK: gvk, Key: key} + read, cached := r.cache.reads[cacheKey] + if !cached { + read.stamp = framework.FactObject{APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, + Namespace: key.Namespace, Name: key.Name} + fresh := reflect.New(reflect.TypeOf(object).Elem()).Interface().(client.Object) + fresh.GetObjectKind().SetGroupVersionKind(gvk) + read.err = r.cache.client.Get(ctx, key, fresh) + if read.err == nil { + // Typed clients may omit TypeMeta in the decoded value. Retain the + // known request GVK so another typed/unstructured consumer can copy it. + fresh.GetObjectKind().SetGroupVersionKind(gvk) + read.stamp.UID = string(fresh.GetUID()) + read.stamp.ResourceVersion = fresh.GetResourceVersion() + read.object, read.err = runtime.DefaultUnstructuredConverter.ToUnstructured(fresh) + } + r.cache.reads[cacheKey] = read + } + r.observed[cacheKey] = read.stamp + if read.err != nil { + return read.err + } + // Deep-copy the canonical API snapshot for each caller, including repeated + // reads of the same reference within a resolver. No mutations reach the cache. + reflect.ValueOf(object).Elem().Set(reflect.Zero(reflect.TypeOf(object).Elem())) + return runtime.DefaultUnstructuredConverter.FromUnstructured(runtime.DeepCopyJSON(read.object), object) +} + +func (r *trackedFactsReader) observations() []framework.FactObject { + objects := make([]framework.FactObject, 0, len(r.observed)) + for _, observed := range r.observed { + objects = append(objects, observed) + } + slices.SortFunc(objects, func(a, b framework.FactObject) int { + return strings.Compare(factObjectKey(a), factObjectKey(b)) + }) + return objects +} + +func factObjectKey(object framework.FactObject) string { + return object.APIVersion + "/" + object.Kind + "/" + object.Namespace + "/" + object.Name +} + +func (r *Reconciler[CR, C, S, F]) resolvePreparedFacts(ctx context.Context, + prepared pipeline.PreparedInputs[C, S, F], +) map[pipeline.GroupKey]framework.FactResult[F] { + if r.ResolveFacts == nil { + return nil + } + cache := &factReadCache{client: r.Client, scheme: r.Scheme, reads: map[factReadKey]factRead{}} + results := make(map[pipeline.GroupKey]framework.FactResult[F], len(prepared.Topology)) + for _, group := range prepared.Topology { + if group.Config == nil || group.Error != "" { + continue + } + reader := &trackedFactsReader{cache: cache, observed: map[factReadKey]framework.FactObject{}} + resolvedInput := input.Clone(framework.FactInput[C, S, F]{Platform: prepared.Platform, Group: group.Group, + Config: *group.Config, + ClusterConfig: prepared.ClusterConfig, Image: prepared.Image, + Shared: prepared.Source.Shared, Topology: prepared.Topology}) + result, err := r.ResolveFacts(ctx, reader, resolvedInput) + if reader.failure != nil { + // Optional absence is a product decision; a failed API observation + // cannot become resolved merely because a resolver ignored its error. + err = reader.failure + } + result = normalizeFactResult(result, err) + result.Diagnostic.Observed = reader.observations() + results[pipeline.GroupKey{Role: group.Group.Role, Name: group.Group.Name}] = input.Clone(result) + } + return results +} + +func normalizeFactResult[F any](result framework.FactResult[F], err error) framework.FactResult[F] { + if err != nil { + return framework.FactResult[F]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsReadError, Reason: "ReadError", Message: safeFactError(err), + }} + } + valid := false + switch result.Diagnostic.State { + case framework.FactsResolved: + valid = result.Value != nil + case framework.FactsPending, framework.FactsInvalid, framework.FactsReadError: + valid = result.Value == nil + } + if !valid { + return framework.FactResult[F]{Diagnostic: framework.FactDiagnostic{State: framework.FactsInvalid, + Reason: "InvalidFactResult", Message: "Resolver returned an invalid state/value combination"}} + } + return result +} + +// Raw API/error messages can contain returned object data. Preserve the error +// type and structured API reason, while object references are recorded separately. +func safeFactError(err error) string { + message := fmt.Sprintf("External fact resolution failed (%T)", err) + var status apierrors.APIStatus + if errors.As(err, &status) { + message += "; API reason=" + string(status.Status().Reason) + } + return message +} + +func cloneFactDiagnostic(in *framework.FactDiagnostic) *framework.FactDiagnostic { + if in == nil { + return nil + } + out := *in + out.Observed = slices.Clone(in.Observed) + return &out +} + +func factsBlockGroup(observation *framework.GroupReconcileStatus) (blocked, pending bool) { + facts := observation.Facts + if facts == nil || facts.State == framework.FactsResolved { + return false, false + } + observation.Message = facts.Message + if observation.Message == "" { + observation.Message = "External facts are " + string(facts.State) + } + return true, facts.State == framework.FactsPending +} + +func (r *Reconciler[CR, C, S, F]) nextRefresh(pending bool) time.Duration { + delay := r.FactRefreshInterval + if delay <= 0 { + delay = defaultFactRefresh + } + if pending { + delay = min(delay, retirementPoll) + } + return delay +} + +func (r *Reconciler[CR, C, S, F]) controllerOptions() controller.Options { + // Reconcile errors discard RequeueAfter. Cap the default priority queue's + // per-key exponential backoff so a failing sibling cannot postpone external + // reads or retirement indefinitely. REST client throttling is unchanged. + // This bounds queue delay, not wall-clock progress during API or worker stalls. + maximum := r.nextRefresh(true) + return controller.Options{RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[ctrl.Request]( + min(5*time.Millisecond, maximum), maximum)} +} diff --git a/internal/framework/controller/dependencies_test.go b/internal/framework/controller/dependencies_test.go new file mode 100644 index 00000000..df1052ed --- /dev/null +++ b/internal/framework/controller/dependencies_test.go @@ -0,0 +1,446 @@ +package controller + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/controller/priorityqueue" +) + +func TestFactsReaderCachesAPIObjectsAndErrorsPerPass(t *testing.T) { + scheme := controllerScheme(t) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "facts", + UID: "shared-uid", ResourceVersion: "7"}, Data: map[string]string{"value": "original"}} + reads := map[string]int{} + denied := apierrors.NewForbidden(schema.GroupResource{Resource: "configmaps"}, "denied", errors.New("private data")) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, object client.Object, + options ...client.GetOption) error { + reads[key.Name]++ + if key.Name == "denied" { + return denied + } + return c.Get(ctx, key, object, options...) + }, + }).Build() + cache := &factReadCache{client: c, scheme: scheme, reads: map[factReadKey]factRead{}} + newReader := func() *trackedFactsReader { + return &trackedFactsReader{cache: cache, observed: map[factReadKey]framework.FactObject{}} + } + first, second := newReader(), newReader() + key := client.ObjectKeyFromObject(cm) + var one, two corev1.ConfigMap + if err := first.Get(t.Context(), key, &one); err != nil { + t.Fatal(err) + } + one.Data["value"] = "caller mutation" + cm.Data["value"] = "new API version" + if err := c.Update(t.Context(), cm); err != nil { + t.Fatal(err) + } + if err := second.Get(t.Context(), key, &two); err != nil { + t.Fatal(err) + } + if two.Data["value"] != "original" || reads["shared"] != 1 { + t.Fatalf("cache was shared mutably or reread: %+v %v", two.Data, reads) + } + // The same GVK may be requested through typed and unstructured objects. + raw := &unstructured.Unstructured{} + raw.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + if err := second.Get(t.Context(), key, raw); err != nil { + t.Fatal(err) + } + value, _, err := unstructured.NestedString(raw.Object, "data", "value") + if err != nil || value != "original" || reads["shared"] != 1 { + t.Fatalf("typed/unstructured cache differs: %s %v %v", value, err, reads) + } + for _, name := range []string{"missing", "denied"} { + for _, reader := range []*trackedFactsReader{first, second} { + err := reader.Get(t.Context(), client.ObjectKey{Namespace: "facts", Name: name}, &corev1.ConfigMap{}) + if (name == "missing" && !apierrors.IsNotFound(err)) || (name == "denied" && !apierrors.IsForbidden(err)) { + t.Fatalf("%s: wrong cached error: %v", name, err) + } + } + if reads[name] != 1 { + t.Fatalf("%s error reread %d times", name, reads[name]) + } + } + observed := second.observations() + if len(observed) != 3 || observed[0].Name != "denied" || observed[1].Name != "missing" || + observed[1].UID != "" || observed[2].UID != "shared-uid" || observed[2].ResourceVersion != "7" { + t.Fatalf("missing or unstable source observations: %+v", observed) + } +} + +func TestNormalizeFactResultRejectsContradictionsAndRedactsErrorData(t *testing.T) { + value := testFacts{} + states := []framework.FactState{framework.FactsResolved, framework.FactsPending, framework.FactsInvalid, + framework.FactsReadError, "unknown"} + for _, state := range states { + for _, present := range []bool{false, true} { + result := framework.FactResult[testFacts]{Diagnostic: framework.FactDiagnostic{State: state}} + if present { + result.Value = &value + } + actual := normalizeFactResult(result, nil) + valid := (state == framework.FactsResolved && present) || + ((state == framework.FactsPending || state == framework.FactsInvalid || + state == framework.FactsReadError) && !present) + if !valid && (actual.Diagnostic.State != framework.FactsInvalid || actual.Value != nil || + actual.Diagnostic.Reason != "InvalidFactResult") { + t.Fatalf("state=%s value=%t was not rejected: %+v", state, present, actual) + } + if valid && !reflect.DeepEqual(actual, result) { + t.Fatalf("valid result changed: %+v", actual) + } + } + } + err := apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "secret-name", + errors.New("secret-value-must-not-appear")) + actual := normalizeFactResult(framework.FactResult[testFacts]{Value: &value}, err) + if actual.Diagnostic.State != framework.FactsReadError || actual.Value != nil || + !strings.Contains(actual.Diagnostic.Message, "StatusError") || + !strings.Contains(actual.Diagnostic.Message, "Forbidden") || + strings.Contains(actual.Diagnostic.Message, "secret-value") { + t.Fatalf("read failure lost type or leaked data: %+v", actual) + } +} + +func TestFactResolutionDoesNotBlockHealthyGroupsOrRetirement(t *testing.T) { + for _, state := range []framework.FactState{framework.FactsPending, framework.FactsInvalid, framework.FactsReadError} { + t.Run(string(state), func(t *testing.T) { + cr := controllerInput() + kept, removed := retirementObjects(t, cr, "blocked", 1), retirementObjects(t, cr, "removed", 0) + r, input := factsTestReconciler(t, append(kept, removed...)) + r.ResolveFacts = func(_ context.Context, _ framework.FactsReader, + in framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + if in.Group.Name == "blocked" { + if state == framework.FactsReadError { + return framework.FactResult[testFacts]{}, errors.New("API connection failed") + } + return framework.FactResult[testFacts]{Diagnostic: framework.FactDiagnostic{ + State: state, Reason: "Fixture", Message: "not resolved"}}, nil + } + return framework.FactResult[testFacts]{Value: &in.Shared, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved}}, nil + } + result, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(input)}) + if err != nil || result.RequeueAfter != retirementPoll { + t.Fatalf("facts hid retirement cadence: %+v %v", result, err) + } + current := input.DeepCopy() + applyTestGet(t, r.Client, current) + if len(current.Status.Groups) != 2 || meta.IsStatusConditionTrue(current.Status.Conditions, "Built") || + meta.IsStatusConditionTrue(current.Status.Conditions, "Applied") { + t.Fatalf("incomplete facts reported success: %+v", current.Status) + } + for _, group := range current.Status.Groups { + if group.Name == "healthy" && !group.Applied { + t.Fatalf("independent group blocked: %+v", group) + } + if group.Name == "blocked" && (group.Applied || group.Facts == nil || group.Facts.State != state) { + t.Fatalf("classified result lost: %+v", group) + } + } + sts := kept[0].(*appsv1.StatefulSet).DeepCopy() + applyTestGet(t, r.Client, sts) + if sts.UID != kept[0].GetUID() || *sts.Spec.Replicas != 1 { + t.Fatal("blocked desired group was retired or replaced") + } + err = r.Client.Get(t.Context(), client.ObjectKeyFromObject(removed[0]), &appsv1.StatefulSet{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("removed group did not progress: %v", err) + } + }) + } +} + +func TestResolvedFactsRefreshWithoutCRChangeAndSettle(t *testing.T) { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "external", Namespace: controllerInput().Namespace, + UID: "external-uid"}, Data: map[string]string{"value": "one"}} + r, input := factsTestReconciler(t, []client.Object{cm}) + r.FactRefreshInterval = 7 * time.Second + reads := 0 + r.ResolveFacts = func(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + reads++ + if in.Shared.Catalogs["test"]["key"] != "base" || in.Topology[0].Config == nil { + t.Fatal("resolver input was shared or a fact gate erased valid topology") + } + var object corev1.ConfigMap + if err := reader.Get(ctx, client.ObjectKeyFromObject(cm), &object); err != nil { + return framework.FactResult[testFacts]{}, err + } + in.Shared.Catalogs["test"]["key"] = object.Data["value"] + in.Topology[0].Config = nil // must not affect the next consumer or pure build + return framework.FactResult[testFacts]{Value: &in.Shared, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved, + Observed: []framework.FactObject{{Name: "fabricated"}}}}, nil + } + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(input)} + versions := map[string]string{} + for pass := range 3 { + if pass == 2 { + applyTestGet(t, r.Client, cm) + cm.Data["value"] = "two" + if err := r.Client.Update(t.Context(), cm); err != nil { + t.Fatal(err) + } + } + before := input.DeepCopy() + applyTestGet(t, r.Client, before) + result, err := r.Reconcile(t.Context(), request) + if err != nil || result.RequeueAfter != 7*time.Second { + t.Fatalf("resolved facts lost refresh: %+v %v", result, err) + } + current := input.DeepCopy() + applyTestGet(t, r.Client, current) + if pass == 1 && current.ResourceVersion != before.ResourceVersion { + t.Fatal("unchanged facts rewrote status") + } + if current.Generation != input.Generation { + t.Fatal("test changed CR generation to trigger external refresh") + } + want := "one" + if pass == 2 { + want = "two" + } + for _, group := range current.Status.Groups { + if group.Facts == nil || len(group.Facts.Observed) != 1 || group.Facts.Observed[0].Name != "external" || + group.Facts.Observed[0].UID != "external-uid" { + t.Fatalf("reader did not replace resolver observations: %+v", group.Facts) + } + var sts appsv1.StatefulSet + key := client.ObjectKey{Namespace: input.Namespace, Name: input.Name + "-workers-" + group.Name} + if err := r.Client.Get(t.Context(), key, &sts); err != nil { + t.Fatal(err) + } + if sts.Spec.Template.Spec.Containers[0].Env[0].Value != want { + t.Fatalf("external value was not refreshed: %+v", sts.Spec.Template.Spec.Containers[0].Env) + } + if pass == 1 && versions[group.Name] != sts.ResourceVersion { + t.Fatal("unchanged resolved facts rewrote the StatefulSet") + } + versions[group.Name] = sts.ResourceVersion + } + } + if reads != 6 { + t.Fatalf("resolver was not called for both consumers each pass: %d", reads) + } +} + +func TestFactsReaderFailureCannotBecomeResolved(t *testing.T) { + for _, missing := range []bool{false, true} { + t.Run(map[bool]string{true: "optional-absence", false: "ignored-read-failure"}[missing], func(t *testing.T) { + r, input := factsTestReconciler(t, nil) + reads := 0 + r.Client = interceptor.NewClient(r.Client.(client.WithWatch), interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, object client.Object, + options ...client.GetOption) error { + if key.Name == "external" { + reads++ + resource := schema.GroupResource{Resource: "configmaps"} + if missing { + return apierrors.NewNotFound(resource, key.Name) + } + return apierrors.NewForbidden(resource, key.Name, errors.New("private content")) + } + return c.Get(ctx, key, object, options...) + }, + }) + r.ResolveFacts = func(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + _ = reader.Get(ctx, client.ObjectKey{Namespace: input.Namespace, Name: "external"}, &corev1.ConfigMap{}) + return framework.FactResult[testFacts]{Value: &in.Shared, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved}}, nil + } + result, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(input)}) + if err != nil || result.RequeueAfter != defaultFactRefresh || reads != 1 { + t.Fatalf("reader failure broke classification/cache/refresh: %+v %v reads=%d", result, err, reads) + } + current := input.DeepCopy() + applyTestGet(t, r.Client, current) + want := framework.FactsReadError + if missing { + want = framework.FactsResolved + } + for _, group := range current.Status.Groups { + if group.Facts == nil || group.Facts.State != want || group.Applied != missing || + len(group.Facts.Observed) != 1 || group.Facts.Observed[0].Name != "external" { + t.Fatalf("reader outcome was overwritten: %+v", group) + } + } + }) + } +} + +func TestFactsRefreshCadence(t *testing.T) { + r, _ := factsTestReconciler(t, nil) + if r.nextRefresh(false) != defaultFactRefresh || r.nextRefresh(true) != retirementPoll { + t.Fatal("built-in platform references must refresh without a product resolver") + } + r.ResolveFacts = func(context.Context, framework.FactsReader, + framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + return framework.FactResult[testFacts]{}, nil + } + for _, interval := range []time.Duration{0, -1, time.Second, time.Minute} { + r.FactRefreshInterval = interval + want := interval + if want <= 0 { + want = defaultFactRefresh + } + if r.nextRefresh(false) != want || r.nextRefresh(true) != min(want, retirementPoll) { + t.Fatalf("interval %v does not preserve earliest refresh", interval) + } + } +} + +func TestFactsErrorBackoffIsBoundedPerKey(t *testing.T) { + r, input := factsTestReconciler(t, nil) + if r.controllerOptions().RateLimiter == nil { + t.Fatal("built-in platform references need bounded error backoff without a product resolver") + } + r.ResolveFacts = func(context.Context, framework.FactsReader, + framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + return framework.FactResult[testFacts]{}, nil + } + key := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(input)} + for _, interval := range []time.Duration{0, -1, time.Millisecond, 30 * time.Second} { + r.FactRefreshInterval = interval + limiter := r.controllerOptions().RateLimiter + maximum := r.nextRefresh(true) + for range 64 { + if delay := limiter.When(key); delay <= 0 || delay > maximum { + t.Fatalf("configured interval=%v: per-key backoff %v exceeds %v", interval, delay, maximum) + } + } + if limiter.NumRequeues(key) != 64 { + t.Fatal("the cap discarded error retry accounting") + } + other := ctrl.Request{NamespacedName: client.ObjectKey{Namespace: input.Namespace, Name: "independent"}} + base := min(5*time.Millisecond, maximum) + if limiter.When(other) != base { + t.Fatal("one key's failure count delayed an independent key") + } + limiter.Forget(key) + if limiter.NumRequeues(key) != 0 || limiter.When(key) != base { + t.Fatal("successful convergence did not reset the exponential backoff") + } + } +} + +func TestFactsMixedFailureRefreshesThroughErrorQueue(t *testing.T) { + r, input := factsTestReconciler(t, nil) + r.FactRefreshInterval = 20 * time.Millisecond + generate := r.Definition.GenerateGroup + r.Definition.GenerateGroup = func(in framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error) { + if in.Group.Name == "healthy" { + return framework.RuntimeDescription{}, errors.New("continuous sibling build failure") + } + return generate(in) + } + external := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "external", Namespace: input.Namespace}, + Data: map[string]string{"value": "arrived-without-a-CR-update"}} + r.ResolveFacts = func(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + if in.Group.Name == "blocked" { + var observed corev1.ConfigMap + if err := reader.Get(ctx, client.ObjectKeyFromObject(external), &observed); err != nil { + if apierrors.IsNotFound(err) { + return framework.FactResult[testFacts]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsPending, Reason: "Missing", Message: "Waiting for the external ConfigMap"}}, nil + } + return framework.FactResult[testFacts]{}, err + } + in.Shared.Catalogs["test"]["key"] = observed.Data["value"] + } + return framework.FactResult[testFacts]{Value: &in.Shared, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved}}, nil + } + key := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(input)} + limiter := r.controllerOptions().RateLimiter + // Put the real queue in the long-lived failure case immediately. An uncapped + // default limiter would now defer the next queue delivery by 1000 seconds. + for range 64 { + limiter.When(key) + } + queue := priorityqueue.New[ctrl.Request]("", func(options *priorityqueue.Opts[ctrl.Request]) { + options.RateLimiter = limiter + }) + defer queue.ShutDown() + queue.Add(key) + for pass := range 2 { + queued := make(chan ctrl.Request, 1) + go func() { + item, shutdown := queue.Get() + if !shutdown { + queued <- item + } + }() + select { + case got := <-queued: + if got != key { + t.Fatalf("queued the wrong CR: %+v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("error queue backoff postponed external refresh past the test deadline") + } + result, err := r.Reconcile(t.Context(), key) + if err == nil || !strings.Contains(err.Error(), "continuous sibling build failure") || !result.IsZero() { + t.Fatalf("mixed failure was swallowed or recast as successful polling: %+v %v", result, err) + } + current := input.DeepCopy() + applyTestGet(t, r.Client, current) + want := framework.FactsPending + if pass == 1 { + want = framework.FactsResolved + } + for _, group := range current.Status.Groups { + if group.Name == "blocked" && (group.Facts == nil || group.Facts.State != want || group.Applied != (pass == 1)) { + t.Fatalf("independent fact did not progress through error retries: %+v", group) + } + } + if current.Generation != input.Generation { + t.Fatal("a CR input event masked the missing external refresh") + } + if pass == 0 { + if err := r.Client.Create(t.Context(), external); err != nil { + t.Fatal(err) + } + // Match controller-runtime's error branch: enqueue with RateLimited + // while processing, then Done releases the key. No CR/watch event. + queue.AddWithOpts(priorityqueue.AddOpts{RateLimited: true}, key) + } + queue.Done(key) + } +} diff --git a/internal/framework/controller/fixture_test.go b/internal/framework/controller/fixture_test.go new file mode 100644 index 00000000..8d11667b --- /dev/null +++ b/internal/framework/controller/fixture_test.go @@ -0,0 +1,78 @@ +package controller + +import ( + "testing" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// The wire fixture is generated formally by U02. Product behavior below is a +// minimal test model; no controller production file imports any product adapter. +type testConfig struct { + HTTPPort int32 `json:"httpPort"` + CatalogConfigMapName string `json:"catalogConfigMapName"` +} +type testClusterConfig struct { + NodeEnvironment string `json:"nodeEnvironment"` +} +type testFacts struct { + Catalogs map[string]map[string]string `json:"catalogs"` +} + +func testDefinition() framework.ProductDefinition[testConfig, testClusterConfig, testFacts] { + defaults := framework.Config[testConfig]{Common: framework.CommonConfig{Resources: framework.Resources{ + CPU: framework.CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("2")}, + Memory: framework.Memory{Limit: resource.MustParse("1Gi")}, + }}, Product: testConfig{HTTPPort: 8080}} + return framework.ProductDefinition[testConfig, testClusterConfig, testFacts]{ + Name: "fixture", ImageDefaults: framework.ImageConfig{Custom: "example.invalid/product:1", + PullPolicy: corev1.PullIfNotPresent}, + Roles: map[string]framework.RoleDefinition[testConfig]{ + "coordinators": {Config: defaults, + RoleConfig: framework.RoleConfig{PodDisruptionBudget: framework.PodDisruptionBudgetConfig{Enabled: true}}}, + "workers": {Config: defaults, + RoleConfig: framework.RoleConfig{PodDisruptionBudget: framework.PodDisruptionBudgetConfig{Enabled: true, + MaxUnavailable: 1}}}, + }, + GenerateGroup: func(framework.EffectiveInput[testConfig, testClusterConfig, + testFacts]) (framework.RuntimeDescription, error) { + return framework.RuntimeDescription{Main: framework.Process{Name: "trino", Command: []string{"run"}}, + Endpoints: []framework.Endpoint{{Name: "http", Port: 8080}}}, nil + }, + } +} +func newTestReconciler(c client.Client, scheme *runtime.Scheme, facts testFacts, +) *Reconciler[*generatedtrino.TrinoCluster, testConfig, testClusterConfig, testFacts] { + return &Reconciler[*generatedtrino.TrinoCluster, testConfig, testClusterConfig, testFacts]{ + Client: c, Scheme: scheme, Binding: generatedtrino.Binding(), Definition: testDefinition(), Facts: facts, + } +} + +func factsTestReconciler(t *testing.T, objects []client.Object) ( + *Reconciler[*generatedtrino.TrinoCluster, testConfig, testClusterConfig, testFacts], *generatedtrino.TrinoCluster, +) { + t.Helper() + cr, scheme := controllerInput(), controllerScheme(t) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{Catalogs: map[string]map[string]string{"test": {"key": "base"}}}) + r.Binding.Project = func(current *generatedtrino.TrinoCluster) (input.Projection, error) { + return input.Projection{Cluster: framework.ClusterIdentity{Name: current.Name, Namespace: current.Namespace}, + Roles: []input.Role{{Name: "workers", Groups: []input.Group{{Name: "blocked"}, {Name: "healthy"}}}}}, nil + } + r.Definition.ValidateInput = nil + r.Definition.GenerateCluster = nil + r.Definition.GenerateGroup = func(in framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + return framework.RuntimeDescription{Main: framework.Process{Name: "trino", + Env: []corev1.EnvVar{{Name: "FACT_VALUE", Value: in.Facts.Catalogs["test"]["key"]}}}}, nil + } + return r, cr +} diff --git a/internal/framework/controller/lifecycle_integration_test.go b/internal/framework/controller/lifecycle_integration_test.go new file mode 100644 index 00000000..6746ae60 --- /dev/null +++ b/internal/framework/controller/lifecycle_integration_test.go @@ -0,0 +1,164 @@ +package controller + +import ( + "testing" + "time" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func waitAPI(t *testing.T, check func() (bool, error)) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + ok, err := check() + if err != nil { + t.Fatal(err) + } + if ok { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("API observation did not converge") +} +func updateOperationAPI(t *testing.T, c client.Client, cr *generatedtrino.TrinoCluster, + mutate func(*generatedtrino.TrinoCluster), +) { + t.Helper() + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := cr.DeepCopy() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), current); err != nil { + return err + } + mutate(current) + return c.Update(t.Context(), current) + }); err != nil { + t.Fatal(err) + } +} +func workloadAPI(t *testing.T, c client.Client, cr client.Object, replicas int32) *appsv1.StatefulSet { + t.Helper() + sts := &appsv1.StatefulSet{} + waitAPI(t, func() (bool, error) { + err := c.Get(t.Context(), client.ObjectKey{Name: cr.GetName() + "-workers-default", + Namespace: cr.GetNamespace()}, sts) + return err == nil && sts.Spec.Replicas != nil && *sts.Spec.Replicas == replicas, client.IgnoreNotFound(err) + }) + return sts +} +func observeZeroAPI(t *testing.T, c client.Client, sts *appsv1.StatefulSet) { + t.Helper() + applyTestGet(t, c, sts) + sts.Status = appsv1.StatefulSetStatus{ObservedGeneration: sts.Generation} + if err := c.Status().Update(t.Context(), sts); err != nil { + t.Fatal(err) + } +} + +func integrationOperationAndRetirement(t *testing.T, c client.Client, + r *Reconciler[*generatedtrino.TrinoCluster, testConfig, testClusterConfig, testFacts], cr *generatedtrino.TrinoCluster, +) { + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)} + three := int32(3) + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { + current.Spec.Workers.RoleGroups = map[string]generatedtrino.RoleGroupInput{"default": {Replicas: &three}} + }) + workloadAPI(t, c, cr, 3) + stop := true + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { current.Spec.ClusterConfig.Stopped = &stop }) + sts := workloadAPI(t, c, cr, 0) + observeZeroAPI(t, c, sts) // explicit fixture observation, not a StatefulSet controller run + waitAPI(t, func() (bool, error) { + current := cr.DeepCopy() + if err := c.Get(t.Context(), request.NamespacedName, current); err != nil { + return false, err + } + return meta.IsStatusConditionTrue(current.Status.Conditions, "Stopped"), nil + }) + var pdb policyv1.PodDisruptionBudget + if err := c.Get(t.Context(), client.ObjectKey{Name: cr.Name + "-workers-pdb", Namespace: cr.Namespace}, + &pdb); err != nil { + t.Fatal(err) + } + if pdb.Spec.MinAvailable.IntVal != 2 { + t.Fatal("stop reduced the declared role budget") + } + pause := true + four := int32(4) + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { + current.Spec.ClusterConfig.ReconciliationPaused = &pause + current.Spec.Workers.RoleGroups = map[string]generatedtrino.RoleGroupInput{"default": {Replicas: &four}} + }) + waitAPI(t, func() (bool, error) { + current := cr.DeepCopy() + if err := c.Get(t.Context(), request.NamespacedName, current); err != nil { + return false, err + } + return meta.IsStatusConditionTrue(current.Status.Conditions, "Paused"), nil + }) + paused := cr.DeepCopy() + applyTestGet(t, c, paused) + built := meta.FindStatusCondition(paused.Status.Conditions, "Built") + if built == nil || built.ObservedGeneration >= paused.Generation { + t.Fatal("pause relabeled prior execution") + } + before := workloadAPI(t, c, cr, 0).ResourceVersion + if _, err := r.Reconcile(t.Context(), request); err != nil { + t.Fatal(err) + } + if current := workloadAPI(t, c, cr, 0); current.ResourceVersion != before { + t.Fatal("paused pass changed workload") + } + pause, stop = false, false + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { + current.Spec.ClusterConfig.ReconciliationPaused = &pause + current.Spec.ClusterConfig.Stopped = &stop + }) + sts = workloadAPI(t, c, cr, 4) + controller := true + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sts.Name + "-0", Namespace: cr.Namespace, + Labels: map[string]string{"app.kubernetes.io/instance": cr.Name, + "app.kubernetes.io/component": "workers", "role-group": "default"}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "StatefulSet", Name: sts.Name, + UID: sts.UID, Controller: &controller}}, + }, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "trino", Image: "example.invalid/fixture:1"}}}} + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { current.Spec.Workers.RoleGroups = nil }) + sts = workloadAPI(t, c, cr, 0) + observeZeroAPI(t, c, sts) + if _, err := r.Reconcile(t.Context(), request); err != nil { + t.Fatal(err) + } + applyTestGet(t, c, sts) // the real Pod still blocks retirement + if err := c.Delete(t.Context(), pod, client.GracePeriodSeconds(0)); err != nil { + t.Fatal(err) + } + waitAPI(t, func() (bool, error) { + if _, err := r.Reconcile(t.Context(), request); err != nil { + return false, err + } + current := cr.DeepCopy() + if err := c.Get(t.Context(), request.NamespacedName, current); err != nil { + return false, err + } + return meta.IsStatusConditionTrue(current.Status.Conditions, "Retired"), nil + }) + for _, slot := range []string{slotStatefulset, slotService, slotHeadless, slotConfigmap} { + object := (groupSlot{Role: "workers", Group: "default", Slot: slot}).object(cr) + if err := c.Get(t.Context(), client.ObjectKeyFromObject(object), object); !apierrors.IsNotFound(err) { + t.Fatalf("retired slot remained: %s %v", slot, err) + } + } +} diff --git a/internal/framework/controller/operation_client.go b/internal/framework/controller/operation_client.go new file mode 100644 index 00000000..7038157e --- /dev/null +++ b/internal/framework/controller/operation_client.go @@ -0,0 +1,158 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// operationClient is private to one pass. It refreshes intent before every API +// request, including conflict retries and PVC receipt writes. It cannot revoke +// requests already sent, or make the CR check and child request atomic. +type operationClient struct { + client.Client + check func(context.Context) error +} + +func checkOperation(ctx context.Context, c client.Client) error { + if guarded, ok := c.(*operationClient); ok { + return guarded.check(ctx) + } + return nil +} + +func (c *operationClient) Get(ctx context.Context, key client.ObjectKey, object client.Object, + options ...client.GetOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Get(ctx, key, object, options...) +} + +func (c *operationClient) List(ctx context.Context, objects client.ObjectList, + options ...client.ListOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.List(ctx, objects, options...) +} + +func (c *operationClient) Create(ctx context.Context, object client.Object, + options ...client.CreateOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Create(ctx, object, options...) +} + +func (c *operationClient) Update(ctx context.Context, object client.Object, + options ...client.UpdateOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Update(ctx, object, options...) +} + +func (c *operationClient) Patch(ctx context.Context, object client.Object, patch client.Patch, + options ...client.PatchOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Patch(ctx, object, patch, options...) +} + +func (c *operationClient) Delete(ctx context.Context, object client.Object, + options ...client.DeleteOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Delete(ctx, object, options...) +} + +func (c *operationClient) DeleteAllOf(ctx context.Context, object client.Object, + options ...client.DeleteAllOfOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.DeleteAllOf(ctx, object, options...) +} + +func (c *operationClient) Apply(ctx context.Context, object runtime.ApplyConfiguration, + options ...client.ApplyOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.Client.Apply(ctx, object, options...) +} + +func (c *operationClient) Status() client.SubResourceWriter { + return &operationWriter{SubResourceWriter: c.Client.Status(), check: c.check} +} +func (c *operationClient) SubResource(name string) client.SubResourceClient { + raw := c.Client.SubResource(name) + return &operationSubresource{operationWriter: operationWriter{SubResourceWriter: raw, check: c.check}, reader: raw} +} + +type operationWriter struct { + client.SubResourceWriter + check func(context.Context) error +} + +func (c *operationWriter) Create(ctx context.Context, object, subresource client.Object, + options ...client.SubResourceCreateOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.SubResourceWriter.Create(ctx, object, subresource, options...) +} + +func (c *operationWriter) Update(ctx context.Context, object client.Object, + options ...client.SubResourceUpdateOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.SubResourceWriter.Update(ctx, object, options...) +} + +func (c *operationWriter) Patch(ctx context.Context, object client.Object, patch client.Patch, + options ...client.SubResourcePatchOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.SubResourceWriter.Patch(ctx, object, patch, options...) +} + +func (c *operationWriter) Apply(ctx context.Context, object runtime.ApplyConfiguration, + options ...client.SubResourceApplyOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.SubResourceWriter.Apply(ctx, object, options...) +} + +type operationSubresource struct { + operationWriter + reader client.SubResourceReader +} + +func (c *operationSubresource) Get(ctx context.Context, object, subresource client.Object, + options ...client.SubResourceGetOption, +) error { + if err := c.check(ctx); err != nil { + return err + } + return c.reader.Get(ctx, object, subresource, options...) +} diff --git a/internal/framework/controller/operation_client_test.go b/internal/framework/controller/operation_client_test.go new file mode 100644 index 00000000..f8c5b38c --- /dev/null +++ b/internal/framework/controller/operation_client_test.go @@ -0,0 +1,185 @@ +package controller + +import ( + "context" + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" +) + +// Every promoted API method panics if it reaches the nil underlying interface. +// Constructing a status/subresource client itself does not make an API request. +type operationPanicClient struct{ client.Client } +type operationPanicSubresource struct{ client.SubResourceClient } + +func (operationPanicClient) Status() client.SubResourceWriter { return operationPanicSubresource{} } +func (operationPanicClient) SubResource(string) client.SubResourceClient { + return operationPanicSubresource{} +} + +func TestOperationClientRejectedRequestsNeverReachTheUnderlyingAPI(t *testing.T) { + ctx, object := t.Context(), &corev1.ConfigMap{} + patch := client.MergeFrom(object.DeepCopy()) + cases := map[string]func(*operationClient) error{ + "get": func(c *operationClient) error { return c.Get(ctx, client.ObjectKey{}, object) }, + "list": func(c *operationClient) error { return c.List(ctx, &corev1.ConfigMapList{}) }, + "create": func(c *operationClient) error { return c.Create(ctx, object) }, + "update": func(c *operationClient) error { return c.Update(ctx, object) }, + "dry-run": func(c *operationClient) error { return c.Update(ctx, object, client.DryRunAll) }, + "patch": func(c *operationClient) error { return c.Patch(ctx, object, patch) }, + "delete": func(c *operationClient) error { return c.Delete(ctx, object) }, + "delete-all": func(c *operationClient) error { return c.DeleteAllOf(ctx, object) }, + "apply": func(c *operationClient) error { return c.Apply(ctx, nil) }, + "status-create": func(c *operationClient) error { + return c.Status().Create(ctx, object, object) + }, + "status-update": func(c *operationClient) error { return c.Status().Update(ctx, object) }, + "status-patch": func(c *operationClient) error { return c.Status().Patch(ctx, object, patch) }, + "status-apply": func(c *operationClient) error { return c.Status().Apply(ctx, nil) }, + "subresource-get": func(c *operationClient) error { + return c.SubResource("scale").Get(ctx, object, object) + }, + "subresource-create": func(c *operationClient) error { + return c.SubResource("scale").Create(ctx, object, object) + }, + "subresource-update": func(c *operationClient) error { return c.SubResource("scale").Update(ctx, object) }, + "subresource-patch": func(c *operationClient) error { return c.SubResource("scale").Patch(ctx, object, patch) }, + "subresource-apply": func(c *operationClient) error { return c.SubResource("scale").Apply(ctx, nil) }, + } + for name, call := range cases { + t.Run(name, func(t *testing.T) { + refused, checks := errors.New("operation refused"), 0 + guarded := &operationClient{Client: operationPanicClient{}, check: func(context.Context) error { + checks++ + return refused + }} + if err := call(guarded); !errors.Is(err, refused) || checks != 1 { + t.Fatalf("request did not preserve guard failure: checks=%d err=%v", checks, err) + } + }) + } +} + +// Fake clients let this test keep generation unchanged deliberately, verifying +// the typed operation getter rather than relying on generation as the only gate. +func pauseOperationWithoutGenerationChange(ctx context.Context, c client.Client, + observed *generatedtrino.TrinoCluster, +) error { + current := observed.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(observed), current); err != nil { + return err + } + paused := true + if current.Spec.ClusterConfig == nil { + current.Spec.ClusterConfig = &generatedtrino.ClusterConfigInput{} + } + current.Spec.ClusterConfig.ReconciliationPaused = &paused + current.Generation = observed.Generation + return c.Update(ctx, current) +} + +func TestOperationClientPauseInterruptsRetainedReceiptRetry(t *testing.T) { + fixture := retainedFixture(t) + paused, receiptWrites, readsAfterPause := false, 0, 0 + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)). + WithObjects(fixture.cr, fixture.class, fixture.sts, fixture.claim, fixture.pv). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, object client.Object, + options ...client.GetOption) error { + if _, crRead := object.(*generatedtrino.TrinoCluster); paused && !crRead { + readsAfterPause++ + } + return c.Get(ctx, key, object, options...) + }, + Update: func(ctx context.Context, c client.WithWatch, object client.Object, + options ...client.UpdateOption) error { + if _, claim := object.(*corev1.PersistentVolumeClaim); !claim { + return c.Update(ctx, object, options...) + } + receiptWrites++ + if err := pauseOperationWithoutGenerationChange(ctx, c, fixture.cr); err != nil { + return err + } + paused = true + return apierrors.NewConflict(schema.GroupResource{Resource: "persistentvolumeclaims"}, object.GetName(), + errors.New("pause requested during receipt write")) + }, + }).Build() + r := newTestReconciler(c, c.Scheme(), testFacts{}) + guarded := &operationClient{Client: c, check: func(ctx context.Context) error { + return r.currentInput(ctx, fixture.cr) + }} + err := checkRetainedStorage(t.Context(), guarded, fixture.cr, fixture.group, + fixture.desired(), fixture.data, fixture.sts) + if !errors.Is(err, errSuperseded) || receiptWrites != 1 || readsAfterPause != 0 { + t.Fatalf("pause failed to stop receipt retry: err=%v writes=%d later reads=%d", err, receiptWrites, readsAfterPause) + } + claim := fixture.claim.DeepCopy() + applyTestGet(t, c, claim) + if _, recorded := claim.Annotations[retainedBindingAnnotation]; recorded { + t.Fatal("a rejected receipt write was nevertheless persisted") + } + current := fixture.cr.DeepCopy() + applyTestGet(t, c, current) + if current.Generation != fixture.cr.Generation || !generatedtrino.Operation(current).ReconciliationPaused { + t.Fatal("test did not pause through the typed getter while keeping generation unchanged") + } +} + +func TestOperationClientPauseInterruptsApplyConflictBeforeFurtherIO(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + live := retirementObjects(t, cr, "one", 1)[3].(*corev1.ConfigMap) + live.Data = map[string]string{"value": "old"} + desired := live.DeepCopy() + desired.Annotations, desired.OwnerReferences = nil, nil + desired.Data = map[string]string{"value": "new"} + paused, writes, dryRuns, readsAfterPause := false, 0, 0, 0 + c := retirementClient(scheme, cr, []client.Object{live}, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, object client.Object, + options ...client.GetOption) error { + if _, crRead := object.(*generatedtrino.TrinoCluster); paused && !crRead { + readsAfterPause++ + } + return c.Get(ctx, key, object, options...) + }, + Update: func(ctx context.Context, c client.WithWatch, object client.Object, + options ...client.UpdateOption) error { + if applyDryRun(options) { + dryRuns++ + return c.Update(ctx, object, options...) + } + writes++ + if err := pauseOperationWithoutGenerationChange(ctx, c, cr); err != nil { + return err + } + paused = true + return apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, object.GetName(), + errors.New("pause requested during apply")) + }, + }) + r := newTestReconciler(c, scheme, testFacts{}) + guarded := &operationClient{Client: c, check: func(ctx context.Context) error { return r.currentInput(ctx, cr) }} + slot := groupSlot{Role: "workers", Group: "one", Slot: slotConfigmap} + changed, err := applyObject(t.Context(), guarded, cr, desired, scheme, &slot, nil) + if changed || !errors.Is(err, errSuperseded) || writes != 1 || dryRuns != 1 || readsAfterPause != 0 { + t.Fatalf("apply continued after pause: changed=%t err=%v writes=%d dry-runs=%d later reads=%d", + changed, err, writes, dryRuns, readsAfterPause) + } + applyTestGet(t, c, live) + if live.Data["value"] != "old" { + t.Fatal("failed apply was persisted after pause") + } + current := cr.DeepCopy() + applyTestGet(t, c, current) + if current.Generation != cr.Generation || !generatedtrino.Operation(current).ReconciliationPaused { + t.Fatal("test did not pause without a generation change") + } +} diff --git a/internal/framework/controller/platform_inputs.go b/internal/framework/controller/platform_inputs.go new file mode 100644 index 00000000..d3c0d408 --- /dev/null +++ b/internal/framework/controller/platform_inputs.go @@ -0,0 +1,78 @@ +package controller + +import ( + "context" + "slices" + "strings" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// Runtime declarations decide this dependency: neither disabled groups nor +// groups that produce no log files require the central destination. Generation +// runs exactly once before these reads; assembly remains a pure later stage. +func (r *Reconciler[CR, C, S, F]) resolvePlatformInputs(ctx context.Context, prepared pipeline.PreparedInputs[C, S, F], + generated []pipeline.GeneratedGroup[C, S, F], options framework.AssemblyOptions, +) framework.AssemblyOptions { + if prepared.Platform.VectorAgentConfigMap == "" { + return options + } + active := false + for _, group := range generated { + active = active || collectsLogs(group) + } + if !active { + return options + } + reader := &trackedFactsReader{cache: &factReadCache{client: r.Client, scheme: r.Scheme, + reads: map[factReadKey]factRead{}}, observed: map[factReadKey]framework.FactObject{}} + result, err := framework.ResolveVectorDestination(ctx, reader, prepared.Source.Cluster.Namespace, + prepared.Platform.VectorAgentConfigMap) + if reader.failure != nil { + err = reader.failure + } + result = normalizeFactResult(result, err) + result.Diagnostic.Observed = reader.observations() + for index := range generated { + group := &generated[index] + if !collectsLogs(*group) { + continue + } + prior := group.Outcome.Facts + diagnostic := input.Clone(result.Diagnostic) + if prior != nil { + diagnostic.Observed = combineFactObservations(prior.Observed, diagnostic.Observed) + } + group.Outcome.Facts = &diagnostic + if result.Diagnostic.State != framework.FactsResolved { + group.Runtime = nil + group.Outcome.GeneratedEndpoints = nil + } + } + options.VectorDestination = input.Clone(result.Value) + return options +} + +func collectsLogs[C, S, F any](group pipeline.GeneratedGroup[C, S, F]) bool { + return group.Outcome.Error == "" && group.Input != nil && group.Runtime != nil && + group.Input.Config.Common.Logging.EnableVectorAgent && len(group.Runtime.LogOutputs) > 0 +} + +func combineFactObservations(prior, current []framework.FactObject) []framework.FactObject { + unique := make(map[string]framework.FactObject, len(prior)+len(current)) + for _, observations := range [][]framework.FactObject{prior, current} { + for _, object := range observations { + unique[factObjectKey(object)] = object + } + } + out := make([]framework.FactObject, 0, len(unique)) + for _, object := range unique { + out = append(out, object) + } + slices.SortFunc(out, func(a, b framework.FactObject) int { + return strings.Compare(factObjectKey(a), factObjectKey(b)) + }) + return out +} diff --git a/internal/framework/controller/platform_observation.go b/internal/framework/controller/platform_observation.go new file mode 100644 index 00000000..62b1abdb --- /dev/null +++ b/internal/framework/controller/platform_observation.go @@ -0,0 +1,306 @@ +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +const platformObserving = "Observing" + +func (r *Reconciler[CR, C, S, F]) platformReader() *trackedFactsReader { + return &trackedFactsReader{cache: &factReadCache{client: r.Client, scheme: r.Scheme, + reads: map[factReadKey]factRead{}}, + observed: map[factReadKey]framework.FactObject{}} +} +func platformObject(group, kind string) *unstructured.Unstructured { + value := &unstructured.Unstructured{} + value.SetGroupVersionKind(schema.GroupVersionKind{Group: group, Version: "v1alpha1", Kind: kind}) + return value +} +func platformDirectory(d framework.Directory) bool { return d.Secret != nil || d.Listener != nil } +func platformReference(namespace string, d framework.Directory) (framework.FactResource, client.ObjectKey) { + if s := d.Secret; s != nil { + if s.SecretName != "" { + return &corev1.Secret{}, client.ObjectKey{Namespace: namespace, Name: s.SecretName} + } + return platformObject("secrets.kubedoop.dev", "SecretClass"), client.ObjectKey{Name: s.SecretClass} + } + if d.Listener.Name != "" { + return platformObject("listeners.kubedoop.dev", "Listener"), + client.ObjectKey{Namespace: namespace, Name: d.Listener.Name} + } + return platformObject("listeners.kubedoop.dev", "ListenerClass"), client.ObjectKey{Name: d.Listener.Class} +} +func platformPending(out *framework.PlatformObservation, message string) { + out.Diagnostic.State = framework.FactsPending + out.Diagnostic.Reason = "PlatformNotReady" + out.Diagnostic.Message = message +} +func finishPlatformObservation(out *framework.PlatformObservation, reader *trackedFactsReader, err error) { + out.Diagnostic.Observed = reader.observations() + if err != nil { + out.Diagnostic.State = framework.FactsReadError + out.Diagnostic.Reason = "PlatformObservationFailed" + out.Diagnostic.Message = safeFactError(err) + } + if out.Diagnostic.State != framework.FactsResolved { + out.Listeners = nil + } +} + +// Source references exist before apply; ephemeral claims and Listener results do +// not. Only Secret data revisions and source identity/spec revisions roll Pods. +func (r *Reconciler[CR, C, S, F]) preparePlatformVolumes( + ctx context.Context, namespace string, runtime *framework.RuntimeDescription, pod *corev1.PodSpec, +) (out *framework.PlatformObservation, stamp string, err error) { + references := platformReferences(namespace, runtime, pod) + if len(references) == 0 { + return nil, "", nil + } + reader := r.platformReader() + out = &framework.PlatformObservation{Phase: "Preparing", + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved}} + defer func() { finishPlatformObservation(out, reader, err) }() + var revisions []string + for _, reference := range references { + object, key := reference.object, reference.key + if err = reader.Get(ctx, key, object); err != nil { + if apierrors.IsNotFound(err) { + if reference.optional { + revisions = append(revisions, reference.slot+":absent") + err = nil + continue + } + platformPending(out, "Waiting for platform reference "+key.String()) + return out, "", nil + } + return out, "", err + } + if !object.GetDeletionTimestamp().IsZero() { + platformPending(out, "Waiting for terminating platform reference "+key.String()) + return out, "", nil + } + version := strconv.FormatInt(object.GetGeneration(), 10) + if _, native := object.(*corev1.Secret); native { + version = object.GetResourceVersion() + } + revisions = append(revisions, reference.slot+":"+string(object.GetUID())+":"+version) + } + data, _ := json.Marshal(revisions) + sum := sha256.Sum256(data) + return out, hex.EncodeToString(sum[:]), nil +} + +// A fresh exact reader after apply cannot retain a pre-creation NotFound. A +// result is published only for the current Pod -> PVC -> PV -> Listener chain. +func (r *Reconciler[CR, C, S, F]) observePlatform( + ctx context.Context, cr CR, group framework.GroupIdentity, resources *pipeline.GroupResources, + runtime *framework.RuntimeDescription, +) (out *framework.PlatformObservation, err error) { + if len(platformReferences(group.Namespace, runtime, &resources.StatefulSet.Spec.Template.Spec)) == 0 { + return nil, nil + } + out = &framework.PlatformObservation{Phase: platformObserving, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved, Reason: "PlatformReady"}} + reader := r.platformReader() + defer func() { finishPlatformObservation(out, reader, err) }() + live := &appsv1.StatefulSet{} + if err = reader.Get(ctx, client.ObjectKeyFromObject(&resources.StatefulSet), live); err != nil { + return out, err + } + ownerKind, kindErr := apiutil.GVKForObject(cr, r.Scheme) + if kindErr != nil { + return out, kindErr + } + if err = checkOwnership(cr, live, ownerKind); err != nil { + return out, err + } + want := group.Replicas + if resources.StatefulSet.Spec.Replicas != nil { + want = *resources.StatefulSet.Spec.Replicas + } + if want == 0 { + platformPending(out, "No active producer Pod; preserving prior platform output") + return out, nil + } + if live.Status.ObservedGeneration < live.Generation { + platformPending(out, "Waiting for current producer revision") + } + for ordinal := int32(0); ordinal < want; ordinal++ { + pod := &corev1.Pod{} + key := client.ObjectKey{Namespace: group.Namespace, Name: live.Name + "-" + strconv.Itoa(int(ordinal))} + found, readErr := readPlatformObject(ctx, reader, key, pod, out) + if readErr != nil { + return out, readErr + } + if !found { + continue + } + if !controlledBy(pod, live.UID) { + return out, fmt.Errorf("platform producer Pod has a different owner") + } + if !platformPodReady(pod, live) { + platformPending(out, "Waiting for mounted current producer Pod "+pod.Name) + continue + } + for _, d := range runtime.Directories { + if !platformDirectory(d) || (d.Secret != nil && d.Secret.SecretName != "") { + continue + } + if err = observeCSIDirectory(ctx, reader, pod, d, out); err != nil { + return out, err + } + } + } + return out, nil +} +func controlledBy(object metav1.Object, uid types.UID) bool { + if uid == "" { + return false + } + for _, owner := range object.GetOwnerReferences() { + if owner.UID == uid && owner.Controller != nil && *owner.Controller { + return true + } + } + return false +} +func platformPodReady(pod *corev1.Pod, sts *appsv1.StatefulSet) bool { + if !pod.DeletionTimestamp.IsZero() || sts.Status.UpdateRevision == "" || + pod.Labels[appsv1.ControllerRevisionHashLabelKey] != sts.Status.UpdateRevision { + return false + } + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue { + return true + } + } + return false +} +func readPlatformObject(ctx context.Context, reader *trackedFactsReader, key client.ObjectKey, + object framework.FactResource, out *framework.PlatformObservation, +) (bool, error) { + if err := reader.Get(ctx, key, object); err != nil { + if apierrors.IsNotFound(err) { + platformPending(out, "Waiting for platform object "+key.String()) + return false, nil + } + return false, err + } + if !object.GetDeletionTimestamp().IsZero() { + platformPending(out, "Waiting for terminating platform object "+key.String()) + return false, nil + } + return true, nil +} +func observeCSIDirectory(ctx context.Context, reader *trackedFactsReader, pod *corev1.Pod, + d framework.Directory, out *framework.PlatformObservation, +) error { + claim := &corev1.PersistentVolumeClaim{} + key := client.ObjectKey{Namespace: pod.Namespace, Name: pod.Name + "-" + d.Name} + found, err := readPlatformObject(ctx, reader, key, claim, out) + if err != nil || !found { + return err + } + if !controlledBy(claim, pod.UID) { + return fmt.Errorf("CSI claim %s does not belong to producer Pod", claim.Name) + } + if claim.Status.Phase != corev1.ClaimBound || claim.Spec.VolumeName == "" { + platformPending(out, "Waiting for CSI binding "+claim.Name) + return nil + } + pv := &corev1.PersistentVolume{} + found, err = readPlatformObject(ctx, reader, client.ObjectKey{Name: claim.Spec.VolumeName}, pv, out) + if err != nil || !found { + return err + } + ref := pv.Spec.ClaimRef + if ref == nil || ref.UID != claim.UID || ref.Name != claim.Name || ref.Namespace != claim.Namespace { + return fmt.Errorf("CSI volume binding does not identify current claim") + } + if d.Listener == nil { + return nil + } + name := claim.Name + if d.Listener.Name != "" { + name = d.Listener.Name + } + listener := platformObject("listeners.kubedoop.dev", "Listener") + found, err = readPlatformObject(ctx, reader, client.ObjectKey{Namespace: pod.Namespace, Name: name}, listener, out) + if err != nil || !found { + return err + } + if d.Listener.Class != "" && !ownedByUID(listener, pv.UID) { + return fmt.Errorf("listener does not belong to current CSI volume") + } + addresses, err := listenerAddresses(listener, pod.Name, d.Name) + if err != nil { + return err + } + if len(addresses) == 0 { + platformPending(out, "Waiting for Listener addresses "+name) + } + out.Listeners = append(out.Listeners, addresses...) + return nil +} + +// Listener CSI uses a PV owner reference, without promising controller=true. +func ownedByUID(object metav1.Object, uid types.UID) bool { + if uid == "" { + return false + } + for _, owner := range object.GetOwnerReferences() { + if owner.UID == uid { + return true + } + } + return false +} +func listenerAddresses(listener *unstructured.Unstructured, pod, directory string, +) ([]framework.ListenerAddress, error) { + values, _, err := unstructured.NestedSlice(listener.Object, "status", "ingressAddresses") + if err != nil { + return nil, err + } + var out []framework.ListenerAddress + for _, raw := range values { + entry, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid Listener address") + } + address, _, err := unstructured.NestedString(entry, "address") + if err != nil || address == "" { + return nil, fmt.Errorf("invalid Listener address") + } + ports, _, err := unstructured.NestedMap(entry, "ports") + if err != nil { + return nil, err + } + item := framework.ListenerAddress{Pod: pod, Directory: directory, Address: address, Ports: map[string]int32{}} + for key, value := range ports { + number, ok := value.(int64) + if !ok || number < 1 || number > 65535 { + return nil, fmt.Errorf("invalid Listener port") + } + item.Ports[key] = int32(number) + } + out = append(out, item) + } + return out, nil +} diff --git a/internal/framework/controller/platform_observation_test.go b/internal/framework/controller/platform_observation_test.go new file mode 100644 index 00000000..a5e2036c --- /dev/null +++ b/internal/framework/controller/platform_observation_test.go @@ -0,0 +1,158 @@ +package controller + +import ( + "context" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func TestPlatformProducerObservationAndReferenceRefresh(t *testing.T) { + ctx := context.Background() + scheme := controllerScheme(t) + class := platformObject("listeners.kubedoop.dev", "ListenerClass") + class.SetName("external") + class.SetUID("class-uid") + class.SetGeneration(1) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "password", Namespace: "test", UID: "secret-uid"}, + Data: map[string][]byte{"password.db": []byte("first")}} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(class, secret). + WithStatusSubresource(&appsv1.StatefulSet{}, &corev1.Pod{}, &corev1.PersistentVolumeClaim{}).Build() + r := newTestReconciler(c, scheme, testFacts{}) + owner := controllerInput() + owner.Namespace = "test" + runtime := &framework.RuntimeDescription{Directories: []framework.Directory{ + {Name: "listener", Listener: &framework.ListenerVolume{Class: "external"}}, + {Name: "password", Secret: &framework.SecretVolume{SecretName: "password"}}, + }} + preparing, first, err := r.preparePlatformVolumes(ctx, "test", runtime, nil) + if err != nil || preparing.Diagnostic.State != framework.FactsResolved || first == "" { + t.Fatalf("producer was withheld waiting for not-yet-created CSI results: %+v %v", preparing, err) + } + // A class status-only write changes RV but must not initiate an endless rollout. + class.Object["status"] = map[string]any{"message": "status-only"} + if err := c.Update(ctx, class); err != nil { + t.Fatal(err) + } + _, second, err := r.preparePlatformVolumes(ctx, "test", runtime, nil) + if err != nil || first != second { + t.Fatalf("status-only reference update rolls producer: %v", err) + } + secret.Data["password.db"] = []byte("second") + if err := c.Update(ctx, secret); err != nil { + t.Fatal(err) + } + _, third, err := r.preparePlatformVolumes(ctx, "test", runtime, nil) + if err != nil || third == second { + t.Fatalf("Secret update did not change producer stamp: %v", err) + } + + sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "demo-workers-default", Namespace: "test", + UID: "sts-uid", Generation: 1}, + Spec: appsv1.StatefulSetSpec{Replicas: ptr.To(int32(1))}, + Status: appsv1.StatefulSetStatus{ObservedGeneration: 1, UpdateRevision: "revision-1"}} + if err := controllerutil.SetControllerReference(owner, sts, scheme); err != nil { + t.Fatal(err) + } + if err := c.Create(ctx, sts); err != nil { + t.Fatal(err) + } + group := framework.GroupIdentity{ClusterIdentity: framework.ClusterIdentity{Namespace: "test"}, Replicas: 1} + resources := &pipeline.GroupResources{StatefulSet: *sts} + out, err := r.observePlatform(ctx, owner, group, resources, runtime) + if err != nil || out.Diagnostic.State != framework.FactsPending { + t.Fatalf("missing producer observation: %+v %v", out, err) + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sts.Name + "-0", Namespace: "test", UID: "pod-uid", + Labels: map[string]string{appsv1.ControllerRevisionHashLabelKey: "revision-1"}, + OwnerReferences: []metav1.OwnerReference{{UID: sts.UID, Controller: ptr.To(true)}}}, + Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}}} + claim := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: pod.Name + "-listener", Namespace: "test", + UID: "claim-uid", + OwnerReferences: []metav1.OwnerReference{{UID: pod.UID, Controller: ptr.To(true)}}}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "listener-pv"}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: "listener-pv", UID: "pv-uid"}, + Spec: corev1.PersistentVolumeSpec{ClaimRef: &corev1.ObjectReference{Name: claim.Name, Namespace: claim.Namespace, + UID: claim.UID}}} + listener := platformObject("listeners.kubedoop.dev", "Listener") + listener.SetName(claim.Name) + listener.SetNamespace("test") + listener.SetUID("listener-uid") + listener.SetOwnerReferences([]metav1.OwnerReference{{UID: pv.UID}}) + listener.Object["status"] = map[string]any{"ingressAddresses": []any{ + map[string]any{"address": "gateway.test", "ports": map[string]any{"http": int64(31080)}}, + }} + for _, object := range []client.Object{pod, claim, pv, listener} { + if err := c.Create(ctx, object); err != nil { + t.Fatal(err) + } + } + out, err = r.observePlatform(ctx, owner, group, resources, runtime) + if err != nil || out.Diagnostic.State != framework.FactsResolved || len(out.Listeners) != 1 { + t.Fatalf("current CSI results were not resolved: %+v %v", out, err) + } + if out.Listeners[0].Ports["http"] != 31080 { + t.Fatal("generated port substituted for observed listener port") + } + // External address refresh needs neither a CR edit nor a Pod restart. + + listener.Object["status"] = map[string]any{"ingressAddresses": []any{ + map[string]any{"address": "gateway-new.test", "ports": map[string]any{"http": int64(31081)}}, + }} + if err := c.Update(ctx, listener); err != nil { + t.Fatal(err) + } + out, err = r.observePlatform(ctx, owner, group, resources, runtime) + if err != nil || out.Listeners[0].Address != "gateway-new.test" { + t.Fatalf("address update was cached: %+v %v", out, err) + } + // Reusing a former listener's name is not proof for the current volume. + listener.SetOwnerReferences([]metav1.OwnerReference{{UID: "former-pv"}}) + if err := c.Update(ctx, listener); err != nil { + t.Fatal(err) + } + out, err = r.observePlatform(ctx, owner, group, resources, runtime) + if err == nil || out.Diagnostic.State != framework.FactsReadError || len(out.Listeners) != 0 { + t.Fatalf("stale physical identity published addresses: %+v %v", out, err) + } +} + +func TestFinalSecretEnvironmentRefresh(t *testing.T) { + ctx := context.Background() + scheme := controllerScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + r := newTestReconciler(c, scheme, testFacts{}) + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "main", Env: []corev1.EnvVar{ + {Name: "TOKEN", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "token"}, Key: "value", Optional: ptr.To(true), + }}}, + }}}} + out, missing, err := r.preparePlatformVolumes(ctx, "test", nil, pod) + if err != nil || out.Diagnostic.State != framework.FactsResolved { + t.Fatalf("optional Secret blocked: %+v %v", out, err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "test", UID: "token-uid"}, + Data: map[string][]byte{"value": []byte("private")}} + if err := c.Create(ctx, secret); err != nil { + t.Fatal(err) + } + out, present, err := r.preparePlatformVolumes(ctx, "test", nil, pod) + if err != nil || out.Diagnostic.State != framework.FactsResolved || missing == present { + t.Fatalf("env-only reference did not refresh: %+v %v", out, err) + } + pod.Containers[0].Env[0] = corev1.EnvVar{Name: "TOKEN", Value: "override"} + out, stamp, err := r.preparePlatformVolumes(ctx, "test", nil, pod) + if err != nil || out != nil || stamp != "" { + t.Fatal("replaced SecretKeyRef remained a dependency") + } +} diff --git a/internal/framework/controller/platform_reconcile_test.go b/internal/framework/controller/platform_reconcile_test.go new file mode 100644 index 00000000..12f2b9aa --- /dev/null +++ b/internal/framework/controller/platform_reconcile_test.go @@ -0,0 +1,150 @@ +package controller + +import ( + "context" + "fmt" + "testing" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// This exercises the real reconcile/build/apply/shared-output call chain. The +// fixture supplies CSI observations; only the kind acceptance proves CSI ran. +func TestPlatformReconcileCreatesProducerThenRefreshesSharedOutput(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + class := platformObject("listeners.kubedoop.dev", "ListenerClass") + class.SetName("external") + class.SetUID("external-uid") + class.SetGeneration(1) + c := retirementClient(scheme, cr, []client.Object{class}, interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error { + if object.GetUID() == "" { + object.SetUID(types.UID(object.GetName() + "-uid")) + } + if object.GetGeneration() == 0 { + object.SetGeneration(1) + } + return c.Create(ctx, object, opts...) + }, + }) + r := newTestReconciler(c, scheme, testFacts{}) + r.Binding.Project = func(current *generatedtrino.TrinoCluster) (input.Projection, error) { + return input.Projection{Cluster: framework.ClusterIdentity{Name: current.Name, Namespace: current.Namespace}, + Roles: []input.Role{{Name: "workers", Groups: []input.Group{{Name: "default", Replicas: ptr.To(int32(1))}}}}}, nil + } + r.Definition.GenerateGroup = func(framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + return framework.RuntimeDescription{Main: framework.Process{Name: "main", Command: []string{"run"}, + Access: []framework.DirectoryAccess{{Directory: "listener", MountPath: "/listener", ReadOnly: true}}}, + Endpoints: []framework.Endpoint{{Name: "http", Port: 8080}}, + Directories: []framework.Directory{{Name: "listener", Listener: &framework.ListenerVolume{Class: "external"}}}}, nil + } + r.Definition.GenerateCluster = func(in framework.ClusterOutputInput[testClusterConfig, testFacts]) ( + framework.ClusterOutput, error, + ) { + for _, group := range in.Groups { + p := group.Platform + if p != nil && p.Phase == platformObserving && p.Diagnostic.State == framework.FactsResolved && + len(p.Listeners) > 0 { + out := sharedOutput(cr, "discovery") + out.ConfigMaps[0].Data["value"] = p.Listeners[0].Address + return out, nil + } + } + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "waiting for actual Listener"}, nil + } + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "discovery")); len(errs) != 0 { + t.Fatal(errs) + } + old := sharedRead(t, c, cr, "discovery") + reconcile := func() { + t.Helper() + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}); err != nil { + t.Fatal(err) + } + } + reconcile() + sts := &appsv1.StatefulSet{} + key := client.ObjectKey{Namespace: cr.Namespace, Name: cr.Name + "-workers-default"} + if err := c.Get(t.Context(), key, sts); err != nil { + t.Fatalf("producer deadlocked behind its own result: %v", err) + } + if got := sharedRead(t, c, cr, "discovery"); got.ResourceVersion != old.ResourceVersion { + t.Fatal("pending result overwrote shared output") + } + if sts.Annotations[platformClaimsAnnotation] == "" { + t.Fatal("platform declaration was not persisted") + } + sts.Status = appsv1.StatefulSetStatus{ObservedGeneration: sts.Generation, Replicas: 1, ReadyReplicas: 1, + UpdatedReplicas: 1, CurrentRevision: "revision-1", UpdateRevision: "revision-1"} + if err := c.Status().Update(t.Context(), sts); err != nil { + t.Fatal(err) + } + listener := supplyPlatformObservation(t, c, sts) + reconcile() + if got := sharedRead(t, c, cr, "discovery"); got.Data["value"] != "first.example" { + t.Fatalf("late result not published: %+v", got.Data) + } + listener.Object["status"] = platformListenerStatus("second.example") + if err := c.Update(t.Context(), listener); err != nil { + t.Fatal(err) + } + reconcile() + if got := sharedRead(t, c, cr, "discovery"); got.Data["value"] != "second.example" { + t.Fatalf("result refresh used stale cache: %+v", got.Data) + } + current := &generatedtrino.TrinoCluster{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), current); err != nil { + t.Fatal(err) + } + if current.Generation != cr.Generation { + t.Fatal("platform observation required changing CR") + } +} + +func supplyPlatformObservation(t *testing.T, c client.Client, sts *appsv1.StatefulSet) *unstructured.Unstructured { + t.Helper() + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sts.Name + "-0", Namespace: sts.Namespace, UID: "pod-uid", + Labels: map[string]string{appsv1.ControllerRevisionHashLabelKey: "revision-1"}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "StatefulSet", + Name: sts.Name, UID: sts.UID, Controller: ptr.To(true)}}}, + Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}}} + claim := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: pod.Name + "-listener", + Namespace: sts.Namespace, UID: "claim-uid", + OwnerReferences: []metav1.OwnerReference{{APIVersion: "v1", Kind: "Pod", + Name: pod.Name, UID: pod.UID, Controller: ptr.To(true)}}}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "listener-pv"}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: "listener-pv", UID: "pv-uid"}, + Spec: corev1.PersistentVolumeSpec{ClaimRef: &corev1.ObjectReference{Name: claim.Name, + Namespace: claim.Namespace, UID: claim.UID}}} + listener := platformObject("listeners.kubedoop.dev", "Listener") + listener.SetName(claim.Name) + listener.SetNamespace(sts.Namespace) + listener.SetOwnerReferences([]metav1.OwnerReference{{APIVersion: "v1", Kind: "PersistentVolume", + Name: pv.Name, UID: pv.UID}}) + listener.Object["status"] = platformListenerStatus("first.example") + for _, object := range []client.Object{pod, claim, pv, listener} { + if err := c.Create(t.Context(), object); err != nil { + t.Fatal(fmt.Errorf("create supplied observation: %w", err)) + } + } + return listener +} + +func platformListenerStatus(address string) map[string]any { + return map[string]any{"ingressAddresses": []any{map[string]any{"address": address, + "ports": map[string]any{"http": int64(8080)}}}} +} diff --git a/internal/framework/controller/platform_references.go b/internal/framework/controller/platform_references.go new file mode 100644 index 00000000..9b8af125 --- /dev/null +++ b/internal/framework/controller/platform_references.go @@ -0,0 +1,52 @@ +package controller + +import ( + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type platformInputReference struct { + object framework.FactResource + key client.ObjectKey + slot string + optional bool +} + +// Environment dependencies come from the final Pod: a removed or replaced +// default env reference must not continue withholding its former consumer. +func platformReferences(namespace string, runtime *framework.RuntimeDescription, pod *corev1.PodSpec, +) []platformInputReference { + var out []platformInputReference + if runtime != nil { + for _, d := range runtime.Directories { + if !platformDirectory(d) { + continue + } + object, key := platformReference(namespace, d) + out = append(out, platformInputReference{object: object, key: key, slot: d.Name}) + } + } + if pod == nil { + return out + } + add := func(slot, name string, optional *bool) { + out = append(out, platformInputReference{object: &corev1.Secret{}, + key: client.ObjectKey{Namespace: namespace, Name: name}, + slot: slot, optional: optional != nil && *optional}) + } + for _, container := range append(append([]corev1.Container{}, pod.InitContainers...), pod.Containers...) { + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil { + ref := env.ValueFrom.SecretKeyRef + add(container.Name+"/env/"+env.Name, ref.Name, ref.Optional) + } + } + for _, env := range container.EnvFrom { + if env.SecretRef != nil { + add(container.Name+"/envFrom/"+env.Prefix, env.SecretRef.Name, env.SecretRef.Optional) + } + } + } + return out +} diff --git a/internal/framework/controller/platform_storage.go b/internal/framework/controller/platform_storage.go new file mode 100644 index 00000000..cef55873 --- /dev/null +++ b/internal/framework/controller/platform_storage.go @@ -0,0 +1,110 @@ +package controller + +import ( + "encoding/json" + "fmt" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const platformClaimsAnnotation = "framework.kubedoop.dev/platform-claims" + +type platformClaimsReceipt struct { + Version int `json:"version"` + CRUID types.UID `json:"crUID"` + Role string `json:"role"` + Group string `json:"group"` + Volumes []corev1.Volume `json:"volumes"` +} + +// This receipt is written only after reserved-key validation and reconstruction +// from the typed declaration. It survives CR edits and controller restarts. +func stampPlatformClaims(owner client.Object, object client.Object, slot *groupSlot, + runtime *framework.RuntimeDescription, +) (client.Object, error) { + sts, ok := object.(*appsv1.StatefulSet) + if !ok { + return object, nil + } + volumes := pipeline.DeclaredPlatformClaims(runtime) + if len(volumes) == 0 { + return object, nil + } + if slot == nil || owner.GetUID() == "" { + return nil, fmt.Errorf("platform claims require an authenticated group") + } + receipt := platformClaimsReceipt{Version: 1, CRUID: owner.GetUID(), Role: slot.Role, + Group: slot.Group, Volumes: volumes} + next := sts.DeepCopy() + if next.Annotations == nil { + next.Annotations = map[string]string{} + } + encoded, err := json.Marshal(receipt) + if err != nil { + return nil, err + } + next.Annotations[platformClaimsAnnotation] = string(encoded) + if _, err := platformClaimNames(next); err != nil { + return nil, err + } + return next, nil +} + +func platformClaimNames(sts *appsv1.StatefulSet) (map[string]bool, error) { + names := map[string]bool{} + text := sts.Annotations[platformClaimsAnnotation] + if text == "" { + return names, nil + } + var receipt platformClaimsReceipt + if err := strictReceipt(text, &receipt); err != nil { + return nil, err + } + if receipt.Version != 1 || receipt.CRUID == "" || receipt.Role == "" || receipt.Group == "" || + len(receipt.Volumes) == 0 { + return nil, fmt.Errorf("invalid platform claim declaration receipt") + } + for _, declared := range receipt.Volumes { + if declared.Name == "" || declared.Ephemeral == nil || names[declared.Name] { + return nil, fmt.Errorf("invalid platform claim slot") + } + found := false + for _, actual := range sts.Spec.Template.Spec.Volumes { + if actual.Name == declared.Name { + if found || !apiequality.Semantic.DeepEqual(actual, declared) { + return nil, fmt.Errorf("platform claim %s differs from its declaration receipt", declared.Name) + } + found = true + } + } + if !found { + return nil, fmt.Errorf("platform claim %s is missing", declared.Name) + } + names[declared.Name] = true + } + return names, nil +} + +func validatePlatformClaimOwner(sts *appsv1.StatefulSet, owner client.Object, group groupSlot) error { + if _, err := platformClaimNames(sts); err != nil { + return err + } + text := sts.Annotations[platformClaimsAnnotation] + if text == "" { + return nil + } + var receipt platformClaimsReceipt + if err := strictReceipt(text, &receipt); err != nil { + return err + } + if receipt.CRUID != owner.GetUID() || receipt.Role != group.Role || receipt.Group != group.Group { + return fmt.Errorf("platform claim declaration belongs to another CR or group") + } + return nil +} diff --git a/internal/framework/controller/platform_storage_test.go b/internal/framework/controller/platform_storage_test.go new file mode 100644 index 00000000..0d44f05f --- /dev/null +++ b/internal/framework/controller/platform_storage_test.go @@ -0,0 +1,90 @@ +package controller + +import ( + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func platformStorageRuntime() *framework.RuntimeDescription { + return &framework.RuntimeDescription{Directories: []framework.Directory{ + {Name: "listener", Listener: &framework.ListenerVolume{Class: "external"}}, + {Name: "tls", Secret: &framework.SecretVolume{SecretClass: "tls", Format: "tls-pem"}}, + }} +} + +func TestPlatformClaimsApplyStopAndRetire(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + slot := groupSlot{Role: "workers", Group: "default", Slot: slotStatefulset} + runtime := platformStorageRuntime() + desired := retirementObjects(t, cr, "default", 0)[0].(*appsv1.StatefulSet) + desired.Annotations, desired.OwnerReferences = nil, nil + desired.Spec.Template.Spec.Volumes = pipeline.DeclaredPlatformClaims(runtime) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr).Build() + r := newTestReconciler(c, scheme, testFacts{}) + if err := r.preflightStorage(t.Context(), cr, slot, desired, nil, runtime); err != nil { + t.Fatal(err) + } + if _, err := applyScopedObject(t.Context(), c, cr, desired, scheme, &slot, nil, nil, nil, runtime); err != nil { + t.Fatalf("declared platform claims cannot apply: %v", err) + } + live := &appsv1.StatefulSet{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(desired), live); err != nil { + t.Fatal(err) + } + if live.Annotations[platformClaimsAnnotation] == "" { + t.Fatal("missing durable platform declaration") + } + // A fresh controller can stop and retire without access to the old runtime. + r = newTestReconciler(c, scheme, testFacts{}) + if pending, err := r.stopWorkload(t.Context(), cr, slot); pending || err != nil { + t.Fatalf("stop: %v %v", pending, err) + } + if _, err := r.retireGroup(t.Context(), cr, slot); err != nil { + t.Fatalf("retire: %v", err) + } +} + +func TestPlatformClaimsCoexistWithRetainedDataAndRejectUndeclaredClaims(t *testing.T) { + f := retainedFixture(t) + runtime := platformStorageRuntime() + f.sts.Spec.Template.Spec.Volumes = pipeline.DeclaredPlatformClaims(runtime) + stamped, err := stampPlatformClaims(f.cr, f.sts, &f.group, runtime) + if err != nil { + t.Fatal(err) + } + sts := stamped.(*appsv1.StatefulSet) + if _, err := declaredRetainedSource(sts, f.cr, f.group); err != nil { + t.Fatalf("platform claim blocks retained data: %v", err) + } + // Even an exact clone of a valid platform claim is not a declaration when it + // is introduced solely by Pod overrides under a new volume name. + injected := *sts.Spec.Template.Spec.Volumes[0].DeepCopy() + injected.Name = "undeclared" + sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, injected) + if err := validRetainedTemplate(sts, f.data); err == nil { + t.Fatal("undeclared ephemeral accepted") + } + sts = stamped.(*appsv1.StatefulSet).DeepCopy() + sts.Spec.VolumeClaimTemplates = nil + if err := validRetainedTemplate(sts, nil); err == nil { + t.Fatal("undeclared ephemeral accepted without retained data") + } +} + +func TestPlatformClaimReceiptCannotBeSuppliedByDesiredMetadata(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + slot := groupSlot{Role: "workers", Group: "default", Slot: slotStatefulset} + desired := retirementObjects(t, cr, "default", 0)[0].(*appsv1.StatefulSet) + desired.Annotations = map[string]string{platformClaimsAnnotation: `{}`} + desired.OwnerReferences = nil + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr).Build() + if _, err := applyScopedObject(t.Context(), c, cr, desired, scheme, &slot, nil, nil, nil, + platformStorageRuntime()); err == nil { + t.Fatal("product/override metadata supplied controller platform proof") + } +} diff --git a/internal/framework/controller/projection_reconcile_test.go b/internal/framework/controller/projection_reconcile_test.go new file mode 100644 index 00000000..35121ea5 --- /dev/null +++ b/internal/framework/controller/projection_reconcile_test.go @@ -0,0 +1,200 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + appsv1 "k8s.io/api/apps/v1" + policyv1 "k8s.io/api/policy/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestPauseReadsOperationBeforeProjectionAndPreservesExecutionGeneration(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + paused := true + cr.Generation = 5 + cr.Spec.ClusterConfig = &generatedtrino.ClusterConfigInput{ReconciliationPaused: &paused} + cr.Status = framework.ReconcileStatus{ObservedGeneration: 4, Groups: []framework.GroupReconcileStatus{ + {Role: "workers", Name: "previous", DesiredReplicas: 2, Applied: true}, + }} + cr.Status.Conditions = []metav1.Condition{{Type: "Built", Status: metav1.ConditionTrue, ObservedGeneration: 4, + Reason: "ResourcesBuilt", LastTransitionTime: metav1.NewTime(time.Unix(1, 0))}} + reads := 0 + c := retirementClient(scheme, cr, nil, interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, + key client.ObjectKey, object client.Object, opts ...client.GetOption) error { + if _, ok := object.(*generatedtrino.TrinoCluster); !ok { + t.Fatal("paused reconciliation read child resources") + } + reads++ + return c.Get(ctx, key, object, opts...) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + r.Binding.Project = func(*generatedtrino.TrinoCluster) (input.Projection, error) { + t.Fatal("pause tried full projection") + return input.Projection{}, nil + } + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)} + for pass := range 2 { + before := cr.DeepCopy() + applyTestGet(t, c, before) + result, err := r.Reconcile(t.Context(), request) + if err != nil || !result.IsZero() { + t.Fatalf("pause scheduled work: %v %v", result, err) + } + current := cr.DeepCopy() + applyTestGet(t, c, current) + built := meta.FindStatusCondition(current.Status.Conditions, "Built") + pause := meta.FindStatusCondition(current.Status.Conditions, "Paused") + if built.ObservedGeneration != 4 || pause.ObservedGeneration != 5 || current.Status.ObservedGeneration != 5 || + len(current.Status.Groups) != 1 || !current.Status.Groups[0].Applied { + t.Fatalf("pause relabeled old execution: %+v", current.Status) + } + if pass == 1 && before.ResourceVersion != current.ResourceVersion { + t.Fatal("stable pause rewrote status") + } + } + if reads == 0 { + t.Fatal("test did not observe CR") + } +} + +func TestProjectionFailureBlocksRetirementButStopStillUsesLiveSlots(t *testing.T) { + for _, stopped := range []bool{false, true} { + t.Run(fmt.Sprint(stopped), func(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + cr.Spec.ClusterConfig = &generatedtrino.ClusterConfigInput{Stopped: &stopped} + objects := retirementObjects(t, cr, "existing", 2) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + r.Binding.Project = func(*generatedtrino.TrinoCluster) (input.Projection, error) { + return input.Projection{}, errors.New("invalid projection") + } + _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}) + if err == nil { + t.Fatal("projection error was lost") + } + sts := objects[0].(*appsv1.StatefulSet).DeepCopy() + applyTestGet(t, c, sts) + want := int32(2) + if stopped { + want = 0 + } + if *sts.Spec.Replicas != want { + t.Fatalf("live stop or retention failed: %+v", sts.Spec.Replicas) + } + for _, object := range objects { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + current := cr.DeepCopy() + applyTestGet(t, c, current) + condition := meta.FindStatusCondition(current.Status.Conditions, "Retired") + if condition == nil || condition.Status != metav1.ConditionUnknown { + t.Fatal("partial projection allowed retirement") + } + }) + } +} + +func TestStoppedUsesExecutionZeroAndResumeUsesLatestDeclaredReplicas(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + current := cr.DeepCopy() + applyTestGet(t, r.Client, current) + stopped := true + current.Spec.ClusterConfig = &generatedtrino.ClusterConfigInput{Stopped: &stopped} + if err := r.Client.Update(t.Context(), current); err != nil { + t.Fatal(err) + } + for pass, replicas := range []int32{2, 3} { + r.Binding.Project = func(observed *generatedtrino.TrinoCluster) (input.Projection, error) { + return input.Projection{Cluster: framework.ClusterIdentity{Name: observed.Name, Namespace: observed.Namespace}, + Roles: []input.Role{{Name: "workers", Replicas: &replicas, Groups: []input.Group{{Name: "blocked"}, + {Name: "healthy"}}}}}, nil + } + if pass == 1 { + applyTestGet(t, r.Client, current) + stopped = false + current.Spec.ClusterConfig.Stopped = &stopped + current.Generation++ + if err := r.Client.Update(t.Context(), current); err != nil { + t.Fatal(err) + } + } + _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}) + if err != nil { + t.Fatal(err) + } + applyTestGet(t, r.Client, current) + execution := replicas + if stopped { + execution = 0 + } + for _, group := range current.Status.Groups { + if group.DesiredReplicas != replicas || group.ExecutionReplicas == nil || *group.ExecutionReplicas != execution { + t.Fatalf("declared/execution counts conflated: %+v", group) + } + } + var pdb policyv1.PodDisruptionBudget + if err := r.Client.Get(t.Context(), client.ObjectKey{Name: cr.Name + "-workers-pdb", + Namespace: cr.Namespace}, &pdb); err != nil { + t.Fatal(err) + } + if pdb.Spec.MinAvailable.IntVal != 2*replicas-1 { + t.Fatal("stop changed declared role budget") + } + if stopped && meta.FindStatusCondition(current.Status.Conditions, + "WorkloadsReady").Status != metav1.ConditionUnknown { + t.Fatal("stop claimed application readiness") + } + } +} + +func TestAbsentSharedCallbackWithdrawsTrustedLiveSet(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "old")); len(errs) != 0 { + t.Fatal(errs) + } + r.Definition.GenerateCluster = nil + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}); err != nil { + t.Fatal(err) + } + sharedGone(t, r.Client, cr, "old") +} + +func TestFailedDesiredGroupDoesNotHideRemovedGroupRetirement(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + kept, removed := retirementObjects(t, cr, "kept", 1), retirementObjects(t, cr, "removed", 0) + c := retirementClient(scheme, cr, append(kept, removed...), interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + r.Binding.Project = func(current *generatedtrino.TrinoCluster) (input.Projection, error) { + return input.Projection{Cluster: framework.ClusterIdentity{Name: current.Name, Namespace: current.Namespace}, + Roles: []input.Role{{Name: "workers", Groups: []input.Group{{Name: "kept"}}}}}, nil + } + r.Definition.GenerateGroup = func(framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + return framework.RuntimeDescription{}, errors.New("group generation failed") + } + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}); err == nil { + t.Fatal("lost generation error") + } + sts := kept[0].(*appsv1.StatefulSet).DeepCopy() + applyTestGet(t, c, sts) + if *sts.Spec.Replicas != 1 { + t.Fatal("failed desired group was retired") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(removed[0]), + &appsv1.StatefulSet{}); !apierrors.IsNotFound(err) { + t.Fatal(err) + } +} diff --git a/internal/framework/controller/reconciler.go b/internal/framework/controller/reconciler.go new file mode 100644 index 00000000..6b245eec --- /dev/null +++ b/internal/framework/controller/reconciler.go @@ -0,0 +1,506 @@ +// Package controller executes the framework resource pipeline against direct API +// observations. Products register through the public operator package. +package controller + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +type Reconciler[CR input.Object, C, S, F any] struct { + // Client must read directly from the API server, including inside retries. + Client client.Client + Scheme *runtime.Scheme + Binding input.Binding[CR] + Definition framework.ProductDefinition[C, S, F] + Assembly framework.AssemblyOptions + Facts F + // ResolveFacts is the explicit read-only I/O step between CR preparation and + // pure resource generation. Every group gets an isolated input and reader. + ResolveFacts func(context.Context, framework.FactsReader, framework.FactInput[C, S, + F]) (framework.FactResult[F], error) + // External objects are refreshed even after resolution succeeds. No external + // object watches or product-owned controller are implied by this interval. + FactRefreshInterval time.Duration +} + +func (r *Reconciler[CR, C, S, F]) SetupWithManager(manager ctrl.Manager) error { + if r.Client == nil || r.Scheme == nil || r.Binding.NewObject == nil || + r.Binding.Status == nil || r.Binding.Project == nil || r.Binding.Operation == nil { + return fmt.Errorf("controller client, scheme and complete input binding are required") + } + // Status writes must not schedule another pass of their own. Child updates + // include StatefulSet status and metadata, not just generation changes. + return ctrl.NewControllerManagedBy(manager). + WithOptions(r.controllerOptions()). + For(r.Binding.NewObject(), builder.WithPredicates(predicate.Or( + predicate.GenerationChangedPredicate{}, predicate.LabelChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + ))). + Owns(&corev1.ConfigMap{}).Owns(&corev1.Service{}).Owns(&appsv1.StatefulSet{}). + Owns(&policyv1.PodDisruptionBudget{}). + Complete(r) +} + +func (r *Reconciler[CR, C, S, F]) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + cr := r.Binding.NewObject() + if err := r.Client.Get(ctx, request.NamespacedName, cr); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !cr.GetDeletionTimestamp().IsZero() { + return ctrl.Result{}, nil + } + if r.Binding.Operation == nil { + return ctrl.Result{}, fmt.Errorf("generated operation binding is required") + } + operation := r.Binding.Operation(cr) + if operation.ReconciliationPaused { + status := r.Binding.Status(cr).DeepCopy() + status.ObservedGeneration = cr.GetGeneration() + setCondition(status, "Paused", true, "ReconciliationPaused", "Resource reconciliation is paused") + if err := r.writeStatus(ctx, cr, *status); err != nil { + if errors.Is(err, errSuperseded) { + return ctrl.Result{RequeueAfter: time.Millisecond}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + // One private client per pass: the manager's shared reconciler never stores + // cluster-specific state. Fresh intent reads use the unwrapped client. + pass := *r + pass.Client = &operationClient{Client: r.Client, check: func(ctx context.Context) error { + return r.currentInput(ctx, cr) + }} + return pass.reconcileObserved(ctx, cr, operation) +} + +func (r *Reconciler[CR, C, S, F]) reconcileObserved( + ctx context.Context, cr CR, operation framework.ClusterOperation, +) (ctrl.Result, error) { + status := framework.ReconcileStatus{ObservedGeneration: cr.GetGeneration()} + // Carry transition times forward. Group observations are rebuilt each pass. + status.Conditions = slices.Clone(r.Binding.Status(cr).Conditions) + setCondition(&status, "Paused", false, "ReconciliationActive", "Resource reconciliation is active") + projection, sourceErr := r.Binding.Project(cr) + var source pipeline.SourceSnapshot[F] + if sourceErr == nil { + source, sourceErr = pipeline.SourceFromProjection(projection, r.Facts, operation) + } + if sourceErr == nil && (source.Cluster.Name != cr.GetName() || source.Cluster.Namespace != cr.GetNamespace()) { + sourceErr = fmt.Errorf("source identity differs from the observed CR") + } + if sourceErr == nil && source.Operation != operation { + sourceErr = fmt.Errorf("source operation differs from the observed CR") + } + var identities []framework.GroupIdentity + var roleIdentities []framework.RoleIdentity + inventoryErr, roleInventoryErr := sourceErr, sourceErr + if sourceErr == nil { + identities, inventoryErr = pipeline.SourceGroupIdentities(source) + roleIdentities, roleInventoryErr = pipeline.SourceRoleIdentities(source) + } + // Role management is built from the complete source independently of group + // preparation and product callbacks. Facts never remove desired role replicas. + var roles []pipeline.BuiltRole + roleBuildErr := sourceErr + if roleBuildErr == nil { + roles, roleBuildErr = pipeline.BuildRoleResources(r.Definition, source) + } + var failures, stopFailures []error + pending := false + if operation.Stopped { + var stopping bool + stopping, stopFailures = r.stopWorkloads(ctx, cr, identities) + pending = pending || stopping + failures = append(failures, stopFailures...) + } + plan, err := r.buildGroupPlan(ctx, source, sourceErr) + if err != nil { + failures = append(failures, err) + setCondition(&status, "Built", false, "BuildFailed", err.Error()) + setCondition(&status, "Applied", false, "NotBuilt", "No resource plan is available") + setCondition(&status, "WorkloadsReady", false, "NotApplied", "The current generation was not applied") + } else { + applying, applyErrors := r.applyPlan(ctx, cr, plan, &status) + pending = pending || applying + failures = append(failures, applyErrors...) + } + rolePending, roleFailures := r.reconcileRoleResources(ctx, cr, roles, + roleIdentities, roleBuildErr, roleInventoryErr, &status) + pending = pending || rolePending + failures = append(failures, roleFailures...) + if inventoryErr != nil { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Retired", + Status: metav1.ConditionUnknown, ObservedGeneration: status.ObservedGeneration, + Reason: "InventoryUnavailable", + Message: "A complete desired identity inventory is unavailable; retirement is not attempted"}) + } else { + retiring, retirementErrors := r.retireGroups(ctx, cr, identities, &status) + pending = pending || retiring + failures = append(failures, retirementErrors...) + } + if operation.Stopped { + // Observe again after apply/retirement: a just-created zero-replica + // StatefulSet has not yet confirmed its controller observation. + stopping, observationErrors := r.stopWorkloads(ctx, cr, identities) + pending = pending || stopping + stopFailures = append(stopFailures, observationErrors...) + failures = append(failures, observationErrors...) + complete := !stopping && len(stopFailures) == 0 + reason, message := "Stopping", "Waiting for zero-replica StatefulSet observations and Pod absence" + if complete { + reason, message = "WorkloadsStopped", "Controlled workloads have zero replicas and no remaining Pods" + } else if len(stopFailures) > 0 { + reason, message = "StopFailed", errorMessage(stopFailures) + } + setCondition(&status, "Stopped", complete, reason, message) + if inventoryErr != nil && len(stopFailures) == 0 { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Stopped", + Status: metav1.ConditionUnknown, ObservedGeneration: status.ObservedGeneration, + Reason: "InventoryUnavailable", + Message: "Known live workloads were checked; complete desired inventory is unavailable"}) + } + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "WorkloadsReady", + Status: metav1.ConditionUnknown, ObservedGeneration: status.ObservedGeneration, + Reason: "ClusterStopped", Message: "Stop is requested; application availability is not evaluated"}) + } else { + setCondition(&status, "Stopped", false, "NotRequested", "Stop is not requested") + } + if errors.Is(errors.Join(failures...), errSuperseded) { + return ctrl.Result{RequeueAfter: time.Millisecond}, nil + } + if err := r.writeStatus(ctx, cr, status); err != nil { + if errors.Is(err, errSuperseded) { + return ctrl.Result{RequeueAfter: time.Millisecond}, nil + } + failures = append(failures, err) + } + if err := errors.Join(failures...); err != nil { + return ctrl.Result{}, err + } + // Pod drain/deletion may not emit a directly owned-object event. Pending + // retirement and facts use the shorter cadence; resolved facts still refresh. + return ctrl.Result{RequeueAfter: r.nextRefresh(pending)}, nil +} + +func (r *Reconciler[CR, C, S, F]) buildGroupPlan(ctx context.Context, source pipeline.SourceSnapshot[F], + sourceErr error, +) (pipeline.ResourcePlan[C, S, F], error) { + if sourceErr != nil { + return pipeline.ResourcePlan[C, S, F]{}, sourceErr + } + if err := checkOperation(ctx, r.Client); err != nil { + return pipeline.ResourcePlan[C, S, F]{}, err + } + prepared, err := pipeline.PrepareInputs(r.Definition, source) + if err != nil { + return pipeline.ResourcePlan[C, S, F]{}, err + } + facts := r.resolvePreparedFacts(ctx, prepared) + generated, err := pipeline.GeneratePreparedGroups(r.Definition, prepared, facts) + if err != nil { + return pipeline.ResourcePlan[C, S, F]{}, err + } + assembly := r.resolvePlatformInputs(ctx, prepared, generated, r.Assembly) + if err := checkOperation(ctx, r.Client); err != nil { + return pipeline.ResourcePlan[C, S, F]{}, err + } + return pipeline.AssemblePreparedGroups(r.Definition, prepared, generated, assembly) +} + +func (r *Reconciler[CR, C, S, F]) applyPlan( + ctx context.Context, cr CR, plan pipeline.ResourcePlan[C, S, F], status *framework.ReconcileStatus, +) (bool, []error) { + var failures []error + pending := false + built, applied, ready := true, true, true + for i := range plan.Groups { + group := &plan.Groups[i] + observation := framework.GroupReconcileStatus{ + Role: group.Outcome.Group.Role, Name: group.Outcome.Group.Name, DesiredReplicas: group.Outcome.Group.Replicas, + Checks: slices.Clone(group.Checks), Facts: cloneFactDiagnostic(group.Outcome.Facts), + Platform: group.Outcome.Platform, + } + if blocked, waiting := factsBlockGroup(&observation); blocked { + built, applied, ready = false, false, false + pending = pending || waiting + } else if group.Resources == nil || group.Outcome.Error != "" { + observation.Message = group.Outcome.Error + if observation.Message == "" { + observation.Message = "Group did not produce a resource set" + } + built, applied, ready = false, false, false + failures = append(failures, fmt.Errorf("%s/%s: %s", observation.Role, observation.Name, observation.Message)) + } else { + waiting, groupReady, errs := r.applyPreparedGroup(ctx, cr, group, &observation) + pending = pending || waiting + applied = applied && observation.Applied + ready = ready && groupReady + failures = append(failures, errs...) + } + group.Outcome.Platform = observation.Platform + status.Groups = append(status.Groups, observation) + } + if plan.Prepared != nil { + pipeline.RefreshClusterOutput(r.Definition, &plan) + } + dependenciesReady := true + for _, g := range status.Groups { + dependenciesReady = dependenciesReady && (g.Platform == nil || g.Platform.Diagnostic.State == framework.FactsResolved) + } + setCondition(status, "PlatformReady", dependenciesReady, choose(dependenciesReady, "PlatformReady", "PlatformPending"), + choose(dependenciesReady, "Platform observations are ready", "Waiting for platform producers and addresses")) + if plan.ClusterError != "" { + built, applied = false, false + failures = append(failures, fmt.Errorf("cluster output: %s", plan.ClusterError)) + } else { + waiting, errs := r.reconcileShared(ctx, cr, plan.ClusterOutput) + pending = pending || waiting + applied = applied && !waiting && len(errs) == 0 + failures = append(failures, errs...) + } + recordPlanConditions(plan, status, built, applied, ready, failures) + return pending, failures +} + +func (r *Reconciler[CR, C, S, F]) applyPreparedGroup(ctx context.Context, cr CR, + group *pipeline.BuiltGroup[C, S, F], observation *framework.GroupReconcileStatus, +) (bool, bool, []error) { + resources := group.Resources + observation.ExecutionReplicas = input.Clone(resources.StatefulSet.Spec.Replicas) + terminating, err := r.groupTerminating(ctx, cr, group.Outcome.Group) + if err != nil { + observation.Message = err.Error() + return false, false, []error{err} + } + if terminating { + observation.Message = "Waiting for terminating group slots before re-adding resources" + return true, false, nil + } + platform, stamp, err := r.preparePlatformVolumes(ctx, cr.GetNamespace(), group.Runtime, + &resources.StatefulSet.Spec.Template.Spec) + observation.Platform = platform + if err != nil { + observation.Message = safeFactError(err) + return false, false, []error{err} + } + if platform != nil && platform.Diagnostic.State != framework.FactsResolved { + observation.Message = platform.Diagnostic.Message + return true, false, nil + } + if platform != nil { + observation.Platform = &framework.PlatformObservation{Phase: platformObserving, Diagnostic: framework.FactDiagnostic{ + State: framework.FactsPending, Reason: "ProducerNotApplied", + Message: "Waiting for producer apply and current Pod observation"}} + } + if stamp != "" { + if resources.StatefulSet.Spec.Template.Annotations == nil { + resources.StatefulSet.Spec.Template.Annotations = map[string]string{} + } + resources.StatefulSet.Spec.Template.Annotations["framework.kubedoop.dev/platform-inputs"] = stamp + } + slot := groupSlot{Role: observation.Role, Group: observation.Name, Slot: slotStatefulset} + err = r.preflightStorage(ctx, cr, slot, &resources.StatefulSet, resources.RetainedData, group.Runtime) + pending := errors.Is(err, errStorageUnbound) + if err != nil && !pending { + observation.Message = err.Error() + if errors.Is(err, errStoragePending) { + return true, false, nil + } + return false, false, []error{err} + } + waiting, err := r.applyGroupObjects(ctx, cr, resources, observation, group.Runtime) + pending = pending || waiting + var failures []error + if err != nil { + failures = append(failures, err) + } + groupReady, err := r.observeWorkload(ctx, cr, &resources.StatefulSet, observation) + if err != nil { + failures = append(failures, err) + observation.Message = err.Error() + } + pending = pending || (resources.Coordination != nil && !groupReady) + if observation.Applied { + platform, err = r.observePlatform(ctx, cr, group.Outcome.Group, resources, group.Runtime) + observation.Platform = platform + if err != nil { + failures = append(failures, err) + observation.Message = safeFactError(err) + } + pending = pending || (platform != nil && platform.Diagnostic.State != framework.FactsResolved) + } + return pending, observation.Applied && groupReady && len(failures) == 0, failures +} + +func (r *Reconciler[CR, C, S, F]) applyGroupObjects(ctx context.Context, cr CR, + resources *pipeline.GroupResources, observation *framework.GroupReconcileStatus, + runtime ...*framework.RuntimeDescription, +) (bool, error) { + objects := []client.Object{&resources.ConfigMap, &resources.HeadlessService, + &resources.Service, &resources.StatefulSet} + slots := []string{slotConfigmap, slotHeadless, slotService, slotStatefulset} + observation.Applied = true + for index, object := range objects { + slot := &groupSlot{Role: observation.Role, Group: observation.Name, Slot: slots[index]} + if slots[index] == slotStatefulset && resources.Coordination != nil && r.Binding.Operation(cr).Stopped { + live, err := r.readSlot(ctx, cr, *slot) + if err != nil { + observation.Applied = false + observation.Message = err.Error() + return false, err + } + if live != nil && replicas(live.(*appsv1.StatefulSet)) > 0 { + observation.Applied = false + return true, nil + } + } + var platformRuntime *framework.RuntimeDescription + if len(runtime) > 0 { + platformRuntime = runtime[0] + } + _, err := applyScopedObject(ctx, r.Client, cr, object, r.Scheme, slot, resources.RetainedData, + nil, nil, platformRuntime, resources.Coordination) + if err != nil { + observation.Applied = false + observation.Message = err.Error() + if errors.Is(err, errStoragePending) { + return true, nil + } + return false, fmt.Errorf("%s/%s: %w", observation.Role, observation.Name, err) + } + } + return false, nil +} + +func (r *Reconciler[CR, C, S, F]) observeWorkload( + ctx context.Context, cr CR, desired *appsv1.StatefulSet, status *framework.GroupReconcileStatus, +) (bool, error) { + live := &appsv1.StatefulSet{} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(desired), live); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("observe StatefulSet %s: %w", desired.Name, err) + } + if !live.DeletionTimestamp.IsZero() { + return false, nil + } + ownerKind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return false, err + } + if err := checkOwnership(cr, live, ownerKind); err != nil { + return false, fmt.Errorf("observe StatefulSet %s: %w", desired.Name, err) + } + status.ReadyReplicas = live.Status.ReadyReplicas + want := status.DesiredReplicas + if desired.Spec.Replicas != nil { + want = *desired.Spec.Replicas + } + ready := live.Status.ObservedGeneration >= live.Generation && live.Spec.Replicas != nil && + *live.Spec.Replicas == want && live.Status.Replicas == want && + live.Status.ReadyReplicas == want && live.Status.UpdatedReplicas == want && + (want == 0 || (live.Status.CurrentRevision != "" && live.Status.CurrentRevision == live.Status.UpdateRevision)) + if err := r.observeCoordination(ctx, cr, live, want, ready); err != nil { + return false, err + } + return ready, nil +} + +var errSuperseded = errors.New("input generation or identity changed during reconciliation") + +func (r *Reconciler[CR, C, S, F]) writeStatus(ctx context.Context, observed CR, + status framework.ReconcileStatus) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := r.Binding.NewObject() + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(observed), current); err != nil { + return client.IgnoreNotFound(err) + } + if current.GetUID() != observed.GetUID() || current.GetGeneration() != observed.GetGeneration() || + r.Binding.Operation(current) != r.Binding.Operation(observed) { + return errSuperseded + } + if !current.GetDeletionTimestamp().IsZero() { + return nil + } + if apiequality.Semantic.DeepEqual(r.Binding.Status(current), &status) { + return nil + } + status.DeepCopyInto(r.Binding.Status(current)) + return client.IgnoreNotFound(r.Client.Status().Update(ctx, current)) + }) +} + +func setCondition(status *framework.ReconcileStatus, name string, success bool, reason, message string) { + value := metav1.ConditionFalse + if success { + value = metav1.ConditionTrue + } + // Kubernetes condition messages are limited to 32768 bytes; keep aggregated + // errors bounded without losing the individual group diagnostics. + if len(message) > 32000 { + message = string([]rune(message)[:min(len([]rune(message)), 8000)]) + } + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: name, Status: value, + ObservedGeneration: status.ObservedGeneration, Reason: reason, Message: message}) +} + +func choose[T any](condition bool, yes, no T) T { + if condition { + return yes + } + return no +} + +func errorMessage(failures []error) string { + if err := errors.Join(failures...); err != nil { + return err.Error() + } + return "Some resources were not applied" +} + +func recordPlanConditions[C, S, F any](plan pipeline.ResourcePlan[C, S, F], status *framework.ReconcileStatus, + built, applied, ready bool, failures []error, +) { + setCondition(status, "Built", built, choose(built, "ResourcesBuilt", "BuildFailed"), + choose(built, "Resources built; per-group Unknown checks remain diagnostic", "Some resources could not be built")) + setCondition(status, "Applied", applied, choose(applied, "ResourcesApplied", "ApplyIncomplete"), + choose(applied, "All planned resources match the current generation", errorMessage(failures))) + if plan.ClusterError == "" && plan.ClusterOutput.State == framework.ClusterOutputPending { + setCondition(status, "Built", false, "SharedOutputPending", plan.ClusterOutput.Reason) + setCondition(status, "Applied", false, "SharedOutputPending", plan.ClusterOutput.Reason) + } + setCondition(status, "WorkloadsReady", ready && applied, + choose(ready && applied, "StatefulSetsReady", "WaitingForWorkloads"), + choose(ready && applied, "StatefulSet replicas are ready; application health and file reload are not checked", + "The current resource plan is not fully applied or its StatefulSets have not reported updated replicas ready")) + if len(plan.Groups) == 0 && applied { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "WorkloadsReady", + Status: metav1.ConditionUnknown, ObservedGeneration: status.ObservedGeneration, + Reason: "NoWorkloads", Message: "There are no desired workloads to observe"}) + } +} diff --git a/internal/framework/controller/reconciler_test.go b/internal/framework/controller/reconciler_test.go new file mode 100644 index 00000000..13a59c7a --- /dev/null +++ b/internal/framework/controller/reconciler_test.go @@ -0,0 +1,138 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework/dataops" +) + +func controllerScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := dataops.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := generatedtrino.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := appsv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := storagev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := policyv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return scheme +} + +func controllerInput() *generatedtrino.TrinoCluster { + return &generatedtrino.TrinoCluster{ObjectMeta: metav1.ObjectMeta{ + Name: "observed", Namespace: "controller-unit", UID: "original-uid", Generation: 1, + }} +} + +func TestStatusRejectsSupersededInputAfterConflict(t *testing.T) { + for _, change := range []string{"generation", "uid"} { + t.Run(change, func(t *testing.T) { + scheme := controllerScheme(t) + observed := controllerInput() + calls := 0 + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(observed.DeepCopy()). + WithStatusSubresource(observed).WithInterceptorFuncs(interceptor.Funcs{ + SubResourceUpdate: func(ctx context.Context, c client.Client, subresource string, + object client.Object, _ ...client.SubResourceUpdateOption) error { + calls++ + current := &generatedtrino.TrinoCluster{} + if err := c.Get(ctx, client.ObjectKeyFromObject(object), current); err != nil { + return err + } + if change == "generation" { + current.Generation++ + } else { + current.UID = "replacement-uid" + } + if err := c.Update(ctx, current); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: subresource}, + object.GetName(), fmt.Errorf("concurrent input update")) + }, + }).Build() + r := newTestReconciler(c, scheme, testFacts{}) + err := r.writeStatus(t.Context(), observed, framework.ReconcileStatus{ObservedGeneration: 1}) + if !errors.Is(err, errSuperseded) || calls != 1 { + t.Fatalf("stale status write retried: calls=%d err=%v", calls, err) + } + current := &generatedtrino.TrinoCluster{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(observed), current); err != nil { + t.Fatal(err) + } + if current.Status.ObservedGeneration != 0 { + t.Fatalf("stale result was published: %+v", current.Status) + } + }) + } +} + +func TestWorkloadObservationRequiresCurrentOwnedObject(t *testing.T) { + for _, state := range []string{"ready", "foreign", "wrong-kind", "terminating", "unobserved", "old-revision"} { + t.Run(state, func(t *testing.T) { + scheme, cr := controllerScheme(t), controllerInput() + controller, replicas := true, int32(1) + sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "workload", Namespace: cr.Namespace, + Generation: 2, OwnerReferences: []metav1.OwnerReference{{UID: cr.UID, Name: cr.Name, + Kind: "TrinoCluster", APIVersion: generatedtrino.GroupVersion.String(), Controller: &controller}}, + }, Spec: appsv1.StatefulSetSpec{Replicas: &replicas}, Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 2, Replicas: 1, ReadyReplicas: 1, UpdatedReplicas: 1, + CurrentRevision: "rev2", UpdateRevision: "rev2", + }} + switch state { + case "foreign": + sts.OwnerReferences[0].UID = "other-uid" + case "wrong-kind": + sts.OwnerReferences[0].Kind = "WrongKind" + case "terminating": + now := metav1.Now() + sts.DeletionTimestamp, sts.Finalizers = &now, []string{"test.design/hold"} + case "unobserved": + sts.Status.ObservedGeneration = 1 + case "old-revision": + sts.Status.CurrentRevision = "rev1" + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sts).Build() + r := newTestReconciler(c, scheme, testFacts{}) + status := framework.GroupReconcileStatus{DesiredReplicas: 1} + ready, err := r.observeWorkload(t.Context(), cr, sts, &status) + foreign := state == "foreign" || state == "wrong-kind" + if ready != (state == "ready") || (err != nil) != foreign { + t.Fatalf("state=%s: ready=%t err=%v", state, ready, err) + } + if foreign && status.ReadyReplicas != 0 { + t.Fatal("readiness was attributed from a foreign workload") + } + }) + } +} diff --git a/internal/framework/controller/retirement.go b/internal/framework/controller/retirement.go new file mode 100644 index 00000000..9db579ba --- /dev/null +++ b/internal/framework/controller/retirement.go @@ -0,0 +1,478 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + 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/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + kubernetesjson "sigs.k8s.io/json" +) + +// GroupSlotAnnotation is issued by the controller for its four fixed group +// slots. It is execution metadata, never a product merge-policy declaration. +const GroupSlotAnnotation = "framework.kubedoop.dev/group-slot" +const retirementPoll = 2 * time.Second + +const ( + slotConfigmap = "configmap" + slotHeadless = "headless" + slotService = "service" + slotStatefulset = "statefulset" +) + +type groupSlot struct { + Role string `json:"role"` + Group string `json:"group"` + Slot string `json:"slot"` +} + +func (s groupSlot) key() string { return s.Role + "/" + s.Group } +func (s groupSlot) base(owner client.Object) string { + return owner.GetName() + "-" + s.Role + "-" + s.Group +} +func (s groupSlot) object(owner client.Object) client.Object { + name := s.base(owner) + metadata := metav1.ObjectMeta{Name: name, Namespace: owner.GetNamespace()} + switch s.Slot { + case slotStatefulset: + return &appsv1.StatefulSet{ObjectMeta: metadata} + case slotConfigmap: + return &corev1.ConfigMap{ObjectMeta: metadata} + case slotHeadless: + metadata.Name += "-headless" + } + return &corev1.Service{ObjectMeta: metadata} +} + +func decodeSlot(object client.Object) (*groupSlot, error) { + raw, present := object.GetAnnotations()[GroupSlotAnnotation] + if !present { + return nil, nil + } + var slot groupSlot + strict, err := kubernetesjson.UnmarshalStrict([]byte(raw), &slot) + if err != nil || len(strict) != 0 || len(validation.IsDNS1123Label(slot.Role)) != 0 || + len(validation.IsDNS1123Label(slot.Group)) != 0 || + !slices.Contains([]string{slotConfigmap, slotService, slotHeadless, slotStatefulset}, slot.Slot) { + return nil, fmt.Errorf("invalid group slot receipt on %T %s", object, object.GetName()) + } + return &slot, nil +} + +func checkSlot(object client.Object, expected *groupSlot) error { + actual, err := decodeSlot(object) + if err != nil { + return err + } + if (actual == nil) != (expected == nil) || (actual != nil && *actual != *expected) { + return fmt.Errorf("%T %s belongs to a different group slot (actual=%+v expected=%+v)", + object, object.GetName(), actual, expected) + } + return nil +} + +func checkSlotObject(owner, object client.Object, kind schema.GroupVersionKind, slot groupSlot) error { + // Terminating objects remain inventory; checkOwnership additionally rejects + // them for apply, so use a metadata copy for the ownership portion here. + copy := object.DeepCopyObject().(client.Object) + copy.SetDeletionTimestamp(nil) + if err := checkOwnership(owner, copy, kind); err != nil { + return err + } + if err := checkSlot(object, &slot); err != nil { + return err + } + expected := slot.object(owner) + if fmt.Sprintf("%T", object) != fmt.Sprintf("%T", expected) || + client.ObjectKeyFromObject(object) != client.ObjectKeyFromObject(expected) { + return fmt.Errorf("group slot %s/%s has a mismatched kind or resource name", slot.key(), slot.Slot) + } + labels := object.GetLabels() + if labels["app.kubernetes.io/instance"] != owner.GetName() || + labels["app.kubernetes.io/component"] != slot.Role || labels["role-group"] != slot.Group { + return fmt.Errorf("group slot %s/%s has damaged identity labels", slot.key(), slot.Slot) + } + record, err := decodeManagedMetadata(object) + if err != nil { + return err + } + if !slices.Contains(record.Object.Annotations, GroupSlotAnnotation) { + return fmt.Errorf("group slot %s/%s is absent from the managed metadata record", slot.key(), slot.Slot) + } + return nil +} + +func (r *Reconciler[CR, C, S, F]) currentInput(ctx context.Context, observed CR) error { + current := r.Binding.NewObject() + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(observed), current); err != nil { + if apierrors.IsNotFound(err) { + return errSuperseded + } + return err + } + if current.GetUID() != observed.GetUID() || current.GetGeneration() != observed.GetGeneration() || + !current.GetDeletionTimestamp().IsZero() || + r.Binding.Operation(current) != r.Binding.Operation(observed) { + return errSuperseded + } + return nil +} + +// Inventory is reconstructed from live fixed slots, not status or process memory. +// Damaged controlled receipts fail visibly; labels alone never authorize deletion. +func (r *Reconciler[CR, C, S, F]) retirementInventory(ctx context.Context, cr CR) (map[string]groupSlot, []error) { + groups := map[string]groupSlot{} + var failures []error + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return groups, []error{err} + } + lists := []client.ObjectList{&appsv1.StatefulSetList{}, &corev1.ServiceList{}, &corev1.ConfigMapList{}} + for _, list := range lists { + if err := r.Client.List(ctx, list, client.InNamespace(cr.GetNamespace())); err != nil { + failures = append(failures, err) + continue + } + var objects []client.Object + switch items := list.(type) { + case *appsv1.StatefulSetList: + for i := range items.Items { + objects = append(objects, &items.Items[i]) + } + case *corev1.ServiceList: + for i := range items.Items { + objects = append(objects, &items.Items[i]) + } + case *corev1.ConfigMapList: + for i := range items.Items { + objects = append(objects, &items.Items[i]) + } + } + for _, object := range objects { + controller := metav1.GetControllerOf(object) + if controller == nil || controller.UID != cr.GetUID() { + continue + } + if shared, err := sharedInventoryObject(cr, object, kind); shared { + if err != nil { + failures = append(failures, err) + } + continue + } + slot, err := decodeSlot(object) + if err == nil && slot == nil { + record, recordErr := decodeManagedMetadata(object) + _, isSet := object.(*appsv1.StatefulSet) + labels := object.GetLabels() + prefix := cr.GetName() + "-" + labels["app.kubernetes.io/component"] + "-" + labels["role-group"] + looksLikeGroup := labels["role-group"] != "" && labels["app.kubernetes.io/component"] != "" && + strings.HasPrefix(object.GetName(), prefix) + recordedSlot := recordErr == nil && slices.Contains(record.Object.Annotations, GroupSlotAnnotation) + if isSet || looksLikeGroup || recordedSlot { + err = fmt.Errorf("controlled group candidate %T %s is missing its group slot receipt", object, object.GetName()) + } + } + if err == nil && slot != nil { + err = checkSlotObject(cr, object, kind, *slot) + } + if err != nil { + failures = append(failures, err) + continue + } + if slot != nil { + groups[slot.key()] = *slot + } + } + } + return groups, failures +} + +func (r *Reconciler[CR, C, S, F]) retireGroups(ctx context.Context, cr CR, + desired []framework.GroupIdentity, status *framework.ReconcileStatus, +) (bool, []error) { + groups, failures := r.retirementInventory(ctx, cr) + wanted := map[string]bool{} + for _, group := range desired { + wanted[group.Role+"/"+group.Name] = true + } + keys := make([]string, 0, len(groups)) + for key := range groups { + if !wanted[key] { + keys = append(keys, key) + } + } + priorities, priorityErr := r.shutdownPriorities(ctx, cr, groups) + if priorityErr != nil { + return true, append(failures, priorityErr) + } + slices.SortFunc(keys, func(a, b string) int { + if priorities[a] < priorities[b] { + return -1 + } + if priorities[a] > priorities[b] { + return 1 + } + return strings.Compare(a, b) + }) + pending := []string{} + var blockedPriority *int32 + for _, key := range keys { + if blockedPriority != nil && priorities[key] > *blockedPriority { + pending = append(pending, key+": waiting for lower shutdown priorities") + continue + } + phase, err := r.retireGroup(ctx, cr, groups[key]) + if phase != "" || err != nil { + priority := priorities[key] + blockedPriority = &priority + } + if err != nil { + failures = append(failures, fmt.Errorf("retire %s: %w", key, err)) + } + if phase != "" { + pending = append(pending, key+": "+phase) + } + if errors.Is(err, errSuperseded) { + break + } + } + switch { + case len(failures) > 0: + setCondition(status, "Retired", false, "RetirementFailed", errorMessage(failures)) + case len(pending) > 0: + setCondition(status, "Retired", false, "RetirementPending", strings.Join(pending, "; ")) + default: + setCondition(status, "Retired", true, "RetirementComplete", + "No removed group slots remain; desired workload readiness is separate") + } + return len(pending) > 0, failures +} + +func (r *Reconciler[CR, C, S, F]) readSlot(ctx context.Context, cr CR, slot groupSlot) (client.Object, error) { + object := slot.object(cr) + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(object), object); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return nil, err + } + if err := checkSlotObject(cr, object, kind, slot); err != nil { + return nil, err + } + return object, nil +} + +// Pod absence is observed directly, including terminating Pods, old StatefulSet +// UIDs and missing/edited labels. No Pod is force-deleted by this controller. +func (r *Reconciler[CR, C, S, F]) groupPodsRemain(ctx context.Context, cr CR, slot groupSlot) (bool, error) { + var pods corev1.PodList + if err := r.Client.List(ctx, &pods, client.InNamespace(cr.GetNamespace())); err != nil { + return false, err + } + name := slot.base(cr) + for _, pod := range pods.Items { + owner := metav1.GetControllerOf(&pod) + if owner != nil && owner.Kind == statefulSetKind && + owner.APIVersion == appsv1.SchemeGroupVersion.String() && owner.Name == name { + return true, nil + } + // A Pod with the canonical ordinal name is also a conservative stop barrier, + // even if someone removed its controller reference. + if suffix, ok := strings.CutPrefix(pod.Name, name+"-"); ok { + if ordinal, err := strconv.ParseUint(suffix, 10, 32); err == nil && strconv.FormatUint(ordinal, 10) == suffix { + return true, nil + } + } + } + return false, nil +} + +func storageRetirementUnsupported(sts *appsv1.StatefulSet) bool { + platformNames, err := platformClaimNames(sts) + if err != nil { + return true + } + if len(sts.Spec.VolumeClaimTemplates) > 0 { + return true + } + if policy := sts.Spec.PersistentVolumeClaimRetentionPolicy; policy != nil && + (policy.WhenDeleted == appsv1.DeletePersistentVolumeClaimRetentionPolicyType || + policy.WhenScaled == appsv1.DeletePersistentVolumeClaimRetentionPolicyType) { + return true + } + for _, volume := range sts.Spec.Template.Spec.Volumes { + if volume.PersistentVolumeClaim != nil || (volume.Ephemeral != nil && !platformNames[volume.Name]) { + return true + } + } + return false +} + +func (r *Reconciler[CR, C, S, F]) retireGroup(ctx context.Context, cr CR, group groupSlot) (string, error) { + group.Slot = slotStatefulset + object, err := r.readSlot(ctx, cr, group) + if err != nil { + return "", err + } + drained := object + if object != nil { + sts := object.(*appsv1.StatefulSet) + if err := checkRetiringStorage(ctx, r.Client, cr, group, sts); err != nil { + if errors.Is(err, errStoragePending) { + return err.Error(), nil + } + return "", err + } + if err := r.observeCoordination(ctx, cr, sts, 0, false); err != nil { + return "coordination blocked", err + } + if !sts.DeletionTimestamp.IsZero() { + return "waiting for StatefulSet deletion", nil + } + if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 { + return r.stopRetainedGroup(ctx, cr, group, sts) + } + if sts.Status.ObservedGeneration < sts.Generation || sts.Status.Replicas != 0 || sts.Status.ReadyReplicas != 0 || + sts.Status.UpdatedReplicas != 0 { + return "waiting for StatefulSet drain observation", nil + } + } + remain, err := r.groupPodsRemain(ctx, cr, group) + if err != nil || remain { + return "waiting for Pods to disappear", err + } + // Issue at most one delete per pass, and confirm its absence on a later pass. + for _, kind := range []string{slotStatefulset, slotService, slotHeadless, slotConfigmap} { + group.Slot = kind + object, err := r.readSlot(ctx, cr, group) + if err != nil { + return "", err + } + if object == nil { + continue + } + if kind == slotStatefulset && (drained == nil || object.GetUID() != drained.GetUID() || + object.GetResourceVersion() != drained.GetResourceVersion()) { + return "StatefulSet changed; drain must be observed again", nil + } + if !object.GetDeletionTimestamp().IsZero() { + return "waiting for " + kind + " deletion", nil + } + if kind == slotStatefulset { + if err := checkRetiringStorage(ctx, r.Client, cr, group, object.(*appsv1.StatefulSet)); err != nil { + if errors.Is(err, errStoragePending) { + return err.Error(), nil + } + return "", err + } + } + if err := r.currentInput(ctx, cr); err != nil { + return "", err + } + uid, version := object.GetUID(), object.GetResourceVersion() + err = r.Client.Delete(ctx, object, &client.DeleteOptions{Preconditions: &metav1.Preconditions{ + UID: &uid, ResourceVersion: &version}, PropagationPolicy: deletionPropagation()}) + return "deleting " + kind, client.IgnoreNotFound(err) + } + return "", nil +} + +func deletionPropagation() *metav1.DeletionPropagation { + policy := metav1.DeletePropagationBackground + return &policy +} + +// Re-adding a group whose delete was already accepted cannot undo that delete. +// Wait for terminating fixed slots before applying any part of the new plan. +func (r *Reconciler[CR, C, S, F]) groupTerminating( + ctx context.Context, cr CR, identity framework.GroupIdentity, +) (bool, error) { + for _, kind := range []string{slotConfigmap, slotHeadless, slotService, slotStatefulset} { + object, err := r.readSlot(ctx, cr, groupSlot{Role: identity.Role, Group: identity.Name, Slot: kind}) + if err != nil { + return false, err + } + if object != nil && !object.GetDeletionTimestamp().IsZero() { + return true, nil + } + } + return false, nil +} + +func (r *Reconciler[CR, C, S, F]) stopRetainedGroup(ctx context.Context, cr CR, group groupSlot, + sts *appsv1.StatefulSet, +) (string, error) { + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current, err := r.readSlot(ctx, cr, group) + if err != nil || current == nil { + return err + } + live := current.(*appsv1.StatefulSet) + if live.UID != sts.UID { + return fmt.Errorf("StatefulSet identity changed while stopping") + } + if !live.DeletionTimestamp.IsZero() { + return nil + } + if err := checkRetiringStorage(ctx, r.Client, cr, group, live); err != nil { + return err + } + if err := r.currentInput(ctx, cr); err != nil { + return err + } + count, err := nextScaleDown(ctx, r.Client, live, 0) + if err != nil { + return err + } + if err := r.observeCoordination(ctx, cr, live, 0, false); err != nil { + return err + } + if replicas(live) == count { + return nil + } + live.Spec.Replicas = &count + return r.Client.Update(ctx, live) + }) + if errors.Is(err, errStoragePending) { + return err.Error(), nil + } + return "stopping StatefulSet", err +} + +// The apply path requires the same complete receipt as inventory, including a +// damaged slot whose annotation was removed while its managed record survived. +func checkApplyGroupSlot(owner, live client.Object, kind schema.GroupVersionKind, expected *groupSlot) error { + if expected != nil { + return checkSlotObject(owner, live, kind, *expected) + } + if err := checkSlot(live, nil); err != nil { + return err + } + record, err := decodeManagedMetadata(live) + if err != nil { + return err + } + if slices.Contains(record.Object.Annotations, GroupSlotAnnotation) { + return fmt.Errorf("%T %s is missing its managed group receipt", live, live.GetName()) + } + return nil +} diff --git a/internal/framework/controller/retirement_test.go b/internal/framework/controller/retirement_test.go new file mode 100644 index 00000000..c125e25e --- /dev/null +++ b/internal/framework/controller/retirement_test.go @@ -0,0 +1,528 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" +) + +// These are observations supplied to the controller, not simulated evidence of +// a StatefulSet controller, kubelet or garbage collector having run. +func retirementObjects(t *testing.T, cr *generatedtrino.TrinoCluster, + group string, replicas int32, +) []client.Object { + t.Helper() + objects := make([]client.Object, 0, 4) + for _, kind := range []string{"statefulset", "service", "headless", "configmap"} { + slot := groupSlot{Role: "workers", Group: group, Slot: kind} + object := slot.object(cr) + data, err := json.Marshal(slot) + if err != nil { + t.Fatal(err) + } + controller := true + object.SetOwnerReferences([]metav1.OwnerReference{{APIVersion: generatedtrino.GroupVersion.String(), + Kind: "TrinoCluster", Name: cr.Name, UID: cr.UID, Controller: &controller}}) + object.SetUID(types.UID(group + "-" + kind + "-uid")) + object.SetResourceVersion("1") + object.SetGeneration(3) + object.SetLabels(map[string]string{"app.kubernetes.io/instance": cr.Name, + "app.kubernetes.io/component": "workers", "role-group": group}) + object.SetAnnotations(map[string]string{GroupSlotAnnotation: string(data), + ManagedMetadataAnnotation: `{"object":{"labels":["app.kubernetes.io/instance",` + + `"app.kubernetes.io/component","role-group"],"annotations":[` + + `"framework.kubedoop.dev/group-slot"]},"template":{}}`}) + if sts, ok := object.(*appsv1.StatefulSet); ok { + sts.Spec.Replicas = &replicas + sts.Status = appsv1.StatefulSetStatus{ObservedGeneration: 3, Replicas: replicas, + ReadyReplicas: replicas, UpdatedReplicas: replicas} + } + objects = append(objects, object) + } + return objects +} + +func retirementScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := controllerScheme(t) + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return scheme +} + +func retirementClient(scheme *runtime.Scheme, cr *generatedtrino.TrinoCluster, + objects []client.Object, intercept interceptor.Funcs, +) client.Client { + all := append([]client.Object{cr.DeepCopy()}, objects...) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(all...). + WithStatusSubresource(cr, &appsv1.StatefulSet{}).WithInterceptorFuncs(intercept).Build() +} + +func retirementCondition(t *testing.T, status *framework.ReconcileStatus, want metav1.ConditionStatus, reason string) { + t.Helper() + condition := meta.FindStatusCondition(status.Conditions, "Retired") + if condition == nil || condition.Status != want || condition.Reason != reason { + t.Fatalf("unexpected retirement condition: %+v", condition) + } +} + +func TestRetirementDamagedIdentityCannotDisappearAsSuccess(t *testing.T) { + for _, damage := range []string{"missing-receipt", "invalid-receipt", "duplicate-key", "missing-record", + "invalid-record", "receipt-not-recorded", "wrong-label", "wrong-owner-kind", "wrong-name"} { + t.Run(damage, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + object := retirementObjects(t, cr, "removed", 0)[1] + switch damage { + case "missing-receipt": + delete(object.GetAnnotations(), GroupSlotAnnotation) + case "invalid-receipt": + object.GetAnnotations()[GroupSlotAnnotation] = "null" + case "duplicate-key": + object.GetAnnotations()[GroupSlotAnnotation] = + `{"role":"workers","role":"workers","group":"removed","slot":"service"}` + case "missing-record": + delete(object.GetAnnotations(), ManagedMetadataAnnotation) + case "invalid-record": + object.GetAnnotations()[ManagedMetadataAnnotation] = "null" + case "receipt-not-recorded": + object.GetAnnotations()[ManagedMetadataAnnotation] = `{"object":{},"template":{}}` + case "wrong-label": + object.GetLabels()["role-group"] = "other" + case "wrong-owner-kind": + owners := object.GetOwnerReferences() + owners[0].Kind = "WrongKind" + object.SetOwnerReferences(owners) + case "wrong-name": + object.SetName(object.GetName() + "-unexpected") + } + c := retirementClient(scheme, cr, []client.Object{object}, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + _, failures := r.retireGroups(t.Context(), cr, nil, &status) + if len(failures) == 0 { + t.Fatal("damaged controlled object vanished from retirement diagnostics") + } + retirementCondition(t, &status, metav1.ConditionFalse, "RetirementFailed") + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + }) + } +} + +func TestRetirementWaitsForActualPodsRegardlessOfLabelsAndUID(t *testing.T) { + for _, shape := range []string{"no-labels", "old-sts-uid", "terminating", "no-owner-canonical-name"} { + t.Run(shape, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 0) + sts := objects[0].(*appsv1.StatefulSet) + controller := true + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sts.Name + "-0", Namespace: cr.Namespace, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "StatefulSet", + Name: sts.Name, UID: sts.UID, Controller: &controller}}}} + switch shape { + case "old-sts-uid": + pod.Name = "noncanonical-but-owned" + pod.OwnerReferences[0].UID = "an-older-sts-uid" + case "terminating": + now := metav1.Now() + pod.DeletionTimestamp, pod.Finalizers = &now, []string{"test.design/hold"} + case "no-owner-canonical-name": + pod.OwnerReferences = nil + } + c := retirementClient(scheme, cr, append(objects, pod), interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + phase, err := r.retireGroup(t.Context(), cr, groupSlot{Role: "workers", Group: "removed"}) + if err != nil || phase != "waiting for Pods to disappear" { + t.Fatalf("existing pod did not block retirement: phase=%q err=%v", phase, err) + } + for _, object := range objects { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + }) + } +} + +func TestRetirementRequiresCurrentDrainObservation(t *testing.T) { + for _, state := range []string{"old-generation", "replicas", "ready-replicas", "updated-replicas"} { + t.Run(state, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 0) + sts := objects[0].(*appsv1.StatefulSet) + switch state { + case "old-generation": + sts.Status.ObservedGeneration-- + case "replicas": + sts.Status.Replicas = 1 + case "ready-replicas": + sts.Status.ReadyReplicas = 1 + case "updated-replicas": + sts.Status.UpdatedReplicas = 1 + } + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + phase, err := r.retireGroup(t.Context(), cr, groupSlot{Role: "workers", Group: "removed"}) + if err != nil || phase != "waiting for StatefulSet drain observation" { + t.Fatalf("unobserved drain was treated as complete: %q %v", phase, err) + } + applyTestGet(t, c, sts.DeepCopy()) + }) + } +} + +func TestRetirementStorageRequiresPolicyBeforeScaleDown(t *testing.T) { + for _, storage := range []string{ + "claim-templates", "delete-when-scaled", "delete-when-deleted", "pod-pvc", "ephemeral", + } { + t.Run(storage, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 1) + sts := objects[0].(*appsv1.StatefulSet) + switch storage { + case "claim-templates": + sts.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{Name: "data"}}} + case "delete-when-scaled": + sts.Spec.PersistentVolumeClaimRetentionPolicy = &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenScaled: appsv1.DeletePersistentVolumeClaimRetentionPolicyType} + case "delete-when-deleted": + sts.Spec.PersistentVolumeClaimRetentionPolicy = &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.DeletePersistentVolumeClaimRetentionPolicyType} + case "pod-pvc": + sts.Spec.Template.Spec.Volumes = []corev1.Volume{{Name: "data", VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "data"}}}} + case "ephemeral": + sts.Spec.Template.Spec.Volumes = []corev1.Volume{{Name: "data", VolumeSource: corev1.VolumeSource{ + Ephemeral: &corev1.EphemeralVolumeSource{}}}} + } + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + _, err := r.retireGroup(t.Context(), cr, groupSlot{Role: "workers", Group: "removed"}) + if err == nil || !strings.Contains(err.Error(), "separate data policy") { + t.Fatalf("storage retirement was accepted: %v", err) + } + live := sts.DeepCopy() + applyTestGet(t, c, live) + if *live.Spec.Replicas != 1 || live.ResourceVersion != sts.ResourceVersion { + t.Fatal("unsupported storage was modified before policy validation") + } + }) + } +} + +func TestRetirementDeleteFailureRecoversFromLiveSlotsAcrossNewReconcilers(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 0) + failed := false + var deleted []string + c := retirementClient(scheme, cr, objects, interceptor.Funcs{Delete: func(ctx context.Context, c client.WithWatch, + object client.Object, options ...client.DeleteOption) error { + settings := &client.DeleteOptions{} + settings.ApplyOptions(options) + if settings.Preconditions == nil || settings.Preconditions.UID == nil || + settings.Preconditions.ResourceVersion == nil || *settings.Preconditions.UID != object.GetUID() || + *settings.Preconditions.ResourceVersion != object.GetResourceVersion() { + t.Fatal("delete did not fence the exact observed UID and resourceVersion") + } + if !failed { + failed = true + return apierrors.NewForbidden(schema.GroupResource{Group: "apps", Resource: "statefulsets"}, + object.GetName(), errors.New("injected failure")) + } + slot, err := decodeSlot(object) + if err != nil { + return err + } + deleted = append(deleted, slot.Slot) + return c.Delete(ctx, object, options...) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + if _, failures := r.retireGroups(t.Context(), cr, nil, &status); len(failures) == 0 { + t.Fatal("injected delete failure was hidden") + } + retirementCondition(t, &status, metav1.ConditionFalse, "RetirementFailed") + for _, object := range objects { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + for i := 0; i < 5; i++ { + // No previous status or controller object is carried into the next pass. + r = newTestReconciler(c, scheme, testFacts{}) + status = framework.ReconcileStatus{ObservedGeneration: cr.Generation} + if _, failures := r.retireGroups(t.Context(), cr, nil, &status); len(failures) != 0 { + t.Fatalf("restart did not recover: %v", failures) + } + } + if !reflect.DeepEqual(deleted, []string{"statefulset", "service", "headless", "configmap"}) { + t.Fatalf("wrong deletion order or repeated delete: %v", deleted) + } + retirementCondition(t, &status, metav1.ConditionTrue, "RetirementComplete") +} + +func TestRetirementWaitsForFinalizersAndReaddWaitsForEverySlot(t *testing.T) { + for _, kind := range []string{"statefulset", "service", "headless", "configmap"} { + t.Run(kind, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 0) + var remaining []client.Object + seen := false + for _, object := range objects { + slot, _ := decodeSlot(object) + if slot.Slot == kind { + object.SetFinalizers([]string{"test.design/hold"}) + seen = true + } + if seen { + remaining = append(remaining, object) + } + } + c := retirementClient(scheme, cr, remaining, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + group := groupSlot{Role: "workers", Group: "removed"} + if _, err := r.retireGroup(t.Context(), cr, group); err != nil { + t.Fatal(err) + } + phase, err := r.retireGroup(t.Context(), cr, group) + if err != nil || !strings.Contains(phase, "waiting for") || !strings.Contains(phase, "deletion") { + t.Fatalf("accepted Delete was confused with absence: %q %v", phase, err) + } + waiting, err := r.groupTerminating(t.Context(), cr, framework.GroupIdentity{Role: "workers", Name: "removed"}) + if err != nil || !waiting { + t.Fatalf("readd ignored terminating %s: %t %v", kind, waiting, err) + } + for _, object := range remaining { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + }) + } +} + +func TestRetirementConflictRechecksInputAndObjectIdentity(t *testing.T) { + for _, change := range []string{"generation", "cr-uid", "sts-uid", "owner"} { + t.Run(change, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 1) + writes := 0 + c := retirementClient(scheme, cr, objects, interceptor.Funcs{Update: func(ctx context.Context, c client.WithWatch, + object client.Object, options ...client.UpdateOption) error { + writes++ + if change == "generation" || change == "cr-uid" { + current := cr.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(current), current); err != nil { + return err + } + if change == "generation" { + current.Generation++ + } else { + current.UID = "replacement-cr" + } + if err := c.Update(ctx, current); err != nil { + return err + } + } else { + live := &appsv1.StatefulSet{} + if err := c.Get(ctx, client.ObjectKeyFromObject(object), live); err != nil { + return err + } + if change == "sts-uid" { + live.UID = "replacement-workload" + } else { + live.OwnerReferences[0].UID = "another-owner" + } + if err := c.Update(ctx, live, options...); err != nil { + return err + } + } + return apierrors.NewConflict(schema.GroupResource{Group: "apps", Resource: "statefulsets"}, + object.GetName(), errors.New("changed while stopping")) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + _, err := r.retireGroup(t.Context(), cr, groupSlot{Role: "workers", Group: "removed"}) + if err == nil || writes != 1 { + t.Fatalf("conflict retried a stale mutation: writes=%d err=%v", writes, err) + } + if (change == "generation" || change == "cr-uid") && !errors.Is(err, errSuperseded) { + t.Fatalf("input replacement was not recognized: %v", err) + } + live := objects[0].(*appsv1.StatefulSet).DeepCopy() + applyTestGet(t, c, live) + if *live.Spec.Replicas != 1 { + t.Fatal("stale stop was persisted") + } + }) + } +} + +func TestRetirementReobservesWorkloadChangedAfterDrainCheck(t *testing.T) { + for _, change := range []string{"scaled-up", "replacement", "appeared"} { + t.Run(change, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "removed", 0) + sts := objects[0].(*appsv1.StatefulSet).DeepCopy() + seed := objects + if change == "appeared" { + seed = objects[1:] + } + reads, deletes := 0, 0 + c := retirementClient(scheme, cr, seed, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, + object client.Object, opts ...client.GetOption, + ) error { + if _, ok := object.(*appsv1.StatefulSet); ok { + reads++ + if reads == 2 { + live := sts.DeepCopy() + if change == "appeared" { + live.ResourceVersion = "" + if err := c.Create(ctx, live); err != nil { + return err + } + } else { + if err := c.Get(ctx, key, live); err != nil { + return err + } + if change == "scaled-up" { + one := int32(1) + live.Spec.Replicas = &one + } else { + live.UID = "replacement-uid" + } + if err := c.Update(ctx, live); err != nil { + return err + } + } + } + } + return c.Get(ctx, key, object, opts...) + }, + Delete: func(context.Context, client.WithWatch, client.Object, ...client.DeleteOption) error { + deletes++ + return errors.New("changed workload must be observed again") + }, + }) + r := newTestReconciler(c, scheme, testFacts{}) + phase, err := r.retireGroup(t.Context(), cr, groupSlot{Role: "workers", Group: "removed"}) + if err != nil || phase == "" || deletes != 0 { + t.Fatalf("unverified workload reached delete: phase=%q deletes=%d err=%v", phase, deletes, err) + } + }) + } +} + +func TestApplyRefusesSameOwnerDifferentSlotAndReservedUserReceipt(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "a", 0) + headless := objects[2].(*corev1.Service) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + desired := headless.DeepCopy() + desired.Annotations, desired.OwnerReferences = nil, nil + other := groupSlot{Role: "workers", Group: "a-headless", Slot: "service"} + _, err := applyObject(t.Context(), c, cr, desired, scheme, &other, nil) + if err == nil || !strings.Contains(err.Error(), "different group slot") { + t.Fatalf("same-owner resource name collision was adopted: %v", err) + } + cm := objects[3].(*corev1.ConfigMap).DeepCopy() + cm.Annotations, cm.OwnerReferences = nil, nil + _, err = ApplyObject(t.Context(), c, cr, cm, scheme) + if err == nil || !strings.Contains(err.Error(), "different group slot") { + t.Fatalf("cluster output adopted group ConfigMap: %v", err) + } + for _, location := range []string{"object-annotation", "object-label", "pod-annotation", "pod-label"} { + t.Run(location, func(t *testing.T) { + desired := objects[0].(*appsv1.StatefulSet).DeepCopy() + desired.Annotations = nil + switch location { + case "object-annotation": + desired.Annotations = map[string]string{GroupSlotAnnotation: "user"} + case "object-label": + desired.Labels[GroupSlotAnnotation] = "user" + case "pod-annotation": + desired.Spec.Template.Annotations = map[string]string{GroupSlotAnnotation: "user"} + case "pod-label": + desired.Spec.Template.Labels = map[string]string{GroupSlotAnnotation: "user"} + } + _, err := ApplyObject(t.Context(), c, cr, desired, scheme) + if err == nil || !strings.Contains(err.Error(), "reserved key") { + t.Fatalf("user wrote a controller-issued receipt at %s: %v", location, err) + } + }) + } +} + +func TestRetirementReaddingStoppedGroupKeepsStatefulSetIdentity(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "returning", 1) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{}) + r := newTestReconciler(c, scheme, testFacts{}) + group := groupSlot{Role: "workers", Group: "returning", Slot: "statefulset"} + if phase, err := r.retireGroup(t.Context(), cr, group); err != nil || phase != "stopping StatefulSet" { + t.Fatalf("first stop failed: %s %v", phase, err) + } + want := objects[0].(*appsv1.StatefulSet).DeepCopy() + want.Annotations, want.OwnerReferences = nil, nil + if _, err := applyObject(t.Context(), c, cr, want, scheme, &group, nil); err != nil { + t.Fatalf("readding stopped workload failed: %v", err) + } + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + desired := []framework.GroupIdentity{{Role: "workers", Name: "returning"}} + if pending, failures := r.retireGroups(t.Context(), cr, desired, &status); pending || len(failures) != 0 { + t.Fatalf("readded group was still retired: %t %v", pending, failures) + } + live := want.DeepCopy() + applyTestGet(t, c, live) + if live.UID != want.UID || *live.Spec.Replicas != 1 || !live.DeletionTimestamp.IsZero() { + t.Fatalf("readded group lost original workload identity: %+v", live.ObjectMeta) + } + retirementCondition(t, &status, metav1.ConditionTrue, "RetirementComplete") +} + +func TestRetirementFailureIsIsolatedAndForeignSlotsStayUntouched(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + broken := retirementObjects(t, cr, "a-failing", 0) + healthy := retirementObjects(t, cr, "b-retiring", 0) + foreign := retirementObjects(t, cr, "c-foreign", 0) + for _, object := range foreign { + owners := object.GetOwnerReferences() + owners[0].UID = "foreign-cr" + object.SetOwnerReferences(owners) + } + objects := append(append(broken, healthy...), foreign...) + c := retirementClient(scheme, cr, objects, interceptor.Funcs{Delete: func(ctx context.Context, c client.WithWatch, + object client.Object, opts ...client.DeleteOption) error { + if strings.Contains(object.GetName(), "a-failing") { + return fmt.Errorf("injected first group failure") + } + return c.Delete(ctx, object, opts...) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + if _, failures := r.retireGroups(t.Context(), cr, nil, &status); len(failures) != 1 { + t.Fatalf("wrong isolated failure count: %v", failures) + } + err := c.Get(t.Context(), client.ObjectKeyFromObject(healthy[0]), &appsv1.StatefulSet{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("first failure blocked second group retirement: %v", err) + } + for _, object := range append(broken, foreign...) { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + retirementCondition(t, &status, metav1.ConditionFalse, "RetirementFailed") +} diff --git a/internal/framework/controller/role_pdb.go b/internal/framework/controller/role_pdb.go new file mode 100644 index 00000000..377b628b --- /dev/null +++ b/internal/framework/controller/role_pdb.go @@ -0,0 +1,319 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + kubernetesjson "sigs.k8s.io/json" +) + +// RolePDBAnnotation is a controller-issued receipt for the one budget slot of a +// role. Products and input metadata cannot supply this deletion authority. +const RolePDBAnnotation = "framework.kubedoop.dev/role-pdb" +const rolePDBKind = "pdb" + +var errRolePDBTerminating = errors.New("waiting for terminating role PodDisruptionBudget") + +type rolePDBSlot struct { + Role string `json:"role"` + Slot string `json:"slot"` +} + +func (s rolePDBSlot) object(owner client.Object) *policyv1.PodDisruptionBudget { + role := framework.RoleIdentity{ClusterIdentity: framework.ClusterIdentity{ + Name: owner.GetName(), Namespace: owner.GetNamespace()}, Name: s.Role} + return &policyv1.PodDisruptionBudget{ObjectMeta: metav1.ObjectMeta{ + Name: role.PodDisruptionBudgetName(), Namespace: role.Namespace}} +} + +func decodeRolePDB(object client.Object) (*rolePDBSlot, error) { + raw, present := object.GetAnnotations()[RolePDBAnnotation] + if !present { + return nil, nil + } + var slot rolePDBSlot + strict, err := kubernetesjson.UnmarshalStrict([]byte(raw), &slot) + if err != nil || len(strict) != 0 || len(validation.IsDNS1123Label(slot.Role)) != 0 || slot.Slot != rolePDBKind { + return nil, fmt.Errorf("invalid role PDB receipt on %T %s", object, object.GetName()) + } + return &slot, nil +} + +func stampRolePDB(desired, owner client.Object, slot rolePDBSlot) (client.Object, error) { + if _, ok := desired.(*policyv1.PodDisruptionBudget); !ok || + client.ObjectKeyFromObject(desired) != client.ObjectKeyFromObject(slot.object(owner)) || + len(validation.IsDNS1123Label(slot.Role)) != 0 || slot.Slot != rolePDBKind { + return nil, fmt.Errorf("role PDB slot does not match its desired resource") + } + next := desired.DeepCopyObject().(client.Object) + annotations := maps.Clone(next.GetAnnotations()) + if annotations == nil { + annotations = map[string]string{} + } + raw, err := json.Marshal(slot) + if err != nil { + return nil, err + } + annotations[RolePDBAnnotation] = string(raw) + next.SetAnnotations(annotations) + return next, nil +} + +func checkRolePDBObject(owner, object client.Object, kind schema.GroupVersionKind, expected rolePDBSlot) error { + copy := object.DeepCopyObject().(client.Object) + copy.SetDeletionTimestamp(nil) + if err := checkOwnership(owner, copy, kind); err != nil { + return err + } + actual, err := decodeRolePDB(object) + if err != nil { + return err + } + if actual == nil || *actual != expected { + return fmt.Errorf("role PDB %s has a missing or mismatched role receipt", object.GetName()) + } + if _, ok := object.(*policyv1.PodDisruptionBudget); !ok || + client.ObjectKeyFromObject(object) != client.ObjectKeyFromObject(expected.object(owner)) { + return fmt.Errorf("role PDB %s has a mismatched kind, name or namespace", object.GetName()) + } + labels := object.GetLabels() + if labels["app.kubernetes.io/instance"] != owner.GetName() || labels["app.kubernetes.io/component"] != expected.Role { + return fmt.Errorf("role PDB %s has damaged identity labels", object.GetName()) + } + record, err := decodeManagedMetadata(object) + if err != nil { + return err + } + if !slices.Contains(record.Object.Annotations, RolePDBAnnotation) { + return fmt.Errorf("role PDB %s is absent from the managed metadata record", object.GetName()) + } + return nil +} + +func checkApplyRolePDB(owner, live client.Object, kind schema.GroupVersionKind, expected *rolePDBSlot) error { + actual, err := decodeRolePDB(live) + if err != nil { + return err + } + if expected == nil { + record, recordErr := decodeManagedMetadata(live) + candidate := false + if pdb, ok := live.(*policyv1.PodDisruptionBudget); ok { + candidate = looksLikeRolePDB(owner, pdb) + } + if actual != nil || candidate || (recordErr == nil && slices.Contains(record.Object.Annotations, RolePDBAnnotation)) { + return fmt.Errorf("%T %s belongs to a role PDB slot", live, live.GetName()) + } + return nil + } + if err := checkRolePDBObject(owner, live, kind, *expected); err != nil { + return err + } + if !live.GetDeletionTimestamp().IsZero() { + return errRolePDBTerminating + } + return nil +} + +func (r *Reconciler[CR, C, S, F]) readRolePDB(ctx context.Context, cr CR, slot rolePDBSlot, +) (*policyv1.PodDisruptionBudget, error) { + object := slot.object(cr) + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(object), object); err != nil { + return nil, client.IgnoreNotFound(err) + } + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return nil, err + } + if err := checkRolePDBObject(cr, object, kind, slot); err != nil { + return nil, err + } + return object, nil +} + +// Deletion observes the exact current slot again after every conflict. Neither +// an earlier list nor a status entry authorizes deleting a replacement object. +func (r *Reconciler[CR, C, S, F]) deleteRolePDB(ctx context.Context, cr CR, slot rolePDBSlot) (bool, error) { + pending := false + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + if err := r.currentInput(ctx, cr); err != nil { + return err + } + live, err := r.readRolePDB(ctx, cr, slot) + if err != nil || live == nil { + pending = false + return err + } + pending = true + if !live.DeletionTimestamp.IsZero() { + return nil + } + if err := r.currentInput(ctx, cr); err != nil { + return err + } + uid, version := live.UID, live.ResourceVersion + return client.IgnoreNotFound(r.Client.Delete(ctx, live, &client.DeleteOptions{ + Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &version}, + PropagationPolicy: deletionPropagation()})) + }) + return pending, err +} + +// Only controlled, receipted role slots are inventory. A custom PDB with no +// receipt is untouched; damage to a recognizable framework slot remains visible. +func (r *Reconciler[CR, C, S, F]) rolePDBInventory(ctx context.Context, cr CR) ([]rolePDBSlot, []error) { + var list policyv1.PodDisruptionBudgetList + if err := r.Client.List(ctx, &list, client.InNamespace(cr.GetNamespace())); err != nil { + return nil, []error{err} + } + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return nil, []error{err} + } + slices.SortFunc(list.Items, func(a, b policyv1.PodDisruptionBudget) int { return strings.Compare(a.Name, b.Name) }) + var slots []rolePDBSlot + var failures []error + for i := range list.Items { + object := &list.Items[i] + controller := metav1.GetControllerOf(object) + if controller == nil || controller.UID != cr.GetUID() { + continue + } + slot, err := decodeRolePDB(object) + if err == nil && slot == nil && looksLikeRolePDB(cr, object) { + err = fmt.Errorf("controlled role PDB candidate %s is missing its role receipt", object.Name) + } + if err == nil && slot != nil { + err = checkRolePDBObject(cr, object, kind, *slot) + } + if err != nil { + failures = append(failures, err) + } else if slot != nil { + slots = append(slots, *slot) + } + } + slices.SortFunc(slots, func(a, b rolePDBSlot) int { return strings.Compare(a.Role, b.Role) }) + return slots, failures +} + +func looksLikeRolePDB(owner client.Object, object *policyv1.PodDisruptionBudget) bool { + record, err := decodeManagedMetadata(object) + if err == nil && slices.Contains(record.Object.Annotations, RolePDBAnnotation) { + return true + } + role := object.Labels["app.kubernetes.io/component"] + return role != "" && object.Name == (rolePDBSlot{Role: role, Slot: rolePDBKind}).object(owner).Name +} + +func (r *Reconciler[CR, C, S, F]) applyBuiltRole(ctx context.Context, cr CR, role pipeline.BuiltRole, +) (bool, error) { + if role.Error != "" { + return false, errors.New(role.Error) + } + if role.Config == nil { + return false, fmt.Errorf("role did not produce resolved management configuration") + } + slot := rolePDBSlot{Role: role.Role.Name, Slot: rolePDBKind} + if !role.Config.PodDisruptionBudget.Enabled { + return r.deleteRolePDB(ctx, cr, slot) + } + if role.PodDisruptionBudget == nil { + return false, fmt.Errorf("enabled role did not produce a PodDisruptionBudget") + } + if err := r.currentInput(ctx, cr); err != nil { + return false, err + } + _, err := applyScopedObject(ctx, r.Client, cr, role.PodDisruptionBudget, r.Scheme, nil, nil, &slot, nil, nil) + if errors.Is(err, errRolePDBTerminating) { + return true, nil + } + return false, err +} + +func (r *Reconciler[CR, C, S, F]) reconcileRoleResources(ctx context.Context, cr CR, roles []pipeline.BuiltRole, + desired []framework.RoleIdentity, buildErr, inventoryErr error, status *framework.ReconcileStatus, +) (bool, []error) { + var failures []error + pending := false + if buildErr != nil { + failures = append(failures, buildErr) + setCondition(status, "Built", false, "BuildFailed", buildErr.Error()) + } + for _, role := range roles { + waiting, err := r.applyBuiltRole(ctx, cr, role) + pending = pending || waiting + observation := framework.RoleReconcileStatus{Name: role.Role.Name, Applied: err == nil && !waiting} + if err != nil { + observation.Message = err.Error() + failures = append(failures, fmt.Errorf("role %s: %w", role.Role.Name, err)) + } else if waiting { + observation.Message = "Waiting for role PodDisruptionBudget deletion before convergence" + } + status.Roles = append(status.Roles, observation) + if role.Config == nil || role.Error != "" { + setCondition(status, "Built", false, "BuildFailed", "Some role management configurations could not be built") + } + } + if inventoryErr == nil { + waiting, errs := r.retireRolePDBs(ctx, cr, desired, status) + pending = pending || waiting + failures = append(failures, errs...) + } else { + failures = append(failures, inventoryErr) + } + slices.SortFunc(status.Roles, func(a, b framework.RoleReconcileStatus) int { return strings.Compare(a.Name, b.Name) }) + complete := !pending && len(failures) == 0 + setCondition(status, "RoleResourcesApplied", complete, + choose(complete, "RoleResourcesApplied", "RoleResourcesIncomplete"), + choose(complete, "Desired role budgets match and removed role budget slots are absent", + choose(len(failures) != 0, errorMessage(failures), "Waiting for role PodDisruptionBudget deletion"))) + if !complete { + setCondition(status, "Applied", false, "ApplyIncomplete", "Role resources have not converged") + } + return pending, failures +} + +func (r *Reconciler[CR, C, S, F]) retireRolePDBs(ctx context.Context, cr CR, desired []framework.RoleIdentity, + status *framework.ReconcileStatus, +) (bool, []error) { + wanted := make(map[string]bool, len(desired)) + for _, role := range desired { + wanted[role.Name] = true + } + slots, failures := r.rolePDBInventory(ctx, cr) + pending := false + for _, slot := range slots { + if wanted[slot.Role] { + continue + } + waiting, err := r.deleteRolePDB(ctx, cr, slot) + pending = pending || waiting + observation := framework.RoleReconcileStatus{Name: slot.Role, Applied: err == nil && !waiting} + if err != nil { + observation.Message = err.Error() + failures = append(failures, fmt.Errorf("retire role %s: %w", slot.Role, err)) + } else if waiting { + observation.Message = "Waiting for removed role PodDisruptionBudget to disappear" + } + status.Roles = append(status.Roles, observation) + if errors.Is(err, errSuperseded) { + break + } + } + return pending, failures +} diff --git a/internal/framework/controller/role_pdb_test.go b/internal/framework/controller/role_pdb_test.go new file mode 100644 index 00000000..1b81520a --- /dev/null +++ b/internal/framework/controller/role_pdb_test.go @@ -0,0 +1,292 @@ +package controller + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + + policyv1 "k8s.io/api/policy/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" +) + +func roleBudgetFixture(t *testing.T, intercept interceptor.Funcs, +) (*Reconciler[*generatedtrino.TrinoCluster, testConfig, testClusterConfig, testFacts], + *generatedtrino.TrinoCluster, pipeline.BuiltRole, +) { + t.Helper() + cr := controllerInput() + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(cr). + WithStatusSubresource(cr, &policyv1.PodDisruptionBudget{}).WithInterceptorFuncs(intercept).Build() + r := newTestReconciler(c, c.Scheme(), testFacts{}) + source := pipeline.SourceSnapshot[testFacts]{ + Cluster: framework.ClusterIdentity{Name: cr.Name, Namespace: cr.Namespace}, + Roles: []pipeline.RoleSource{{Name: "workers"}}, + Groups: []pipeline.GroupSource{{Role: "workers", Name: "a", Replicas: 2}, + {Role: "workers", Name: "b", Replicas: 3}}, + } + roles, err := pipeline.BuildRoleResources(r.Definition, source) + if err != nil || len(roles) != 1 || roles[0].Error != "" || roles[0].PodDisruptionBudget == nil { + t.Fatalf("build role budget: roles=%+v err=%v", roles, err) + } + return r, cr, roles[0] +} + +func readRoleBudget(t *testing.T, c client.Client, role pipeline.BuiltRole) *policyv1.PodDisruptionBudget { + t.Helper() + live := role.PodDisruptionBudget.DeepCopy() + applyTestGet(t, c, live) + return live +} + +func TestRolePDBApplyPreservesStatusAndNoop(t *testing.T) { + persisted := 0 + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{Update: func(ctx context.Context, c client.WithWatch, + object client.Object, options ...client.UpdateOption) error { + if len((&client.UpdateOptions{}).ApplyOptions(options).DryRun) == 0 { + persisted++ + } + return c.Update(ctx, object, options...) + }}) + if pending, err := r.applyBuiltRole(t.Context(), cr, role); err != nil || pending { + t.Fatalf("create role budget pending=%t err=%v", pending, err) + } + live := readRoleBudget(t, r.Client, role) + if live.Spec.MinAvailable.IntVal != 4 || live.Spec.MaxUnavailable != nil || + len(live.Spec.Selector.MatchLabels) != 2 || live.Spec.Selector.MatchLabels["role-group"] != "" { + t.Fatalf("unexpected cross-group budget: %+v", live.Spec) + } + live.Status.DisruptionsAllowed, live.Status.ObservedGeneration = 2, 7 + if err := r.Client.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + live = readRoleBudget(t, r.Client, role) + live.Annotations["another.controller/keep"] = "external" + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + live = readRoleBudget(t, r.Client, role) + version := live.ResourceVersion + persisted = 0 + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + if got := readRoleBudget(t, r.Client, role); got.ResourceVersion != version || persisted != 0 { + t.Fatal("identical role budget caused a persisted update") + } + minimum := intstr.FromInt32(3) + role.PodDisruptionBudget.Spec.MinAvailable = &minimum + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + got := readRoleBudget(t, r.Client, role) + if persisted != 1 || got.Spec.MinAvailable.IntVal != 3 || got.Status.DisruptionsAllowed != 2 || + got.Status.ObservedGeneration != 7 || got.Annotations["another.controller/keep"] != "external" { + t.Fatalf("role budget apply lost independent state: updates=%d object=%+v", persisted, got) + } +} + +func TestRolePDBConvergesDespiteGroupBuildFailure(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + r.Definition.ValidateInput = func(framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) error { + return errors.New("deliberate product validation failure") + } + _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}) + if err == nil || !strings.Contains(err.Error(), "deliberate product validation failure") { + t.Fatalf("missing group pipeline error: %v", err) + } + live := &policyv1.PodDisruptionBudget{ObjectMeta: metav1.ObjectMeta{ + Name: cr.Name + "-workers-pdb", Namespace: cr.Namespace}} + applyTestGet(t, r.Client, live) + if live.Spec.MinAvailable.IntVal != 1 { + t.Fatalf("failed groups were excluded from desired replicas: %+v", live.Spec) + } + current := cr.DeepCopy() + applyTestGet(t, r.Client, current) + if !meta.IsStatusConditionTrue(current.Status.Conditions, "RoleResourcesApplied") || + meta.IsStatusConditionTrue(current.Status.Conditions, "Applied") { + t.Fatalf("role and workload outcomes were conflated: %+v", current.Status) + } +} + +func TestRolePDBPendingFactsKeepCompleteBudget(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + r.ResolveFacts = func(context.Context, framework.FactsReader, + framework.FactInput[testConfig, testClusterConfig, testFacts], + ) ( + framework.FactResult[testFacts], error) { + return framework.FactResult[testFacts]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsPending, Reason: "Missing", Message: "waiting"}}, nil + } + result, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}) + if err != nil || result.RequeueAfter != retirementPoll { + t.Fatalf("pending facts lost cadence: %v %v", result, err) + } + live := &policyv1.PodDisruptionBudget{ObjectMeta: metav1.ObjectMeta{ + Name: cr.Name + "-workers-pdb", Namespace: cr.Namespace}} + applyTestGet(t, r.Client, live) + if live.Spec.MinAvailable.IntVal != 1 { + t.Fatal("pending facts reduced role budget") + } +} + +func TestRolePDBDisabledWaitsForDeletionAndReadd(t *testing.T) { + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{}) + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + live := readRoleBudget(t, r.Client, role) + live.Finalizers = []string{"fixture.design/hold"} + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + disabled := role + disabled.Config = input.Clone(role.Config) + disabled.Config.PodDisruptionBudget.Enabled = false + disabled.PodDisruptionBudget = nil + if pending, err := r.applyBuiltRole(t.Context(), cr, disabled); err != nil || !pending { + t.Fatalf("disabled budget was not pending deletion: %t %v", pending, err) + } + live = readRoleBudget(t, r.Client, role) + if live.DeletionTimestamp.IsZero() { + t.Fatal("disabled role did not issue deletion") + } + version := live.ResourceVersion + if pending, err := r.applyBuiltRole(t.Context(), cr, role); err != nil || !pending { + t.Fatalf("re-add did not wait for accepted deletion: %t %v", pending, err) + } + if readRoleBudget(t, r.Client, role).ResourceVersion != version { + t.Fatal("re-add rewrote terminating budget") + } +} + +func TestRolePDBRetirementUsesCompleteInventoryAndRetainsInvalidConfig(t *testing.T) { + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{}) + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + invalid := pipeline.BuiltRole{Role: role.Role, Error: "invalid management config"} + status := framework.ReconcileStatus{} + if _, errs := r.reconcileRoleResources(t.Context(), cr, []pipeline.BuiltRole{invalid}, + []framework.RoleIdentity{role.Role}, nil, nil, &status); len(errs) == 0 { + t.Fatal("invalid role config was accepted") + } + readRoleBudget(t, r.Client, role) + status = framework.ReconcileStatus{} + inventoryErr := errors.New("incomplete role inventory") + if _, errs := r.reconcileRoleResources(t.Context(), cr, nil, nil, + inventoryErr, inventoryErr, &status); len(errs) == 0 { + t.Fatal("incomplete inventory was accepted") + } + readRoleBudget(t, r.Client, role) + status = framework.ReconcileStatus{} + pending, errs := r.reconcileRoleResources(t.Context(), cr, nil, nil, nil, nil, &status) + if len(errs) != 0 || !pending { + t.Fatalf("removed role did not retire: %t %v", pending, errs) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(role.PodDisruptionBudget), + &policyv1.PodDisruptionBudget{}); !apierrors.IsNotFound(err) { + t.Fatalf("removed role budget remains: %v", err) + } +} + +func TestRolePDBCannotForgeOrAdoptSlot(t *testing.T) { + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{}) + for _, metadata := range []string{"annotation", "label"} { + desired := role.PodDisruptionBudget.DeepCopy() + if metadata == "annotation" { + desired.Annotations = map[string]string{RolePDBAnnotation: `{"role":"workers","slot":"pdb"}`} + } else { + desired.Labels[RolePDBAnnotation] = "forged" + } + if changed, err := ApplyObject(t.Context(), r.Client, cr, desired, r.Scheme); err == nil || changed { + t.Fatalf("public apply accepted forged %s", metadata) + } + } + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + if changed, err := ApplyObject(t.Context(), r.Client, cr, role.PodDisruptionBudget, r.Scheme); err == nil || changed { + t.Fatal("public apply captured role slot") + } + live := readRoleBudget(t, r.Client, role) + delete(live.Annotations, RolePDBAnnotation) + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if changed, err := ApplyObject(t.Context(), r.Client, cr, role.PodDisruptionBudget, r.Scheme); err == nil || changed { + t.Fatal("public apply captured role slot after its receipt was removed") + } + if _, err := r.applyBuiltRole(t.Context(), cr, role); err == nil { + t.Fatal("role apply adopted damaged slot") + } + status := framework.ReconcileStatus{} + if _, errs := r.retireRolePDBs(t.Context(), cr, nil, &status); len(errs) == 0 { + t.Fatal("damaged removed slot silently disappeared from inventory") + } + readRoleBudget(t, r.Client, role) +} + +func TestRolePDBDeletionConflictRechecksCurrentInput(t *testing.T) { + deletes := 0 + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{Delete: func(ctx context.Context, c client.WithWatch, + object client.Object, options ...client.DeleteOption) error { + deletes++ + preconditions := (&client.DeleteOptions{}).ApplyOptions(options).Preconditions + if preconditions == nil || preconditions.UID == nil || preconditions.ResourceVersion == nil || + *preconditions.UID != object.GetUID() || *preconditions.ResourceVersion != object.GetResourceVersion() { + t.Fatal("role deletion omitted exact UID/RV preconditions") + } + current := controllerInput() + if err := c.Get(ctx, client.ObjectKeyFromObject(current), current); err != nil { + return err + } + current.Generation++ + if err := c.Update(ctx, current); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "poddisruptionbudgets"}, + object.GetName(), errors.New("competing input change")) + }}) + if _, err := r.applyBuiltRole(t.Context(), cr, role); err != nil { + t.Fatal(err) + } + _, err := r.deleteRolePDB(t.Context(), cr, rolePDBSlot{Role: role.Role.Name, Slot: rolePDBKind}) + if !errors.Is(err, errSuperseded) || deletes != 1 { + t.Fatalf("delete retried superseded intent: deletes=%d err=%v", deletes, err) + } + readRoleBudget(t, r.Client, role) +} + +func TestRolePDBCustomResourceIsNotRetirementInventory(t *testing.T) { + r, cr, role := roleBudgetFixture(t, interceptor.Funcs{}) + custom := role.PodDisruptionBudget.DeepCopy() + custom.Name = "custom-budget" + if _, err := ApplyObject(t.Context(), r.Client, cr, custom, r.Scheme); err != nil { + t.Fatal(err) + } + status := framework.ReconcileStatus{} + if pending, errs := r.retireRolePDBs(t.Context(), cr, nil, &status); pending || len(errs) != 0 { + t.Fatalf("custom budget became a role slot: %t %v", pending, errs) + } + applyTestGet(t, r.Client, custom) + record, err := decodeManagedMetadata(custom) + if err != nil || slices.Contains(record.Object.Annotations, RolePDBAnnotation) { + t.Fatalf("custom budget received a role receipt: %+v %v", record, err) + } +} diff --git a/internal/framework/controller/shared_configmap.go b/internal/framework/controller/shared_configmap.go new file mode 100644 index 00000000..f09b398e --- /dev/null +++ b/internal/framework/controller/shared_configmap.go @@ -0,0 +1,256 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// SharedConfigMapAnnotation records one slot in the complete shared output set. +// A matching owner alone does not authorize adoption or withdrawal of custom data. +const SharedConfigMapAnnotation = "framework.kubedoop.dev/shared-configmap" + +var errSharedTerminating = errors.New("waiting for terminating shared ConfigMap") + +type sharedConfigMapSlot struct { + Version int `json:"version"` + CRUID types.UID `json:"crUID"` + Name string `json:"name"` +} + +func sharedSlot(owner client.Object, name string) sharedConfigMapSlot { + return sharedConfigMapSlot{Version: 1, CRUID: owner.GetUID(), Name: name} +} + +func decodeSharedSlot(object client.Object) (*sharedConfigMapSlot, error) { + raw, present := object.GetAnnotations()[SharedConfigMapAnnotation] + if !present { + return nil, nil + } + var slot sharedConfigMapSlot + if err := strictReceipt(raw, &slot); err != nil || slot.Version != 1 || slot.CRUID == "" || + len(validation.IsDNS1123Subdomain(slot.Name)) != 0 { + return nil, fmt.Errorf("invalid shared ConfigMap receipt on %s", object.GetName()) + } + return &slot, nil +} + +func stampSharedSlot(owner, desired client.Object, slot sharedConfigMapSlot) (client.Object, error) { + if _, ok := desired.(*corev1.ConfigMap); !ok || slot != sharedSlot(owner, desired.GetName()) || + len(validation.IsDNS1123Subdomain(slot.Name)) != 0 || desired.GetNamespace() != owner.GetNamespace() { + return nil, fmt.Errorf("shared ConfigMap slot differs from the desired object") + } + next := desired.DeepCopyObject().(client.Object) + annotations := maps.Clone(next.GetAnnotations()) + if annotations == nil { + annotations = map[string]string{} + } + data, err := json.Marshal(slot) + if err != nil { + return nil, err + } + annotations[SharedConfigMapAnnotation] = string(data) + next.SetAnnotations(annotations) + return next, nil +} + +func checkSharedObject(owner, live client.Object, kind schema.GroupVersionKind, expected sharedConfigMapSlot) error { + copy := live.DeepCopyObject().(client.Object) + copy.SetDeletionTimestamp(nil) + if err := checkOwnership(owner, copy, kind); err != nil { + return err + } + actual, err := decodeSharedSlot(live) + if err != nil { + return err + } + if _, ok := live.(*corev1.ConfigMap); !ok || actual == nil || *actual != expected || + expected != sharedSlot(owner, live.GetName()) || live.GetNamespace() != owner.GetNamespace() { + return fmt.Errorf("shared ConfigMap %s has a missing or mismatched source receipt", live.GetName()) + } + if live.GetLabels()["app.kubernetes.io/instance"] != owner.GetName() { + return fmt.Errorf("shared ConfigMap %s has damaged identity labels", live.GetName()) + } + record, err := decodeManagedMetadata(live) + if err != nil { + return err + } + if !slices.Contains(record.Object.Annotations, SharedConfigMapAnnotation) || + !slices.Contains(record.Object.Labels, "app.kubernetes.io/instance") { + return fmt.Errorf("shared ConfigMap %s is absent from managed metadata", live.GetName()) + } + if err := checkApplyGroupSlot(owner, live, kind, nil); err != nil { + return err + } + if _, present := live.GetAnnotations()[RolePDBAnnotation]; present { + return fmt.Errorf("shared ConfigMap %s carries a conflicting role receipt", live.GetName()) + } + return nil +} + +func checkApplyShared(owner, live client.Object, kind schema.GroupVersionKind, expected *sharedConfigMapSlot) error { + actual, err := decodeSharedSlot(live) + if err != nil { + return err + } + if expected == nil { + record, err := decodeManagedMetadata(live) + if err != nil { + return err + } + if actual != nil || slices.Contains(record.Object.Annotations, SharedConfigMapAnnotation) { + return fmt.Errorf("%T %s belongs to a shared ConfigMap slot", live, live.GetName()) + } + return nil + } + if err := checkSharedObject(owner, live, kind, *expected); err != nil { + return err + } + if !live.GetDeletionTimestamp().IsZero() { + return errSharedTerminating + } + return nil +} + +func (r *Reconciler[CR, C, S, F]) sharedInventory(ctx context.Context, cr CR) ([]sharedConfigMapSlot, []error) { + var list corev1.ConfigMapList + if err := r.Client.List(ctx, &list, client.InNamespace(cr.GetNamespace())); err != nil { + return nil, []error{err} + } + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return nil, []error{err} + } + slices.SortFunc(list.Items, func(a, b corev1.ConfigMap) int { return strings.Compare(a.Name, b.Name) }) + var slots []sharedConfigMapSlot + var failures []error + for i := range list.Items { + object := &list.Items[i] + owner := metav1.GetControllerOf(object) + if owner == nil || owner.UID != cr.GetUID() { + continue + } + slot, err := decodeSharedSlot(object) + record, recordErr := decodeManagedMetadata(object) + if err == nil && slot == nil && recordErr == nil && + slices.Contains(record.Object.Annotations, SharedConfigMapAnnotation) { + err = fmt.Errorf("shared ConfigMap %s is missing its source receipt", object.Name) + } + if err == nil && slot != nil { + err = checkSharedObject(cr, object, kind, *slot) + } + if err != nil { + failures = append(failures, err) + } else if slot != nil { + slots = append(slots, *slot) + } + } + return slots, failures +} + +func (r *Reconciler[CR, C, S, F]) deleteShared(ctx context.Context, cr CR, slot sharedConfigMapSlot) (bool, error) { + pending := false + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + if err := r.currentInput(ctx, cr); err != nil { + return err + } + live := &corev1.ConfigMap{} + if err := r.Client.Get(ctx, client.ObjectKey{Namespace: cr.GetNamespace(), Name: slot.Name}, live); err != nil { + pending = false + return client.IgnoreNotFound(err) + } + kind, err := apiutil.GVKForObject(cr, r.Scheme) + if err != nil { + return err + } + if err := checkSharedObject(cr, live, kind, slot); err != nil { + return err + } + pending = true + if !live.DeletionTimestamp.IsZero() { + return nil + } + if err := r.currentInput(ctx, cr); err != nil { + return err + } + uid, version := live.UID, live.ResourceVersion + return client.IgnoreNotFound(r.Client.Delete(ctx, live, &client.DeleteOptions{ + Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &version}, + PropagationPolicy: deletionPropagation(), + })) + }) + return pending, err +} + +// Ready is a complete set, including empty withdrawal. Pending never applies a +// partial set or retires previous output. Live inventory survives status loss and +// controller restarts; every mutation checks fresh intent and exact object source. +func (r *Reconciler[CR, C, S, F]) reconcileShared(ctx context.Context, cr CR, + output framework.ClusterOutput, +) (bool, []error) { + if err := framework.ValidateClusterOutput(output); err != nil { + return false, []error{err} + } + if output.State == framework.ClusterOutputPending { + return true, nil + } + desired := make(map[string]bool, len(output.ConfigMaps)) + var failures []error + pending := false + for i := range output.ConfigMaps { + cm := &output.ConfigMaps[i] + desired[cm.Name] = true + slot := sharedSlot(cr, cm.Name) + if err := r.currentInput(ctx, cr); err != nil { + failures = append(failures, err) + continue + } + _, err := applyScopedObject(ctx, r.Client, cr, cm, r.Scheme, nil, nil, nil, &slot, nil) + if errors.Is(err, errSharedTerminating) { + pending = true + } else if err != nil { + failures = append(failures, err) + } + } + slots, errs := r.sharedInventory(ctx, cr) + failures = append(failures, errs...) + for _, slot := range slots { + if desired[slot.Name] { + continue + } + waiting, err := r.deleteShared(ctx, cr, slot) + pending = pending || waiting + if err != nil { + failures = append(failures, err) + } + } + return pending, failures +} + +// A receipt chooses the validator, not an exemption from source checks. A valid +// shared ConfigMap may carry group-looking user labels and a derived-name prefix; +// only complete shared identity validation excludes it from workload inventory. +func sharedInventoryObject(owner, object client.Object, kind schema.GroupVersionKind) (bool, error) { + slot, err := decodeSharedSlot(object) + if err != nil { + return true, err + } + if slot == nil { + return false, nil + } + return true, checkSharedObject(owner, object, kind, *slot) +} diff --git a/internal/framework/controller/shared_configmap_test.go b/internal/framework/controller/shared_configmap_test.go new file mode 100644 index 00000000..2c015c56 --- /dev/null +++ b/internal/framework/controller/shared_configmap_test.go @@ -0,0 +1,309 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func sharedOutput(cr client.Object, names ...string) framework.ClusterOutput { + output := framework.ClusterOutput{State: framework.ClusterOutputReady} + for _, name := range names { + output.ConfigMaps = append(output.ConfigMaps, corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: cr.GetNamespace(), Labels: map[string]string{"app.kubernetes.io/instance": cr.GetName()}, + }, Data: map[string]string{"value": "original"}}) + } + return output +} +func sharedRead(t *testing.T, c client.Client, cr client.Object, name string) *corev1.ConfigMap { + t.Helper() + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: cr.GetNamespace()}} + applyTestGet(t, c, cm) + return cm +} +func sharedGone(t *testing.T, c client.Client, cr client.Object, name string) { + t.Helper() + err := c.Get(t.Context(), client.ObjectKey{Name: name, Namespace: cr.GetNamespace()}, &corev1.ConfigMap{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected shared %s gone: %v", name, err) + } +} + +func TestSharedCompleteSetWithdrawalSurvivesControllerRestart(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "one", "two")); pending || len(errs) != 0 { + t.Fatalf("create shared set: %t %v", pending, errs) + } + live := sharedRead(t, r.Client, cr, "one") + version := live.ResourceVersion + if live.Annotations[SharedConfigMapAnnotation] == "" || metav1.GetControllerOf(live).UID != cr.UID { + t.Fatal("source/owner receipt missing") + } + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "one", "two")); pending || len(errs) != 0 { + t.Fatalf("no-op shared set: %t %v", pending, errs) + } + if got := sharedRead(t, r.Client, cr, "one"); got.ResourceVersion != version { + t.Fatal("steady shared output rewrote object") + } + r = newTestReconciler(r.Client, r.Scheme, testFacts{}) + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "two")); !pending || len(errs) != 0 { + t.Fatalf("withdrawal must await observed absence: %t %v", pending, errs) + } + sharedGone(t, r.Client, cr, "one") + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)); !pending || len(errs) != 0 { + t.Fatalf("empty Ready did not withdraw remaining output: %t %v", pending, errs) + } + sharedGone(t, r.Client, cr, "two") + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)); pending || len(errs) != 0 { + t.Fatalf("withdrawn set did not settle: %t %v", pending, errs) + } +} + +func TestSharedPendingInvalidAndErrorKeepExistingOutputs(t *testing.T) { + for _, mode := range []string{"pending", "invalid-state", "invalid-partial", "error"} { + t.Run(mode, func(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "old")); len(errs) != 0 { + t.Fatal(errs) + } + before := sharedRead(t, r.Client, cr, "old") + plan := pipeline.ResourcePlan[testConfig, testClusterConfig, testFacts]{ClusterOutput: sharedOutput(cr)} + switch mode { + case "pending": + plan.ClusterOutput = framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "waiting"} + case "invalid-state": + plan.ClusterOutput.State = "" + case "invalid-partial": + plan.ClusterOutput = sharedOutput(cr, "new") + plan.ClusterOutput.State = framework.ClusterOutputPending + plan.ClusterOutput.Reason = "waiting" + case "error": + plan.ClusterError = "product failed" + } + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + waiting, errs := r.applyPlan(t.Context(), cr, plan, &status) + if (mode == "pending") != waiting || (mode != "pending") != (len(errs) > 0) { + t.Fatalf("%t %v", waiting, errs) + } + after := sharedRead(t, r.Client, cr, "old") + if before.ResourceVersion != after.ResourceVersion || before.UID != after.UID { + t.Fatal("unavailable output changed old set") + } + if mode == "pending" && meta.FindStatusCondition(status.Conditions, "Applied").Message != "waiting" { + t.Fatal("pending output reason lost") + } + if meta.IsStatusConditionTrue(status.Conditions, "Applied") { + t.Fatal("pending/error reported applied") + } + sharedGone(t, r.Client, cr, "new") + }) + } +} + +func TestSharedRejectsForeignDamagedAndCrossSlotObjects(t *testing.T) { + for _, damage := range []string{"foreign-owner", "owner-kind", "missing-receipt", "missing-record", "copied-name", + "wrong-cruid", "identity-label", "group-slot", "duplicate-key", "null-receipt"} { + t.Run(damage, func(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "old")); len(errs) != 0 { + t.Fatal(errs) + } + live := sharedRead(t, r.Client, cr, "old") + switch damage { + case "foreign-owner": + live.OwnerReferences[0].UID = "foreign" + case "owner-kind": + live.OwnerReferences[0].Kind = "Other" + case "missing-receipt": + delete(live.Annotations, SharedConfigMapAnnotation) + case "missing-record": + delete(live.Annotations, ManagedMetadataAnnotation) + case "copied-name": + live.Annotations[SharedConfigMapAnnotation] = storageJSON(t, sharedSlot(cr, "different")) + case "wrong-cruid": + slot := sharedSlot(cr, "old") + slot.CRUID = "different" + live.Annotations[SharedConfigMapAnnotation] = storageJSON(t, slot) + case "identity-label": + live.Labels["app.kubernetes.io/instance"] = "other" + case "group-slot": + live.Annotations[GroupSlotAnnotation] = storageJSON(t, groupSlot{Role: "workers", Group: "old", + Slot: slotConfigmap}) + case "duplicate-key": + live.Annotations[SharedConfigMapAnnotation] = `{"version":1,"version":1,"crUID":"original-uid","name":"old"}` + case "null-receipt": + live.Annotations[SharedConfigMapAnnotation] = "null" + } + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + version := sharedRead(t, r.Client, cr, "old").ResourceVersion + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "old", "independent")); len(errs) == 0 { + t.Fatal("damaged shared slot was adopted") + } + sharedRead(t, r.Client, cr, "independent") + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)); len(errs) == 0 && damage != "foreign-owner" { + t.Fatal("damaged own slot was silently accepted") + } + if got := sharedRead(t, r.Client, cr, "old"); got.ResourceVersion != version { + t.Fatal("damaged/foreign source was changed") + } + }) + } +} + +func TestSharedNeverAdoptsOrDeletesCustomOwnerConfigMap(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + custom := sharedOutput(cr, "custom").ConfigMaps[0] + if _, err := ApplyObject(t.Context(), r.Client, cr, &custom, r.Scheme); err != nil { + t.Fatal(err) + } + before := sharedRead(t, r.Client, cr, "custom") + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "custom")); len(errs) == 0 { + t.Fatal("custom CM adopted as shared") + } + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)); pending || len(errs) != 0 { + t.Fatalf("custom was inventory: %t %v", pending, errs) + } + after := sharedRead(t, r.Client, cr, "custom") + if before.ResourceVersion != after.ResourceVersion { + t.Fatal("custom changed") + } + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "owned")); len(errs) != 0 { + t.Fatal(errs) + } + live := sharedRead(t, r.Client, cr, "owned") + delete(live.Annotations, SharedConfigMapAnnotation) + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + desired := sharedOutput(cr, "owned").ConfigMaps[0] + if _, err := ApplyObject(t.Context(), r.Client, cr, &desired, r.Scheme); err == nil { + t.Fatal("unscoped apply overwrote damaged shared slot") + } +} + +func TestSharedTerminatingReaddWaitsAndChecksSource(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "one")); len(errs) != 0 { + t.Fatal(errs) + } + live := sharedRead(t, r.Client, cr, "one") + live.Finalizers = []string{"test.framework/hold"} + if err := r.Client.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)); len(errs) != 0 { + t.Fatal(errs) + } + live = sharedRead(t, r.Client, cr, "one") + if live.DeletionTimestamp.IsZero() { + t.Fatal("test did not start deletion") + } + if pending, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "one")); !pending || len(errs) != 0 { + t.Fatalf("readd stole terminating source: %t %v", pending, errs) + } + if got := sharedRead(t, r.Client, cr, "one"); got.UID != live.UID || got.ResourceVersion != live.ResourceVersion { + t.Fatal("terminating source changed") + } +} + +func TestSharedDeleteRetryRechecksIntentAndSource(t *testing.T) { + for _, change := range []string{"generation", "pause", "replacement"} { + t.Run(change, func(t *testing.T) { + cr, scheme := controllerInput(), controllerScheme(t) + deletes := 0 + c := retirementClient(scheme, cr, nil, interceptor.Funcs{Delete: func(ctx context.Context, c client.WithWatch, + object client.Object, options ...client.DeleteOption) error { + deletes++ + opts := (&client.DeleteOptions{}).ApplyOptions(options) + if opts.Preconditions == nil || opts.Preconditions.UID == nil || opts.Preconditions.ResourceVersion == nil { + t.Fatal("delete lacks exact identity preconditions") + } + if change == "replacement" { + fresh := object.DeepCopyObject().(client.Object) + fresh.SetUID("replacement") + fresh.SetOwnerReferences(nil) + if err := c.Update(ctx, fresh); err != nil { + return err + } + } else { + current := cr.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(cr), current); err != nil { + return err + } + if change == "generation" { + current.Generation++ + } else { + if err := pauseOperationWithoutGenerationChange(ctx, c, cr); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, object.GetName(), + fmt.Errorf("pause race")) + } + if err := c.Update(ctx, current); err != nil { + return err + } + } + return apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, object.GetName(), fmt.Errorf("race")) + }}) + r := newTestReconciler(c, scheme, testFacts{}) + if _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr, "one")); len(errs) != 0 { + t.Fatal(errs) + } + _, errs := r.reconcileShared(t.Context(), cr, sharedOutput(cr)) + if len(errs) == 0 || deletes != 1 { + t.Fatalf("retry bypassed changed authority: calls=%d errs=%v", deletes, errs) + } + if change == "generation" && !errors.Is(errors.Join(errs...), errSuperseded) { + t.Fatal(errs) + } + sharedRead(t, c, cr, "one") + }) + } +} + +func TestSharedGroupLookingMetadataRemainsOutsideWorkloadInventory(t *testing.T) { + r, cr := factsTestReconciler(t, nil) + name := cr.Name + "-workers-default-discovery" + output := sharedOutput(cr, name) + output.ConfigMaps[0].Labels["app.kubernetes.io/component"] = "workers" + output.ConfigMaps[0].Labels["role-group"] = "default" + if _, errs := r.reconcileShared(t.Context(), cr, output); len(errs) != 0 { + t.Fatal(errs) + } + initial := sharedRead(t, r.Client, cr, name) + groups, failures := r.retirementInventory(t.Context(), cr) + if len(failures) != 0 || len(groups) != 0 { + t.Fatalf("validated shared source became a workload candidate: %v %v", groups, failures) + } + if pending, errs := r.stopWorkloads(t.Context(), cr, nil); pending || len(errs) != 0 { + t.Fatalf("shared source blocked stopped observation: %t %v", pending, errs) + } + status := framework.ReconcileStatus{ObservedGeneration: cr.Generation} + if pending, errs := r.retireGroups(t.Context(), cr, nil, &status); pending || len(errs) != 0 { + t.Fatalf("shared source blocked group retirement: %t %v", pending, errs) + } + if !meta.IsStatusConditionTrue(status.Conditions, "Retired") { + t.Fatalf("retirement: %+v", status) + } + after := sharedRead(t, r.Client, cr, name) + if after.UID != initial.UID || after.ResourceVersion != initial.ResourceVersion { + t.Fatal("workload lifecycle mutated a shared ConfigMap") + } + slots, errs := r.sharedInventory(t.Context(), cr) + if len(errs) != 0 || len(slots) != 1 || slots[0].Name != name { + t.Fatalf("shared source disappeared from its own inventory: %v %v", slots, errs) + } +} diff --git a/internal/framework/controller/shared_integration_test.go b/internal/framework/controller/shared_integration_test.go new file mode 100644 index 00000000..26626232 --- /dev/null +++ b/internal/framework/controller/shared_integration_test.go @@ -0,0 +1,176 @@ +package controller + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +// Real API persistence, server defaults, and manager watches are exercised here. +// Envtest has no workload controller or kubelet; this does not claim Pod readiness. +func TestControllerAPIConvergence(t *testing.T) { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + assets = filepath.Join("..", "..", "..", "bin", "k8s", "1.35.0-"+runtime.GOOS+"-"+runtime.GOARCH) + } + if _, err := os.Stat(filepath.Join(assets, "kube-apiserver")); err != nil { + t.Fatalf("envtest assets unavailable: %v", err) + } + existing := false + environment := &envtest.Environment{UseExistingCluster: &existing, BinaryAssetsDirectory: assets, + CRDDirectoryPaths: []string{filepath.Join("..", "pipeline", "testinput", "crd.yaml"), + filepath.Join("..", "..", "..", "config", "framework-data", "bases")}, ErrorIfCRDPathMissing: true} + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + config, err := environment.Start() + if err != nil { + t.Fatal(err) + } + scheme := controllerScheme(t) + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "shared-api-"}} + if err := c.Create(t.Context(), ns); err != nil { + t.Fatal(err) + } + t.Run("retained-source-and-readdition", func(t *testing.T) { integrationRetainedAPI(t, c, scheme) }) + mgr, err := ctrl.NewManager(config, ctrl.Options{Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, + HealthProbeBindAddress: "0"}) + if err != nil { + t.Fatal(err) + } + r := newTestReconciler(c, scheme, testFacts{}) + r.Definition.GenerateCluster = func(in framework.ClusterOutputInput[testClusterConfig, testFacts]) ( + framework.ClusterOutput, error, + ) { + switch in.ClusterConfig.NodeEnvironment { + case "pending": + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "dependency is pending"}, nil + case "error": + return framework.ClusterOutput{}, errors.New("shared generation rejected") + case "empty": + return framework.ClusterOutput{State: framework.ClusterOutputReady}, nil + default: + owner := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: in.Cluster.Name, Namespace: in.Cluster.Namespace}} + return sharedOutput(owner, in.ClusterConfig.NodeEnvironment), nil + } + } + if err := r.SetupWithManager(mgr); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- mgr.Start(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(5 * time.Second): + t.Error("manager did not stop") + } + }) + zero := int32(0) + one := "one" + cr := &generatedtrino.TrinoCluster{ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: ns.Name}, + Spec: generatedtrino.SpecInput{ClusterConfig: &generatedtrino.ClusterConfigInput{NodeEnvironment: &one}, + Workers: &generatedtrino.RoleInput{ + RoleGroups: map[string]generatedtrino.RoleGroupInput{"default": {Replicas: &zero}}}}} + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + waitSharedAPI(t, c, cr, "one", true) + initial := sharedRead(t, c, cr, "one") + current := cr.DeepCopy() + applyTestGet(t, c, current) + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)}); err != nil { + t.Fatal(err) + } + if got := sharedRead(t, c, cr, "one"); got.ResourceVersion != initial.ResourceVersion { + t.Fatal("server-normalized no-op rewrote CM") + } + var sts appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKey{Name: cr.Name + "-workers-default", Namespace: cr.Namespace}, + &sts); err != nil { + t.Fatal(err) + } + if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 { + t.Fatal("generated workload missing") + } + for _, mode := range []string{"pending", "error", "two", "empty"} { + updateOperationAPI(t, c, current, func(latest *generatedtrino.TrinoCluster) { + latest.Spec.ClusterConfig.NodeEnvironment = &mode + }) + applyTestGet(t, c, current) + switch mode { + case "pending", "error": + waitSharedCondition(t, c, current) + if got := sharedRead(t, c, cr, "one"); got.ResourceVersion != initial.ResourceVersion { + t.Fatal("pending/error changed old CM") + } + case "two": + waitSharedAPI(t, c, cr, "two", true) + waitSharedAPI(t, c, cr, "one", false) + default: + waitSharedAPI(t, c, cr, "two", false) + } + } + t.Run("operation-and-retirement", func(t *testing.T) { integrationOperationAndRetirement(t, c, r, cr) }) +} + +func waitSharedAPI(t *testing.T, c client.Client, cr client.Object, name string, present bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + cm := &corev1.ConfigMap{} + err := c.Get(t.Context(), client.ObjectKey{Name: name, Namespace: cr.GetNamespace()}, cm) + if !present && apierrors.IsNotFound(err) { + return + } + if present && err == nil && cm.Annotations[SharedConfigMapAnnotation] != "" { + return + } + if err != nil && !apierrors.IsNotFound(err) { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("shared %s presence did not reach %t", name, present) +} +func waitSharedCondition(t *testing.T, c client.Client, expected *generatedtrino.TrinoCluster) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + current := expected.DeepCopy() + applyTestGet(t, c, current) + cond := meta.FindStatusCondition(current.Status.Conditions, "Applied") + if cond != nil && cond.ObservedGeneration == expected.Generation && cond.Status == metav1.ConditionFalse { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("pending/error was not reported for the current generation") +} diff --git a/internal/framework/controller/stop.go b/internal/framework/controller/stop.go new file mode 100644 index 00000000..0fcb0c4c --- /dev/null +++ b/internal/framework/controller/stop.go @@ -0,0 +1,136 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" +) + +// stopWorkloads operates on authenticated live slots even when product input +// cannot build. Declared identities add absent workloads to Pod observation; +// their replica counts remain unchanged for topology, status and role budgets. +// Unlike retirement, this path never creates or deletes workload resources. +func (r *Reconciler[CR, C, S, F]) stopWorkloads(ctx context.Context, cr CR, + identities []framework.GroupIdentity, +) (bool, []error) { + if cr.GetUID() == "" { + return false, []error{fmt.Errorf("stopping workloads requires an observed CR UID")} + } + if err := r.currentInput(ctx, cr); err != nil { + return false, []error{err} + } + // A surviving authenticated ConfigMap or Service still identifies a group + // whose StatefulSet is absent but whose Pods may not have disappeared. + groups, failures := r.retirementInventory(ctx, cr) + if errors.Is(errors.Join(failures...), errSuperseded) { + return false, failures + } + for _, identity := range identities { + slot := groupSlot{Role: identity.Role, Group: identity.Name, Slot: slotStatefulset} + groups[slot.key()] = slot + } + keys := make([]string, 0, len(groups)) + for key, group := range groups { + // Inventory records whichever fixed slot survived; stopping always reads + // the StatefulSet slot and then observes this group's actual Pods. + group.Slot = slotStatefulset + groups[key] = group + keys = append(keys, key) + } + priorities, err := r.shutdownPriorities(ctx, cr, groups) + if err != nil { + return false, append(failures, err) + } + slices.SortFunc(keys, func(a, b string) int { + if priorities[a] < priorities[b] { + return -1 + } + if priorities[a] > priorities[b] { + return 1 + } + return strings.Compare(a, b) + }) + pending := false + var blockedPriority *int32 + for _, key := range keys { + if blockedPriority != nil && priorities[key] > *blockedPriority { + pending = true + continue + } + waiting, err := r.stopWorkload(ctx, cr, groups[key]) + pending = pending || waiting + if waiting || err != nil { + priority := priorities[key] + blockedPriority = &priority + } + if err != nil { + failures = append(failures, fmt.Errorf("stop %s: %w", key, err)) + } + if errors.Is(err, errSuperseded) { + return pending, failures + } + } + return pending, failures +} + +func (r *Reconciler[CR, C, S, F]) stopWorkload(ctx context.Context, cr CR, group groupSlot) (bool, error) { + object, err := r.readSlot(ctx, cr, group) + if err != nil { + return false, err + } + if object != nil { + set := object.(*appsv1.StatefulSet) + if err := checkRetiringStorage(ctx, r.Client, cr, group, set); err != nil { + if errors.Is(err, errStoragePending) { + return true, nil + } + return false, err + } + if replicas(set) > 0 || set.Status.ObservedGeneration < set.Generation || + set.Status.Replicas != 0 || set.Status.ReadyReplicas != 0 || set.Status.UpdatedReplicas != 0 { + if err := r.observeCoordination(ctx, cr, set, 0, false); err != nil { + return true, err + } + } + if !set.DeletionTimestamp.IsZero() { + return true, nil + } + if set.Spec.Replicas == nil || *set.Spec.Replicas != 0 { + _, err := r.stopRetainedGroup(ctx, cr, group, set) + return true, err + } + if set.Status.ObservedGeneration < set.Generation || set.Status.Replicas != 0 || + set.Status.ReadyReplicas != 0 || set.Status.UpdatedReplicas != 0 { + return true, nil + } + } + remain, err := r.groupPodsRemain(ctx, cr, group) + if err != nil || remain { + if err == nil && object != nil { + err = r.observeCoordination(ctx, cr, object.(*appsv1.StatefulSet), 0, false) + } + return true, err + } + // A replacement or scale change during Pod observation needs a new pass. + // This is an observed stop condition, not a lock against external writers. + fresh, err := r.readSlot(ctx, cr, group) + if err != nil { + return false, err + } + if (object == nil) != (fresh == nil) || (object != nil && fresh != nil && + (object.GetUID() != fresh.GetUID() || object.GetResourceVersion() != fresh.GetResourceVersion())) { + return true, nil + } + if fresh != nil { + if err := r.observeCoordination(ctx, cr, fresh.(*appsv1.StatefulSet), 0, true); err != nil { + return false, err + } + } + return false, nil +} diff --git a/internal/framework/controller/stop_test.go b/internal/framework/controller/stop_test.go new file mode 100644 index 00000000..18ab7c8a --- /dev/null +++ b/internal/framework/controller/stop_test.go @@ -0,0 +1,361 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + 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/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func stopNoCreateOrDelete(t *testing.T) interceptor.Funcs { + t.Helper() + return interceptor.Funcs{ + Create: func(context.Context, client.WithWatch, client.Object, ...client.CreateOption) error { + t.Fatal("stopping must not create resources") + return nil + }, + Delete: func(context.Context, client.WithWatch, client.Object, ...client.DeleteOption) error { + t.Fatal("stopping must not delete resources") + return nil + }, + } +} + +func TestStopWorkloadsUsesLiveInventoryWithoutSourceAndNeverDeletes(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + one, two := retirementObjects(t, cr, "one", 1), retirementObjects(t, cr, "two", 3) + foreign := retirementObjects(t, cr, "foreign", 2)[0].(*appsv1.StatefulSet) + foreign.OwnerReferences[0].UID = "another-cr" + objects := append(append(one, two...), foreign) + c := retirementClient(scheme, cr, objects, stopNoCreateOrDelete(t)) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, nil) + if !pending || len(failures) != 0 { + t.Fatalf("live-only stop failed: pending=%t errors=%v", pending, failures) + } + for _, original := range []*appsv1.StatefulSet{one[0].(*appsv1.StatefulSet), two[0].(*appsv1.StatefulSet)} { + live := original.DeepCopy() + applyTestGet(t, c, live) + if *live.Spec.Replicas != 0 || live.UID != original.UID { + t.Fatalf("live inventory was not stopped in place: %+v", live) + } + live.Status = appsv1.StatefulSetStatus{ObservedGeneration: live.Generation} + if err := c.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + } + identities := []framework.GroupIdentity{{ClusterIdentity: framework.ClusterIdentity{Name: cr.Name, + Namespace: cr.Namespace}, + Role: "workers", Name: "one", Replicas: 7}} + pending, failures = r.stopWorkloads(t.Context(), cr, identities) + if pending || len(failures) != 0 || identities[0].Replicas != 7 { + t.Fatalf("complete stop changed declarations or remains pending: %t %v %+v", pending, failures, identities) + } + for _, object := range objects { + applyTestGet(t, c, object.DeepCopyObject().(client.Object)) + } + applyTestGet(t, c, foreign) + if *foreign.Spec.Replicas != 2 { + t.Fatal("another CR's workload was stopped") + } +} + +func TestStopWorkloadsRequiresZeroObservationAndActualPodAbsence(t *testing.T) { + cases := []string{"complete", "unobserved", "replicas", "ready", "updated", "terminating", + "pod", "old-owner-pod", "missing-sts-pod", "missing-sts"} + for _, state := range cases { + t.Run(state, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + objects := retirementObjects(t, cr, "one", 0) + set := objects[0].(*appsv1.StatefulSet) + switch state { + case "unobserved": + set.Status.ObservedGeneration-- + case "replicas": + set.Status.Replicas = 1 + case "ready": + set.Status.ReadyReplicas = 1 + case "updated": + set.Status.UpdatedReplicas = 1 + case "terminating": + now := metav1.Now() + set.DeletionTimestamp, set.Finalizers = &now, []string{"fixture.design/hold"} + } + if strings.Contains(state, "pod") { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: set.Name + "-0", Namespace: cr.Namespace}} + if state == "old-owner-pod" { + controller, now := true, metav1.Now() + pod.Name = "old-pod-with-edited-name" + pod.DeletionTimestamp, pod.Finalizers = &now, []string{"fixture.design/hold"} + pod.OwnerReferences = []metav1.OwnerReference{{APIVersion: appsv1.SchemeGroupVersion.String(), + Kind: statefulSetKind, Name: set.Name, UID: "old-set-uid", Controller: &controller}} + } + objects = append(objects, pod) + } + if strings.HasPrefix(state, "missing-sts") { + objects = objects[1:] + } + intercept := stopNoCreateOrDelete(t) + intercept.Update = func(context.Context, client.WithWatch, client.Object, ...client.UpdateOption) error { + t.Fatal("an already stopped or absent StatefulSet must not be rewritten") + return nil + } + c := retirementClient(scheme, cr, objects, intercept) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, []framework.GroupIdentity{{Role: "workers", Name: "one"}}) + want := state != "complete" && state != "missing-sts" + if pending != want || len(failures) != 0 { + t.Fatalf("observation %s: pending=%t errors=%v", state, pending, failures) + } + }) + } +} + +func TestStopWorkloadsRejectsDamagedReceiptsAndContinuesOtherGroups(t *testing.T) { + cases := []string{"missing", "duplicate", "wrong-slot", "wrong-label", "missing-record", "wrong-owner-kind"} + for _, damage := range cases { + t.Run(damage, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + bad := retirementObjects(t, cr, "bad", 1)[0].(*appsv1.StatefulSet) + good := retirementObjects(t, cr, "good", 1)[0].(*appsv1.StatefulSet) + switch damage { + case "missing": + delete(bad.Annotations, GroupSlotAnnotation) + case "duplicate": + bad.Annotations[GroupSlotAnnotation] = `{"role":"workers","role":"workers","group":"bad","slot":"statefulset"}` + case "wrong-slot": + bad.Annotations[GroupSlotAnnotation] = storageJSON(t, groupSlot{Role: "workers", Group: "bad", Slot: slotService}) + case "wrong-label": + bad.Labels["role-group"] = "other" + case "missing-record": + delete(bad.Annotations, ManagedMetadataAnnotation) + case "wrong-owner-kind": + bad.OwnerReferences[0].Kind = "AnotherKind" + } + c := retirementClient(scheme, cr, []client.Object{bad, good}, stopNoCreateOrDelete(t)) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, nil) + if !pending || len(failures) == 0 { + t.Fatalf("damaged inventory was hidden or stopped other work: %t %v", pending, failures) + } + applyTestGet(t, c, bad) + applyTestGet(t, c, good) + if *bad.Spec.Replicas != 1 || *good.Spec.Replicas != 0 { + t.Fatal("damaged workload was changed or independent workload was blocked") + } + }) + } +} + +func TestStopWorkloadsRequiresSafeRetainedStorage(t *testing.T) { + for _, damage := range []string{"none", "when-scaled", "when-deleted", "claim-owner", "missing-source"} { + t.Run(damage, func(t *testing.T) { + f := retainedFixture(t) + switch damage { + case "when-scaled": + f.sts.Spec.PersistentVolumeClaimRetentionPolicy.WhenScaled = appsv1.DeletePersistentVolumeClaimRetentionPolicyType + case "when-deleted": + f.sts.Spec.PersistentVolumeClaimRetentionPolicy.WhenDeleted = appsv1.DeletePersistentVolumeClaimRetentionPolicyType + case "claim-owner": + f.claim.OwnerReferences = []metav1.OwnerReference{{UID: f.sts.UID}} + case "missing-source": + delete(f.sts.Spec.VolumeClaimTemplates[0].Annotations, retainedSourceAnnotation) + } + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)). + WithObjects(f.cr, f.class, f.sts, f.claim, f.pv).WithStatusSubresource(&appsv1.StatefulSet{}). + WithInterceptorFuncs(stopNoCreateOrDelete(t)).Build() + r := newTestReconciler(c, c.Scheme(), testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), f.cr, nil) + live := f.sts.DeepCopy() + applyTestGet(t, c, live) + if damage != "none" { + if len(failures) == 0 || *live.Spec.Replicas != 1 { + t.Fatalf("unsafe storage was stopped: %v %+v", failures, live.Spec) + } + return + } + if !pending || len(failures) != 0 || *live.Spec.Replicas != 0 { + t.Fatalf("same-source retained workload failed to stop: %t %v", pending, failures) + } + claim := f.claim.DeepCopy() + applyTestGet(t, c, claim) + if claim.UID != f.claim.UID || claim.Annotations[retainedBindingAnnotation] == "" || + len(claim.OwnerReferences) != 0 { + t.Fatalf("stop lost binding evidence or altered claim identity: %+v", claim) + } + live.Status = appsv1.StatefulSetStatus{ObservedGeneration: live.Generation} + if err := c.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if pending, failures = r.stopWorkloads(t.Context(), f.cr, nil); pending || len(failures) != 0 { + t.Fatalf("retained stop never completed: %t %v", pending, failures) + } + applyTestGet(t, c, f.sts.DeepCopy()) + applyTestGet(t, c, f.pv.DeepCopy()) + }) + } +} + +func TestStopWorkloadsSupersededConflictStopsThePass(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + first := retirementObjects(t, cr, "first", 1)[0].(*appsv1.StatefulSet) + second := retirementObjects(t, cr, "second", 1)[0].(*appsv1.StatefulSet) + writes := 0 + intercept := stopNoCreateOrDelete(t) + intercept.Update = func(ctx context.Context, c client.WithWatch, object client.Object, + _ ...client.UpdateOption) error { + writes++ + current := cr.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(cr), current); err != nil { + return err + } + current.Generation++ + if err := c.Update(ctx, current); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "statefulsets"}, object.GetName(), + errors.New("CR changed during stop")) + } + c := retirementClient(scheme, cr, []client.Object{first, second}, intercept) + r := newTestReconciler(c, scheme, testFacts{}) + _, failures := r.stopWorkloads(t.Context(), cr, nil) + if !errors.Is(errors.Join(failures...), errSuperseded) || writes != 1 { + t.Fatalf("superseded stop continued: writes=%d errors=%v", writes, failures) + } + for _, set := range []*appsv1.StatefulSet{first, second} { + applyTestGet(t, c, set) + if *set.Spec.Replicas != 1 { + t.Fatal("stale stop was persisted") + } + } +} + +func TestStopWorkloadsEmptyInventoryAndFailedInventoryDiffer(t *testing.T) { + for _, failList := range []bool{false, true} { + t.Run(map[bool]string{false: "empty", true: "list-failed"}[failList], func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + intercept := stopNoCreateOrDelete(t) + if failList { + intercept.List = func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error { + return errors.New("inventory unavailable") + } + } + c := retirementClient(scheme, cr, nil, intercept) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, nil) + if pending || (len(failures) != 0) != failList { + t.Fatalf("inventory absence and failure were conflated: %t %v", pending, failures) + } + }) + } +} + +func TestStopWorkloadsReobservesStatefulSetAfterPodList(t *testing.T) { + for _, change := range []string{"scaled-up", "replacement", "appeared"} { + t.Run(change, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + set := retirementObjects(t, cr, "one", 0)[0].(*appsv1.StatefulSet) + var objects []client.Object + if change != "appeared" { + objects = append(objects, set) + } + intercept := stopNoCreateOrDelete(t) + intercept.List = func(ctx context.Context, c client.WithWatch, list client.ObjectList, + options ...client.ListOption) error { + if _, pods := list.(*corev1.PodList); pods { + fresh := set.DeepCopy() + if change == "appeared" { + fresh.ResourceVersion = "" + if err := c.Create(ctx, fresh); err != nil { + return err + } + } else { + if err := c.Get(ctx, client.ObjectKeyFromObject(set), fresh); err != nil { + return err + } + if change == "scaled-up" { + one := int32(1) + fresh.Spec.Replicas = &one + } else { + fresh.UID = "replacement-set" + } + if err := c.Update(ctx, fresh); err != nil { + return err + } + } + } + return c.List(ctx, list, options...) + } + c := retirementClient(scheme, cr, objects, intercept) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, []framework.GroupIdentity{{Role: "workers", Name: "one"}}) + if !pending || len(failures) != 0 { + t.Fatalf("StatefulSet change during Pod observation was missed: pending=%t errors=%v", pending, failures) + } + }) + } +} + +func TestStopWorkloadsInventoryFailureStillStopsDeclaredAuthenticatedTarget(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + set := retirementObjects(t, cr, "one", 1)[0].(*appsv1.StatefulSet) + intercept := stopNoCreateOrDelete(t) + intercept.List = func(ctx context.Context, c client.WithWatch, list client.ObjectList, + options ...client.ListOption) error { + if _, sets := list.(*appsv1.StatefulSetList); sets { + return errors.New("StatefulSet inventory unavailable") + } + return c.List(ctx, list, options...) + } + c := retirementClient(scheme, cr, []client.Object{set}, intercept) + r := newTestReconciler(c, scheme, testFacts{}) + pending, failures := r.stopWorkloads(t.Context(), cr, []framework.GroupIdentity{{Role: "workers", Name: "one"}}) + if !pending || len(failures) != 1 || !strings.Contains(failures[0].Error(), "inventory unavailable") { + t.Fatalf("inventory failure or independent progress was lost: %t %v", pending, failures) + } + applyTestGet(t, c, set) + if *set.Spec.Replicas != 0 { + t.Fatal("an inventory list failure blocked the authenticated declared target") + } +} + +func TestStopWorkloadsObservesPodsFromSurvivingSlotsWithoutStatefulSet(t *testing.T) { + for name, index := range map[string]int{"service": 1, "configmap": 3} { + t.Run(name, func(t *testing.T) { + cr, scheme := controllerInput(), retirementScheme(t) + survivor := retirementObjects(t, cr, "removed", 0)[index] + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: cr.Name + "-workers-removed-0", Namespace: cr.Namespace}} + raw := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cr, survivor, pod).Build() + c := interceptor.NewClient(raw, stopNoCreateOrDelete(t)) + r := newTestReconciler(c, scheme, testFacts{}) + // This group has left the declared identities and its StatefulSet is + // absent. The surviving authenticated slot must retain Pod observation. + pending, failures := r.stopWorkloads(t.Context(), cr, nil) + if !pending || len(failures) != 0 { + t.Fatalf("removed group's residual Pod was not observed: pending=%t errors=%v", pending, failures) + } + // Simulate another actor completing Pod deletion, outside the guarded + // stop client. The stop path itself must never issue a delete. + if err := raw.Delete(t.Context(), pod); err != nil { + t.Fatal(err) + } + pending, failures = r.stopWorkloads(t.Context(), cr, nil) + if pending || len(failures) != 0 { + t.Fatalf("remaining non-workload slot blocked a completed stop: pending=%t errors=%v", pending, failures) + } + applyTestGet(t, c, survivor.DeepCopyObject().(client.Object)) + }) + } +} diff --git a/internal/framework/controller/storage.go b/internal/framework/controller/storage.go new file mode 100644 index 00000000..f6c4c6de --- /dev/null +++ b/internal/framework/controller/storage.go @@ -0,0 +1,591 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/dataops" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + kubernetesjson "sigs.k8s.io/json" +) + +const retainedSourceAnnotation = "framework.kubedoop.dev/retained-data" +const retainedBindingAnnotation = "framework.kubedoop.dev/retained-binding" +const statefulSetKind = "StatefulSet" +const nullReceipt = "null" + +var errStoragePending = errors.New("retained storage observation pending") +var errStorageUnbound = errors.New("retained claims have not yet acquired observed bindings") + +// These are controller receipts, not user merge annotations or GC ownership. +type retainedSource struct { + Version int `json:"version"` + CRUID types.UID `json:"crUID"` + Role string `json:"role"` + Group string `json:"group"` + Slot string `json:"slot"` + StorageClass string `json:"storageClass"` + Capacity string `json:"capacity"` +} + +type retainedBinding struct { + Version int `json:"version"` + PVCUID types.UID `json:"pvcUID"` + PVUID types.UID `json:"pvUID"` + VolumeName string `json:"volumeName"` +} + +func strictReceipt(text string, into any) error { + if text == "" || strings.TrimSpace(text) == nullReceipt { + return fmt.Errorf("missing storage receipt") + } + strict, err := kubernetesjson.UnmarshalStrict([]byte(text), into) + if err != nil || len(strict) > 0 { + return fmt.Errorf("invalid storage receipt: %w", errors.Join(append(strict, err)...)) + } + return nil +} + +func sourceFor(owner client.Object, group groupSlot, data *pipeline.RetainedDataSlot) retainedSource { + return retainedSource{Version: 1, CRUID: owner.GetUID(), Role: group.Role, Group: group.Group, + Slot: data.Name, StorageClass: data.StorageClassName, Capacity: data.Capacity.String()} +} + +func retainedClaimSpec(data *pipeline.RetainedDataSlot) corev1.PersistentVolumeClaimSpec { + mode, class := corev1.PersistentVolumeFilesystem, data.StorageClassName + return corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + StorageClassName: &class, VolumeMode: &mode, + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: data.Capacity.DeepCopy(), + }}} +} + +func validRetainedTemplate(sts *appsv1.StatefulSet, data *pipeline.RetainedDataSlot) error { + platformNames, err := platformClaimNames(sts) + if err != nil { + return err + } + if data == nil { + if storageRetirementUnsupported(sts) { + return fmt.Errorf("spec.volumeClaimTemplates/Pod PVC retirement requires a separate data policy; " + + "no explicit retained slot was declared") + } + return nil + } + if data.Name == "" || data.StorageClassName == "" || data.Capacity.Sign() <= 0 || + len(sts.Spec.VolumeClaimTemplates) != 1 { + return fmt.Errorf("retained storage requires exactly one named, explicitly sized and classified claim template") + } + policy := sts.Spec.PersistentVolumeClaimRetentionPolicy + if policy == nil || policy.WhenScaled != appsv1.RetainPersistentVolumeClaimRetentionPolicyType || + policy.WhenDeleted != appsv1.RetainPersistentVolumeClaimRetentionPolicyType { + return fmt.Errorf("retained storage requires explicit Retain/Retain; existing Delete policy is not migrated") + } + claim := &sts.Spec.VolumeClaimTemplates[0] + if claim.Name != data.Name || len(claim.OwnerReferences) != 0 || + !apiequality.Semantic.DeepEqual(claim.Spec, retainedClaimSpec(data)) { + return fmt.Errorf("retained declaration differs from the final claim template") + } + for _, volume := range sts.Spec.Template.Spec.Volumes { + if volume.PersistentVolumeClaim != nil || (volume.Ephemeral != nil && !platformNames[volume.Name]) { + return fmt.Errorf("additional Pod PVC or ephemeral claims are unsupported") + } + } + return nil +} + +func stampRetainedSource(sts *appsv1.StatefulSet, owner client.Object, group groupSlot, + data *pipeline.RetainedDataSlot, +) (*appsv1.StatefulSet, error) { + if err := validRetainedTemplate(sts, data); err != nil { + return nil, err + } + next := sts.DeepCopy() + if data != nil { + encoded, err := json.Marshal(sourceFor(owner, group, data)) + if err != nil { + return nil, err + } + claim := &next.Spec.VolumeClaimTemplates[0] + if claim.Annotations == nil { + claim.Annotations = map[string]string{} + } + claim.Annotations[retainedSourceAnnotation] = string(encoded) + } + return next, nil +} + +func declaredRetainedSource(sts *appsv1.StatefulSet, owner client.Object, + group groupSlot, +) (*pipeline.RetainedDataSlot, error) { + if err := validatePlatformClaimOwner(sts, owner, group); err != nil { + return nil, err + } + if !storageRetirementUnsupported(sts) { + return nil, nil + } + if len(sts.Spec.VolumeClaimTemplates) != 1 { + return nil, fmt.Errorf("PVC retirement requires a separate data policy") + } + var source retainedSource + if err := strictReceipt(sts.Spec.VolumeClaimTemplates[0].Annotations[retainedSourceAnnotation], &source); err != nil { + return nil, fmt.Errorf("PVC retirement requires a separate data policy: %w", err) + } + capacity, err := resource.ParseQuantity(source.Capacity) + if err != nil { + return nil, fmt.Errorf("invalid retained capacity receipt: %w", err) + } + data := &pipeline.RetainedDataSlot{Name: source.Slot, + RetainedData: pipeline.RetainedData{StorageClassName: source.StorageClass, Capacity: capacity}} + if source != sourceFor(owner, group, data) { + return nil, fmt.Errorf("retained source belongs to another CR or group") + } + if err := validRetainedTemplate(sts, data); err != nil { + return nil, err + } + return data, nil +} + +// All reads are direct. Pending never means that an existing data binding is +// proven; a never-bound WFFC claim can coexist with creating its first Pod. +func checkRetainedStorage(ctx context.Context, c client.Client, owner client.Object, group groupSlot, + desired *appsv1.StatefulSet, data *pipeline.RetainedDataSlot, live *appsv1.StatefulSet, +) error { + if err := validRetainedTemplate(desired, data); err != nil { + return err + } + if live != nil { + actual, err := declaredRetainedSource(live, owner, group) + if err != nil { + return err + } + if !apiequality.Semantic.DeepEqual(actual, data) { + return fmt.Errorf("live retained storage declaration changed; use an explicit DataOperation for migration") + } + } + var claims corev1.PersistentVolumeClaimList + if err := c.List(ctx, &claims, client.InNamespace(owner.GetNamespace())); err != nil { + return err + } + if data == nil { + for _, claim := range claims.Items { + var prior retainedSource + if strictReceipt(claim.Annotations[retainedSourceAnnotation], &prior) == nil && prior.CRUID == owner.GetUID() && + prior.Role == group.Role && prior.Group == group.Group { + return fmt.Errorf("retained claims remain but the group no longer declares their data slot") + } + } + return nil + } + class := &storagev1.StorageClass{} + if err := c.Get(ctx, client.ObjectKey{Name: data.StorageClassName}, class); err != nil { + return err + } + if !class.DeletionTimestamp.IsZero() || class.ReclaimPolicy == nil || + *class.ReclaimPolicy != corev1.PersistentVolumeReclaimRetain { + return fmt.Errorf("StorageClass must explicitly retain volumes") + } + want := sourceFor(owner, group, data) + prefix := data.Name + "-" + desired.Name + "-" + selected, err := retainedClaims(claims.Items, prefix, want, data) + if err != nil { + return err + } + historySource := dataops.Source{Version: want.Version, CRUID: want.CRUID, Role: want.Role, Group: want.Group, + Slot: want.Slot, StorageClass: want.StorageClass, Capacity: want.Capacity} + if err := dataops.CheckHistory(ctx, c, owner.GetNamespace(), historySource, selected); err != nil { + return err + } + if len(selected) > 0 { + if err := checkClaimConsumers(ctx, c, desired, live, selected); err != nil { + return err + } + } + unbound := false + for i := range selected { + if err := observeRetainedBinding(ctx, c, owner, group, live, &selected[i], data); err != nil { + if errors.Is(err, errStorageUnbound) { + unbound = true + } else { + return err + } + } + } + // Missing desired ordinals may be first creation; this is a poll hint, not + // evidence of data absence; the independent ledger was checked above. + if desired.Spec.Replicas != nil { + names := map[string]bool{} + for _, claim := range selected { + names[claim.Name] = true + } + for ordinal := int32(0); ordinal < *desired.Spec.Replicas; ordinal++ { + if !names[prefix+strconv.FormatInt(int64(ordinal), 10)] { + unbound = true + break + } + } + } + if unbound { + return errStorageUnbound + } + return nil +} + +func checkClaimShape(claim *corev1.PersistentVolumeClaim, data *pipeline.RetainedDataSlot) error { + if !claim.DeletionTimestamp.IsZero() { + return fmt.Errorf("%w: PVC %s is deleting", errStoragePending, claim.Name) + } + if claim.UID == "" { + return fmt.Errorf("PVC %s has no observed UID", claim.Name) + } + if len(claim.OwnerReferences) != 0 { + return fmt.Errorf("PVC %s has potentially reclaiming owner references", claim.Name) + } + spec := claim.Spec.DeepCopy() + spec.VolumeName = "" + if spec.VolumeMode == nil { + mode := corev1.PersistentVolumeFilesystem + spec.VolumeMode = &mode + } + if !apiequality.Semantic.DeepEqual(*spec, retainedClaimSpec(data)) { + return fmt.Errorf("PVC %s specification differs from its retained slot", claim.Name) + } + return nil +} + +func checkClaimConsumers(ctx context.Context, c client.Client, desired, live *appsv1.StatefulSet, + claims []corev1.PersistentVolumeClaim, +) error { + names := map[string]bool{} + for _, claim := range claims { + names[claim.Name] = true + } + var pods corev1.PodList + if err := c.List(ctx, &pods, client.InNamespace(desired.Namespace)); err != nil { + return err + } + for _, pod := range pods.Items { + for _, volume := range pod.Spec.Volumes { + if volume.PersistentVolumeClaim == nil || !names[volume.PersistentVolumeClaim.ClaimName] { + continue + } + controller := metav1.GetControllerOf(&pod) + suffix, match := strings.CutPrefix(pod.Name, desired.Name+"-") + ordinal, err := strconv.ParseUint(suffix, 10, 32) + if live != nil && controller != nil && controller.APIVersion == appsv1.SchemeGroupVersion.String() && + controller.Kind == statefulSetKind && + controller.Name == live.Name && controller.UID == live.UID && match && err == nil && + strconv.FormatUint(ordinal, 10) == suffix && + strings.HasSuffix(volume.PersistentVolumeClaim.ClaimName, "-"+pod.Name) { + continue + } + if !pod.DeletionTimestamp.IsZero() { + return fmt.Errorf("%w: old consuming Pod %s is terminating", errStoragePending, pod.Name) + } + return fmt.Errorf("PVC %s is consumed by unrelated Pod %s", volume.PersistentVolumeClaim.ClaimName, pod.Name) + } + } + return nil +} + +func bindingObservation(ctx context.Context, c client.Client, claim *corev1.PersistentVolumeClaim, + data *pipeline.RetainedDataSlot, +) (*retainedBinding, error) { + previous, err := recordedBinding(claim) + if err != nil { + return nil, err + } + if claim.Status.Phase == corev1.ClaimLost { + return nil, fmt.Errorf("PVC %s is Lost", claim.Name) + } + if claim.Spec.VolumeName == "" { + if previous != nil || claim.Status.Phase == corev1.ClaimBound { + return nil, fmt.Errorf("PVC %s lost its volume", claim.Name) + } + return nil, nil + } + pv := &corev1.PersistentVolume{} + if err := c.Get(ctx, client.ObjectKey{Name: claim.Spec.VolumeName}, pv); err != nil { + if apierrors.IsNotFound(err) && previous == nil && claim.Status.Phase != corev1.ClaimBound { + return nil, fmt.Errorf("%w: PV not yet visible", errStoragePending) + } + return nil, fmt.Errorf("recorded PVC volume unavailable: %w", err) + } + if err := checkRetainedPV(pv, data); err != nil { + return nil, err + } + if previous != nil && pv.UID != previous.PVUID { + return nil, fmt.Errorf("PV %s identity changed", pv.Name) + } + ref := pv.Spec.ClaimRef + if ref != nil && ((ref.UID != "" && ref.UID != claim.UID) || (ref.Name != "" && ref.Name != claim.Name) || + (ref.Namespace != "" && ref.Namespace != claim.Namespace)) { + return nil, fmt.Errorf("PV %s is bound to another claim identity", pv.Name) + } + if ref == nil || ref.UID == "" || ref.Name == "" || ref.Namespace == "" || + claim.Status.Phase != corev1.ClaimBound || pv.Status.Phase != corev1.VolumeBound { + if previous != nil { + return nil, fmt.Errorf("PVC %s lost its recorded bidirectional binding", claim.Name) + } + return nil, fmt.Errorf("%w: PVC/PV binding is not yet complete", errStoragePending) + } + return &retainedBinding{Version: 1, PVCUID: claim.UID, PVUID: pv.UID, VolumeName: pv.Name}, nil +} + +func observeRetainedBinding(ctx context.Context, c client.Client, owner client.Object, group groupSlot, + live *appsv1.StatefulSet, observed *corev1.PersistentVolumeClaim, data *pipeline.RetainedDataSlot, +) error { + binding, err := bindingObservation(ctx, c, observed, data) + if err != nil { + return err + } + if binding == nil { + return errStorageUnbound + } + if _, present := observed.Annotations[retainedBindingAnnotation]; present { + return nil + } + if live == nil || !live.DeletionTimestamp.IsZero() { + return fmt.Errorf("bound PVC %s lacks a binding receipt and a live same-source StatefulSet", observed.Name) + } + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, client.ObjectKeyFromObject(observed), current); err != nil { + return err + } + if current.UID != observed.UID { + return fmt.Errorf("PVC identity changed before recording binding") + } + if err := checkClaimShape(current, data); err != nil { + return err + } + var source retainedSource + if err := strictReceipt(current.Annotations[retainedSourceAnnotation], &source); err != nil { + return err + } + if source != sourceFor(owner, group, data) { + return fmt.Errorf("PVC provenance changed before recording binding") + } + fresh := &appsv1.StatefulSet{} + if err := c.Get(ctx, client.ObjectKeyFromObject(live), fresh); err != nil { + return err + } + kind, err := apiutil.GVKForObject(owner, c.Scheme()) + if err != nil { + return err + } + if fresh.UID != live.UID { + return fmt.Errorf("StatefulSet identity changed before recording binding") + } + if !fresh.DeletionTimestamp.IsZero() { + return fmt.Errorf("%w: StatefulSet is deleting before recording binding", errStoragePending) + } + if err := checkSlotObject(owner, fresh, kind, group); err != nil { + return err + } + actualData, err := declaredRetainedSource(fresh, owner, group) + if err != nil { + return err + } + if !apiequality.Semantic.DeepEqual(actualData, data) { + return fmt.Errorf("StatefulSet storage changed before recording binding") + } + if err := checkClaimConsumers(ctx, c, fresh, fresh, []corev1.PersistentVolumeClaim{*current}); err != nil { + return err + } + actual, err := bindingObservation(ctx, c, current, data) + if err != nil { + return err + } + if actual == nil || *actual != *binding { + return fmt.Errorf("binding changed before recording receipt") + } + if _, present := current.Annotations[retainedBindingAnnotation]; present { + return nil + } + currentOwner := owner.DeepCopyObject().(client.Object) + if err := c.Get(ctx, client.ObjectKeyFromObject(owner), currentOwner); err != nil { + return err + } + if currentOwner.GetUID() != owner.GetUID() || currentOwner.GetGeneration() != owner.GetGeneration() || + !currentOwner.GetDeletionTimestamp().IsZero() { + return errSuperseded + } + encoded, err := json.Marshal(actual) + if err != nil { + return err + } + current.Annotations[retainedBindingAnnotation] = string(encoded) + return c.Update(ctx, current) + }) +} + +// The explicit group declaration is private to the typed controller path. +func checkApplyStorage(ctx context.Context, c client.Client, owner, desired, current client.Object, + group *groupSlot, data *pipeline.RetainedDataSlot, +) error { + sts, ok := desired.(*appsv1.StatefulSet) + if !ok { + return nil + } + var live *appsv1.StatefulSet + if current != nil { + live = current.(*appsv1.StatefulSet) + } + if group == nil { + if live != nil { + return validRetainedTemplate(live, nil) + } + return validRetainedTemplate(sts, nil) + } + err := checkRetainedStorage(ctx, c, owner, *group, sts, data, live) + if errors.Is(err, errStorageUnbound) { + return nil + } + return err +} + +func (r *Reconciler[CR, C, S, F]) preflightStorage(ctx context.Context, cr CR, group groupSlot, + desired *appsv1.StatefulSet, data *pipeline.RetainedDataSlot, + runtime ...*framework.RuntimeDescription, +) error { + if len(runtime) > 0 { + prepared, err := stampPlatformClaims(cr, desired, &group, runtime[0]) + if err != nil { + return err + } + desired = prepared.(*appsv1.StatefulSet) + } + object, err := r.readSlot(ctx, cr, group) + if err != nil { + return err + } + var live *appsv1.StatefulSet + if object != nil { + live = object.(*appsv1.StatefulSet) + } + err = checkRetainedStorage(ctx, r.Client, cr, group, desired, data, live) + if data == nil || (err != nil && !errors.Is(err, errStorageUnbound)) { + return err + } + if ledgerErr := recordDataAssets(ctx, r.Client, cr, group, desired, data); ledgerErr != nil { + return ledgerErr + } + return err +} + +func checkRetiringStorage(ctx context.Context, c client.Client, cr client.Object, group groupSlot, + sts *appsv1.StatefulSet, +) error { + data, err := declaredRetainedSource(sts, cr, group) + if err != nil { + return err + } + err = checkRetainedStorage(ctx, c, cr, group, sts, data, sts) + if errors.Is(err, errStorageUnbound) { + return nil + } + return err +} + +func retainedClaims(claims []corev1.PersistentVolumeClaim, prefix string, want retainedSource, + data *pipeline.RetainedDataSlot, +) ([]corev1.PersistentVolumeClaim, error) { + selected := []corev1.PersistentVolumeClaim{} + for _, claim := range claims { + var source retainedSource + sourceErr := strictReceipt(claim.Annotations[retainedSourceAnnotation], &source) + suffix, prefixMatch := strings.CutPrefix(claim.Name, prefix) + ordinal, ordinalErr := strconv.ParseUint(suffix, 10, 32) + canonical := prefixMatch && ordinalErr == nil && strconv.FormatUint(ordinal, 10) == suffix + sameGroup := sourceErr == nil && source.CRUID == want.CRUID && source.Role == want.Role && source.Group == want.Group + if !canonical && !sameGroup { + continue + } + if !canonical || sourceErr != nil || source != want { + return nil, fmt.Errorf("PVC %s has unknown or conflicting retained provenance", claim.Name) + } + if err := checkClaimShape(&claim, data); err != nil { + return nil, err + } + selected = append(selected, claim) + } + return selected, nil +} + +func checkRetainedPV(pv *corev1.PersistentVolume, data *pipeline.RetainedDataSlot) error { + if pv.UID == "" || !apiequality.Semantic.DeepEqual(pv.Spec.AccessModes, + []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}) || !pv.DeletionTimestamp.IsZero() || + len(pv.OwnerReferences) != 0 || pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain || + pv.Spec.StorageClassName != data.StorageClassName || pv.Spec.Capacity.Storage().Cmp(data.Capacity) < 0 || + (pv.Spec.VolumeMode != nil && *pv.Spec.VolumeMode != corev1.PersistentVolumeFilesystem) { + return fmt.Errorf("PV %s no longer satisfies retained storage", pv.Name) + } + return nil +} + +func recordedBinding(claim *corev1.PersistentVolumeClaim) (*retainedBinding, error) { + var previous *retainedBinding + if text, exists := claim.Annotations[retainedBindingAnnotation]; exists { + previous = &retainedBinding{} + if err := strictReceipt(text, previous); err != nil { + return nil, err + } + if previous.Version != 1 || previous.PVCUID != claim.UID || previous.PVUID == "" || + previous.VolumeName == "" || previous.VolumeName != claim.Spec.VolumeName { + return nil, fmt.Errorf("PVC %s lost or changed its recorded binding identity", claim.Name) + } + } + return previous, nil +} + +// Recording new independent identities belongs to normal apply preparation. Stop +// and retirement invoke the shared read guard without creating ledger resources. +func recordDataAssets(ctx context.Context, c client.Client, owner client.Object, group groupSlot, + desired *appsv1.StatefulSet, data *pipeline.RetainedDataSlot, +) error { + var claims corev1.PersistentVolumeClaimList + if err := c.List(ctx, &claims, client.InNamespace(owner.GetNamespace())); err != nil { + return err + } + selected, err := retainedClaims(claims.Items, data.Name+"-"+desired.Name+"-", sourceFor(owner, group, data), data) + if err != nil { + return err + } + for i := range selected { + claim := &selected[i] + if _, recorded := claim.Annotations[retainedBindingAnnotation]; !recorded { + continue + } + if _, err := bindingObservation(ctx, c, claim, data); err != nil { + return err + } + gvk, err := apiutil.GVKForObject(owner, c.Scheme()) + if err != nil { + return err + } + sourceCluster := dataops.ClusterRef{APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, + Name: owner.GetName(), UID: owner.GetUID()} + if err := dataops.EnsureAsset(ctx, c, claim, sourceCluster); err != nil { + return err + } + } + return nil +} diff --git a/internal/framework/controller/storage_integration_test.go b/internal/framework/controller/storage_integration_test.go new file mode 100644 index 00000000..a4129925 --- /dev/null +++ b/internal/framework/controller/storage_integration_test.go @@ -0,0 +1,149 @@ +package controller + +import ( + "reflect" + "testing" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// The API server persists the source/binding records and enforces VCT defaults. +// PV/PVC Bound status below is an explicit fixture observation: envtest runs no +// binder/provisioner and proves neither a mount nor retained filesystem contents. +func integrationRetainedAPI(t *testing.T, c client.Client, scheme *runtime.Scheme) { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "retained-api-"}} + if err := c.Create(t.Context(), ns); err != nil { + t.Fatal(err) + } + retain, wait := corev1.PersistentVolumeReclaimRetain, storagev1.VolumeBindingWaitForFirstConsumer + class := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: ns.Name}, Provisioner: "example.invalid/fixture", + ReclaimPolicy: &retain, VolumeBindingMode: &wait} + if err := c.Create(t.Context(), class); err != nil { + t.Fatal(err) + } + one := int32(1) + kind := "persistent" + lower, upper := resource.MustParse("32Mi"), resource.MustParse("64Mi") + cr := &generatedtrino.TrinoCluster{ObjectMeta: metav1.ObjectMeta{Name: "retained", Namespace: ns.Name}, + Spec: generatedtrino.SpecInput{Workers: &generatedtrino.RoleInput{ + Config: &generatedtrino.ConfigInput{Resources: &generatedtrino.ConfigInputResources{ + Storage: &generatedtrino.ConfigInputResourcesStorage{Type: &kind, + StorageClassName: &class.Name, Capacity: &lower}}}, + RoleGroups: map[string]generatedtrino.RoleGroupInput{"default": {Replicas: &one, + Config: &generatedtrino.ConfigInput{Resources: &generatedtrino.ConfigInputResources{ + Storage: &generatedtrino.ConfigInputResourcesStorage{Capacity: &upper}}}}}}}} + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + r := newTestReconciler(c, scheme, testFacts{}) + generate := r.Definition.GenerateGroup + r.Definition.GenerateGroup = func(in framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + result, err := generate(in) + result.Directories = []framework.Directory{{Name: "data", Data: true}} + result.Main.Access = []framework.DirectoryAccess{{Directory: "data", MountPath: "/data"}} + return result, err + } + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cr)} + reconcile := func() { + t.Helper() + if _, err := r.Reconcile(t.Context(), request); err != nil { + t.Fatal(err) + } + } + reconcile() + sts := workloadAPI(t, c, cr, 1) + if len(sts.Spec.VolumeClaimTemplates) != 1 { + t.Fatal("missing retained template") + } + var source retainedSource + if err := strictReceipt(sts.Spec.VolumeClaimTemplates[0].Annotations[retainedSourceAnnotation], &source); err != nil { + t.Fatal(err) + } + if source.CRUID != cr.UID || source.Capacity != "64Mi" || source.Slot != "data" { + t.Fatalf("wrong persisted source: %+v", source) + } + version := sts.ResourceVersion + reconcile() + applyTestGet(t, c, sts) + if sts.ResourceVersion != version { + t.Fatal("API-defaulted retained template was rewritten") + } + template := sts.Spec.VolumeClaimTemplates[0].DeepCopy() + claim := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data-" + sts.Name + "-0", + Namespace: cr.Namespace, + Annotations: template.Annotations}, Spec: template.Spec} + claim.Spec.VolumeName = "pv-" + ns.Name + if err := c.Create(t.Context(), claim); err != nil { + t.Fatal(err) + } + mode := corev1.PersistentVolumeFilesystem + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: claim.Spec.VolumeName}, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("64Mi")}, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: &mode, StorageClassName: class.Name, + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, + ClaimRef: &corev1.ObjectReference{Namespace: claim.Namespace, Name: claim.Name, UID: claim.UID}, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + HostPath: &corev1.HostPathVolumeSource{Path: "/api-test-only"}, + }, + }} + if err := c.Create(t.Context(), pv); err != nil { + t.Fatal(err) + } + pv.Status.Phase = corev1.VolumeBound + if err := c.Status().Update(t.Context(), pv); err != nil { + t.Fatal(err) + } + claim.Status.Phase = corev1.ClaimBound + if err := c.Status().Update(t.Context(), claim); err != nil { + t.Fatal(err) + } + reconcile() + applyTestGet(t, c, claim) + receipt, err := recordedBinding(claim) + if err != nil || receipt == nil { + t.Fatalf("binding was not observed: %v", err) + } + if receipt.PVCUID != claim.UID || receipt.PVUID != pv.UID || receipt.VolumeName != pv.Name { + t.Fatalf("wrong binding identities: %+v", receipt) + } + oldClaim, oldPV, oldSTS := claim.DeepCopy(), pv.DeepCopy(), sts.UID + groupInput := cr.DeepCopy().Spec.Workers.RoleGroups["default"] + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { current.Spec.Workers.RoleGroups = nil }) + reconcile() + sts = workloadAPI(t, c, cr, 0) + observeZeroAPI(t, c, sts) + for range 5 { + reconcile() + } + applyTestGet(t, c, claim) + applyTestGet(t, c, pv) + if claim.UID != oldClaim.UID || pv.UID != oldPV.UID || !reflect.DeepEqual(claim.Spec, oldClaim.Spec) || + !claim.DeletionTimestamp.IsZero() || !pv.DeletionTimestamp.IsZero() { + t.Fatal("retirement changed retained storage") + } + updateOperationAPI(t, c, cr, func(current *generatedtrino.TrinoCluster) { + current.Spec.Workers.RoleGroups = map[string]generatedtrino.RoleGroupInput{"default": groupInput} + }) + reconcile() + replacement := workloadAPI(t, c, cr, 1) + applyTestGet(t, c, claim) + applyTestGet(t, c, pv) + if replacement.UID == oldSTS || claim.UID != oldClaim.UID || pv.UID != oldPV.UID { + t.Fatal("same-source readd failed") + } + if err := c.Delete(t.Context(), cr); err != nil { + t.Fatal(err) + } +} diff --git a/internal/framework/controller/storage_test.go b/internal/framework/controller/storage_test.go new file mode 100644 index 00000000..bdede032 --- /dev/null +++ b/internal/framework/controller/storage_test.go @@ -0,0 +1,469 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + generatedtrino "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" +) + +type storageFixture struct { + cr *generatedtrino.TrinoCluster + group groupSlot + data *pipeline.RetainedDataSlot + sts *appsv1.StatefulSet + claim *corev1.PersistentVolumeClaim + pv *corev1.PersistentVolume + class *storagev1.StorageClass +} + +func storageJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(encoded) +} + +func retainedFixture(t *testing.T) storageFixture { + t.Helper() + f := storageFixture{cr: controllerInput(), group: groupSlot{Role: "workers", Group: "default", Slot: slotStatefulset}, + data: &pipeline.RetainedDataSlot{Name: "data", + RetainedData: pipeline.RetainedData{StorageClassName: "retain", Capacity: resource.MustParse("64Mi")}}} + f.sts = retirementObjects(t, f.cr, "default", 1)[0].(*appsv1.StatefulSet) + f.sts.Spec.PersistentVolumeClaimRetentionPolicy = &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + WhenDeleted: appsv1.RetainPersistentVolumeClaimRetentionPolicyType} + f.sts.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: f.data.Name}, Spec: retainedClaimSpec(f.data)}} + var err error + f.sts, err = stampRetainedSource(f.sts, f.cr, f.group, f.data) + if err != nil { + t.Fatal(err) + } + f.claim = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{ + Name: "data-" + f.sts.Name + "-0", Namespace: f.cr.Namespace, UID: "claim-uid", + Annotations: map[string]string{retainedSourceAnnotation: storageJSON(t, sourceFor(f.cr, f.group, f.data))}}, + Spec: retainedClaimSpec(f.data)} + f.claim.Spec.VolumeName = "data-pv" + f.claim.Status.Phase = corev1.ClaimBound + mode := corev1.PersistentVolumeFilesystem + f.pv = &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: "data-pv", UID: "pv-uid"}, + Spec: corev1.PersistentVolumeSpec{Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("64Mi")}, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: &mode, StorageClassName: "retain", + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, + ClaimRef: &corev1.ObjectReference{ + Namespace: f.claim.Namespace, Name: f.claim.Name, UID: f.claim.UID}}, + Status: corev1.PersistentVolumeStatus{Phase: corev1.VolumeBound}} + retain, wait := corev1.PersistentVolumeReclaimRetain, storagev1.VolumeBindingWaitForFirstConsumer + f.class = &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "retain"}, + ReclaimPolicy: &retain, VolumeBindingMode: &wait} + return f +} + +func (f storageFixture) receipt(t *testing.T) { + f.claim.Annotations[retainedBindingAnnotation] = storageJSON(t, retainedBinding{ + Version: 1, PVCUID: f.claim.UID, PVUID: f.pv.UID, VolumeName: f.pv.Name}) +} + +func (f storageFixture) desired() *appsv1.StatefulSet { + sts := f.sts.DeepCopy() + sts.Annotations, sts.OwnerReferences = nil, nil + sts.Spec.VolumeClaimTemplates[0].Annotations = nil + return sts +} + +func TestRetainedFirstConsumerAndHistoricalNeverBoundDoNotBlock(t *testing.T) { + for _, existing := range []bool{false, true} { + t.Run(map[bool]string{false: "first-creation", true: "never-bound-high-ordinal"}[existing], func(t *testing.T) { + f := retainedFixture(t) + objects := []client.Object{f.cr, f.class} + if existing { + f.claim.Name = "data-" + f.sts.Name + "-17" + f.claim.Spec.VolumeName, f.claim.Status.Phase = "", corev1.ClaimPending + objects = append(objects, f.claim) + } + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(objects...).Build() + err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, nil) + if !errors.Is(err, errStorageUnbound) { + t.Fatalf("missing binding must only request observation: %v", err) + } + changed, err := applyObject(t.Context(), c, f.cr, f.desired(), c.Scheme(), &f.group, f.data) + if err != nil || !changed { + t.Fatalf("WFFC was prevented from creating its consumer: %v %v", changed, err) + } + live := &appsv1.StatefulSet{} + applyTestGet(t, c, liveWithKey(live, f.sts)) + if live.Spec.VolumeClaimTemplates[0].Annotations[retainedSourceAnnotation] == "" { + t.Fatal("missing stamped source") + } + // API-assigned identity is supplied because fake Create does not allocate it. + live.UID = "created-sts" + if err := c.Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if err := checkRetiringStorage(t.Context(), c, f.cr, f.group, live); err != nil { + t.Fatalf("unbound claim prevented safe retain: %v", err) + } + }) + } +} + +func liveWithKey(sts, source *appsv1.StatefulSet) *appsv1.StatefulSet { + sts.Name, sts.Namespace = source.Name, source.Namespace + return sts +} + +func TestRetainedBindingReceiptAndSameSourceReaddition(t *testing.T) { + f := retainedFixture(t) + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class, f.sts, f.claim, f.pv).Build() + if err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, f.sts); err != nil { + t.Fatal(err) + } + observed := &corev1.PersistentVolumeClaim{} + observed.Name, observed.Namespace = f.claim.Name, f.claim.Namespace + applyTestGet(t, c, observed) + var binding retainedBinding + if err := strictReceipt(observed.Annotations[retainedBindingAnnotation], &binding); err != nil { + t.Fatal(err) + } + if binding.PVCUID != f.claim.UID || binding.PVUID != f.pv.UID || binding.VolumeName != f.pv.Name || + len(observed.OwnerReferences) != 0 { + t.Fatalf("incorrect binding receipt: %+v", binding) + } + version := observed.ResourceVersion + if err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, f.sts); err != nil { + t.Fatal(err) + } + applyTestGet(t, c, observed) + if observed.ResourceVersion != version { + t.Fatal("unchanged receipt was rewritten") + } + if err := c.Delete(t.Context(), f.sts); err != nil { + t.Fatal(err) + } + changed, err := applyObject(t.Context(), c, f.cr, f.desired(), c.Scheme(), &f.group, f.data) + if err != nil || !changed { + t.Fatalf("retained readdition: %v %v", changed, err) + } +} + +func TestRetainedRecoveryRejectsConflictingSurvivingClaims(t *testing.T) { + cases := map[string]func(*storageFixture){ + "new-cr-uid": func(f *storageFixture) { f.cr.UID = "new-cr" }, + "missing-provenance": func(f *storageFixture) { delete(f.claim.Annotations, retainedSourceAnnotation) }, + "missing-receipt": func(f *storageFixture) { delete(f.claim.Annotations, retainedBindingAnnotation) }, + "new-pvc-uid": func(f *storageFixture) { f.claim.UID = "replacement-pvc" }, + "new-pv-uid": func(f *storageFixture) { f.pv.UID = "replacement-pv" }, + "foreign-binding-uid": func(f *storageFixture) { f.pv.Spec.ClaimRef.UID = "other" }, + "foreign-binding-name": func(f *storageFixture) { f.pv.Spec.ClaimRef.Name = "other" }, + "lost-binding": func(f *storageFixture) { f.pv.Spec.ClaimRef = nil }, + "lost-claim": func(f *storageFixture) { f.claim.Status.Phase = corev1.ClaimLost }, + "owner-reference-noncontroller": func(f *storageFixture) { + f.claim.OwnerReferences = []metav1.OwnerReference{{APIVersion: "v1", Kind: "Pod", Name: "old", UID: "old"}} + }, + "pv-delete-policy": func(f *storageFixture) { + f.pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimDelete + }, + "sc-delete-policy": func(f *storageFixture) { + policy := corev1.PersistentVolumeReclaimDelete + f.class.ReclaimPolicy = &policy + }, + "capacity-changed": func(f *storageFixture) { + f.data.Capacity = resource.MustParse("128Mi") + f.sts.Spec.VolumeClaimTemplates[0].Spec = retainedClaimSpec(f.data) + }, + "slot-renamed-after-retirement": func(f *storageFixture) { + f.data.Name = "new-data" + f.sts.Spec.VolumeClaimTemplates[0].Name = "new-data" + }, + "historical-high-ordinal": func(f *storageFixture) { + f.claim.Name = "data-" + f.sts.Name + "-37" + f.pv.Spec.ClaimRef.Name = f.claim.Name + f.pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimDelete + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + f := retainedFixture(t) + f.receipt(t) + mutate(&f) + writes := 0 + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class, f.claim, f.pv). + WithInterceptorFuncs(interceptor.Funcs{Create: func( + context.Context, client.WithWatch, client.Object, ...client.CreateOption, + ) error { + writes++ + return nil + }}).Build() + changed, err := applyObject(t.Context(), c, f.cr, f.desired(), c.Scheme(), &f.group, f.data) + if err == nil || errors.Is(err, errStorageUnbound) || changed || writes != 0 { + t.Fatalf("unsafe recovery wrote objects: changed=%v writes=%d err=%v", changed, writes, err) + } + }) + } +} + +func TestRetainedConsumerAndBindingTransitions(t *testing.T) { + for _, scenario := range []string{"own", "foreign", "old-terminating", + "claim-deleting", "binding-incomplete", "missing-recorded-pv"} { + t.Run(scenario, func(t *testing.T) { + f := retainedFixture(t) + objects := []client.Object{f.cr, f.class, f.sts, f.claim, f.pv} + var want error + conflict := false + switch scenario { + case "own", "foreign", "old-terminating": + controller := true + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: f.sts.Name + "-0", Namespace: f.cr.Namespace, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "StatefulSet", + Name: f.sts.Name, UID: f.sts.UID, Controller: &controller}}, + }, Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "data", + VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: f.claim.Name, + }}, + }}}} + + if scenario != "own" { + pod.OwnerReferences[0].UID = "old-sts" + conflict = true + } + if scenario == "old-terminating" { + now := metav1.Now() + pod.DeletionTimestamp = &now + pod.Finalizers = []string{"test/finalizer"} + want = errStoragePending + conflict = false + } + objects = append(objects, pod) + case "claim-deleting": + now := metav1.Now() + f.claim.DeletionTimestamp = &now + f.claim.Finalizers = []string{"kubernetes.io/pvc-protection"} + want = errStoragePending + case "binding-incomplete": + f.pv.Spec.ClaimRef.UID = "" + want = errStoragePending + case "missing-recorded-pv": + f.receipt(t) + objects = objects[:4] + conflict = true + } + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(objects...).Build() + err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, f.sts) + if want != nil { + if !errors.Is(err, want) { + t.Fatalf("want pending: %v", err) + } + } else if conflict { + if err == nil || errors.Is(err, errStoragePending) { + t.Fatalf("want conflict: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRetainedReceiptRetryRechecksClaimIdentity(t *testing.T) { + f := retainedFixture(t) + calls := 0 + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class, f.sts, f.claim, f.pv). + WithInterceptorFuncs(interceptor.Funcs{Update: func(ctx context.Context, c client.WithWatch, + obj client.Object, opts ...client.UpdateOption, + ) error { + if claim, ok := obj.(*corev1.PersistentVolumeClaim); ok { + calls++ + current := claim.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(claim), current); err != nil { + return err + } + current.UID = "replacement" + if err := c.Update(ctx, current); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "persistentvolumeclaims"}, + claim.Name, errors.New("race")) + } + return c.Update(ctx, obj, opts...) + }}).Build() + err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, f.sts) + if err == nil || !strings.Contains(err.Error(), "identity changed") || calls != 1 { + t.Fatalf("receipt retry failed to recheck identity: %v calls=%d", err, calls) + } +} + +func TestRetainedRetirementBlocksPolicyBeforeScaleAndRetainsClaims(t *testing.T) { + for _, unsafe := range []bool{false, true} { + t.Run(map[bool]string{false: "retain", true: "delete-policy"}[unsafe], func(t *testing.T) { + f := retainedFixture(t) + f.receipt(t) + if unsafe { + f.sts.Spec.PersistentVolumeClaimRetentionPolicy.WhenScaled = appsv1.DeletePersistentVolumeClaimRetentionPolicyType + } + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)). + WithObjects(f.cr, f.class, f.sts, f.claim, f.pv).WithStatusSubresource(&appsv1.StatefulSet{}).Build() + r := newTestReconciler(c, c.Scheme(), testFacts{}) + _, err := r.retireGroup(t.Context(), f.cr, f.group) + live := liveWithKey(&appsv1.StatefulSet{}, f.sts) + applyTestGet(t, c, live) + if unsafe { + if err == nil || *live.Spec.Replicas != 1 { + t.Fatalf("delete policy was changed/scaled: %v", err) + } + return + } + if err != nil || *live.Spec.Replicas != 0 { + t.Fatalf("retain scale failed: %v", err) + } + live.Status = appsv1.StatefulSetStatus{ObservedGeneration: live.Generation} + if err := c.Status().Update(t.Context(), live); err != nil { + t.Fatal(err) + } + if phase, err := r.retireGroup(t.Context(), f.cr, f.group); err != nil || phase != "deleting statefulset" { + t.Fatalf("retirement: %s %v", phase, err) + } + claim := f.claim.DeepCopy() + applyTestGet(t, c, claim) + if claim.UID != f.claim.UID || len(claim.OwnerReferences) != 0 { + t.Fatal("retirement changed retained claim identity/ownership") + } + }) + } +} + +func TestRetainedRawPVCDefaultDenialAndReservedVCTReceipts(t *testing.T) { + f := retainedFixture(t) + for _, scenario := range []string{"vct", "pod-pvc", "ephemeral", "source-spoof", "binding-spoof"} { + t.Run(scenario, func(t *testing.T) { + desired := f.desired() + switch scenario { + case "pod-pvc", "ephemeral": + desired.Spec.VolumeClaimTemplates = nil + desired.Spec.PersistentVolumeClaimRetentionPolicy = nil + v := corev1.Volume{Name: "data"} + if scenario == "pod-pvc" { + v.PersistentVolumeClaim = &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "external"} + } else { + v.Ephemeral = &corev1.EphemeralVolumeSource{} + } + desired.Spec.Template.Spec.Volumes = []corev1.Volume{v} + case "source-spoof": + desired.Spec.VolumeClaimTemplates[0].Annotations = map[string]string{retainedSourceAnnotation: "user"} + case "binding-spoof": + desired.Spec.VolumeClaimTemplates[0].Annotations = map[string]string{retainedBindingAnnotation: "user"} + } + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).Build() + if changed, err := ApplyObject(t.Context(), c, f.cr, desired, c.Scheme()); err == nil || changed { + t.Fatalf("undeclared PVC accepted: %v %v", changed, err) + } + }) + } +} + +func TestRetainedPreflightBlocksAllGroupWrites(t *testing.T) { + f := retainedFixture(t) + f.receipt(t) + delete(f.claim.Annotations, retainedSourceAnnotation) + writes := 0 + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class, f.claim, f.pv). + WithInterceptorFuncs(interceptor.Funcs{Create: func( + context.Context, client.WithWatch, client.Object, ...client.CreateOption, + ) error { + writes++ + return nil + }}).Build() + r := newTestReconciler(c, c.Scheme(), testFacts{}) + plan := pipeline.ResourcePlan[testConfig, testClusterConfig, testFacts]{ + ClusterOutput: framework.ClusterOutput{State: framework.ClusterOutputReady}, + Groups: []pipeline.BuiltGroup[testConfig, testClusterConfig, testFacts]{ + {Outcome: framework.GroupOutcome{Group: framework.GroupIdentity{Role: "workers", Name: "default", Replicas: 1}}, + Resources: &pipeline.GroupResources{StatefulSet: *f.desired(), RetainedData: f.data}}, + }, + } + status := framework.ReconcileStatus{} + pending, failures := r.applyPlan(t.Context(), f.cr, plan, &status) + if pending || len(failures) == 0 || writes != 0 || status.Groups[0].Applied { + t.Fatalf("unsafe group reached its first write: pending=%v writes=%d errors=%v", pending, writes, failures) + } +} + +func TestRetainedUnboundPlanAppliesAndPolls(t *testing.T) { + f := retainedFixture(t) + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class).Build() + objects := retirementObjects(t, f.cr, "default", 1) + for _, object := range objects { + object.SetAnnotations(nil) + object.SetOwnerReferences(nil) + } + resources := &pipeline.GroupResources{StatefulSet: *f.desired(), RetainedData: f.data, + ConfigMap: *objects[3].(*corev1.ConfigMap), Service: *objects[1].(*corev1.Service), + HeadlessService: *objects[2].(*corev1.Service)} + plan := pipeline.ResourcePlan[testConfig, testClusterConfig, testFacts]{ + ClusterOutput: framework.ClusterOutput{State: framework.ClusterOutputReady}, + Groups: []pipeline.BuiltGroup[testConfig, testClusterConfig, testFacts]{ + {Outcome: framework.GroupOutcome{Group: framework.GroupIdentity{Role: "workers", Name: "default", Replicas: 1}}, + Resources: resources}, + }, + } + r := newTestReconciler(c, c.Scheme(), testFacts{}) + status := framework.ReconcileStatus{} + pending, failures := r.applyPlan(t.Context(), f.cr, plan, &status) + if !pending || len(failures) != 0 || !status.Groups[0].Applied { + t.Fatalf("first consumer must be applied and promptly observed: %v %v %+v", pending, failures, status.Groups) + } +} + +func TestRetainedReceiptRetryRechecksGroupReceipt(t *testing.T) { + f := retainedFixture(t) + calls := 0 + c := fake.NewClientBuilder().WithScheme(controllerScheme(t)).WithObjects(f.cr, f.class, f.sts, f.claim, f.pv). + WithInterceptorFuncs(interceptor.Funcs{Update: func(ctx context.Context, c client.WithWatch, + obj client.Object, opts ...client.UpdateOption, + ) error { + if _, ok := obj.(*corev1.PersistentVolumeClaim); ok { + calls++ + sts := liveWithKey(&appsv1.StatefulSet{}, f.sts) + if err := c.Get(ctx, client.ObjectKeyFromObject(sts), sts); err != nil { + return err + } + delete(sts.Annotations, GroupSlotAnnotation) + if err := c.Update(ctx, sts); err != nil { + return err + } + return apierrors.NewConflict(schema.GroupResource{Resource: "persistentvolumeclaims"}, + obj.GetName(), errors.New("race")) + } + return c.Update(ctx, obj, opts...) + }}).Build() + err := checkRetainedStorage(t.Context(), c, f.cr, f.group, f.desired(), f.data, f.sts) + if err == nil || !strings.Contains(err.Error(), "group slot") || calls != 1 { + t.Fatalf("receipt retry accepted changed group provenance: %v calls=%d", err, calls) + } + claim := f.claim.DeepCopy() + applyTestGet(t, c, claim) + if _, written := claim.Annotations[retainedBindingAnnotation]; written { + t.Fatal("unsafe receipt persisted") + } +} diff --git a/internal/framework/controller/vector_inputs_test.go b/internal/framework/controller/vector_inputs_test.go new file mode 100644 index 00000000..0f075133 --- /dev/null +++ b/internal/framework/controller/vector_inputs_test.go @@ -0,0 +1,129 @@ +package controller + +import ( + "context" + "encoding/json" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestVectorRuntimeDependenciesAndSingleGeneration(t *testing.T) { + ctx := context.Background() + cr, scheme := controllerInput(), controllerScheme(t) + reads := 0 + c := retirementClient(scheme, cr, nil, interceptor.Funcs{ + Get: func(ctx context.Context, underlying client.WithWatch, key client.ObjectKey, object client.Object, + options ...client.GetOption, + ) error { + if key.Name == "destination" { + reads++ + } + return underlying.Get(ctx, key, object, options...) + }, + }) + r := newTestReconciler(c, scheme, testFacts{}) + role := r.Definition.Roles["workers"] + role.Config.Common.Logging.EnableVectorAgent = true + r.Definition.Roles["workers"] = role + r.Assembly = framework.AssemblyOptions{MaterializerImage: "example.invalid/materializer:test", + VectorImage: "example.invalid/vector:test"} + generated := map[string]int{} + r.Definition.GenerateGroup = func(in framework.EffectiveInput[testConfig, testClusterConfig, testFacts]) ( + framework.RuntimeDescription, error, + ) { + generated[in.Group.Name]++ + out := framework.RuntimeDescription{ConfigDirectory: "config", Main: framework.Process{Name: "main", + Command: []string{"run"}, Access: []framework.DirectoryAccess{ + {Directory: "config", MountPath: "/config", ReadOnly: true}, {Directory: "logs", MountPath: "/logs"}}}, + Directories: []framework.Directory{{Name: "config"}, {Name: "logs"}}, + Files: []framework.File{{Directory: "config", Path: "application.conf", Content: framework.Text("ready")}}} + if in.Group.Name != "nofiles" { + out.LogOutputs = []framework.LogOutput{{Container: "main", Directory: "logs", RelativePath: "server.log"}} + } + return out, nil + } + source := pipeline.SourceSnapshot[testFacts]{ + Cluster: framework.ClusterIdentity{Name: cr.Name, Namespace: cr.Namespace}, + ClusterConfig: json.RawMessage(`{"vectorAgentConfigMap":"destination"}`), + Roles: []pipeline.RoleSource{{Name: "workers"}}, Groups: []pipeline.GroupSource{ + {Role: "workers", Name: "enabled", Replicas: 1}, + {Role: "workers", Name: "disabled", Replicas: 1, Config: json.RawMessage(`{"logging":{"enableVectorAgent":false}}`)}, + {Role: "workers", Name: "nofiles", Replicas: 1}, + }} + plan, err := r.buildGroupPlan(ctx, source, nil) + if err != nil { + t.Fatal(err) + } + for _, group := range plan.Groups { + name := group.Outcome.Group.Name + if generated[name] != 1 { + t.Fatalf("%s generated %d times", name, generated[name]) + } + if name == "enabled" { + if group.Resources != nil || group.Outcome.Facts == nil || group.Outcome.Facts.State != framework.FactsPending { + t.Fatalf("enabled collector did not wait: %+v", group.Outcome) + } + } else if group.Resources == nil || group.Outcome.Facts != nil { + t.Fatalf("unselected consumer acquired a dependency: %+v", group.Outcome) + } + } + if reads != 1 { + t.Fatalf("destination reads = %d, want one per pass", reads) + } + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "destination", Namespace: cr.Namespace, + UID: "destination-uid"}, + Data: map[string]string{"ADDRESS": "receiver-a.test.svc:6000"}} + if err := c.Create(ctx, cm); err != nil { + t.Fatal(err) + } + first := vectorPlanAddress(t, r, source, cm.ResourceVersion) + cm.Data["ADDRESS"] = "receiver-b.test.svc:6000" + if err := c.Update(ctx, cm); err != nil { + t.Fatal(err) + } + second := vectorPlanAddress(t, r, source, cm.ResourceVersion) + if first != "receiver-a.test.svc:6000" || second != "receiver-b.test.svc:6000" { + t.Fatalf("live discovery was cached across passes: %q -> %q", first, second) + } + reads = 0 + source.Groups = source.Groups[1:] + if _, err := r.buildGroupPlan(ctx, source, nil); err != nil { + t.Fatal(err) + } + if reads != 0 { + t.Fatal("groups without an actual selected collector still read the destination") + } +} + +func vectorPlanAddress[CR client.Object](t *testing.T, + r *Reconciler[CR, testConfig, testClusterConfig, testFacts], source pipeline.SourceSnapshot[testFacts], version string, +) string { + t.Helper() + plan, err := r.buildGroupPlan(context.Background(), source, nil) + if err != nil { + t.Fatal(err) + } + for _, group := range plan.Groups { + if group.Outcome.Group.Name != "enabled" { + continue + } + if group.Resources == nil || group.Outcome.Facts == nil || group.Outcome.Facts.State != framework.FactsResolved || + len(group.Outcome.Facts.Observed) != 1 || group.Outcome.Facts.Observed[0].UID != "destination-uid" || + group.Outcome.Facts.Observed[0].ResourceVersion != version { + t.Fatalf("resolved destination/provenance missing: %+v", group.Outcome) + } + for _, container := range group.Resources.StatefulSet.Spec.Template.Spec.Containers { + if container.Name == "vector" { + return container.Env[0].Value + } + } + } + t.Fatal("no selected Vector") + return "" +} diff --git a/internal/framework/pipeline/AGENTS.md b/internal/framework/pipeline/AGENTS.md new file mode 100644 index 00000000..49ecfe2d --- /dev/null +++ b/internal/framework/pipeline/AGENTS.md @@ -0,0 +1,152 @@ +# Internal config-to-resource pipeline + +Parent repository instructions: [AGENTS.md](../../../AGENTS.md). +Design intent: [core specification](../../../docs/architecture.md#framework-design). + +This package implements the internal build pipeline. It does not contain a +Kubernetes reconciler, a dependency reader, a resource applier or a product-specific +production entry point. Products import `pkg/framework`; Source, Prepared and Plan +remain internal execution structures. + +## Existing code responsibilities + +| Files | Responsibility | +| --- | --- | +| `contracts.go` | Internal source, prepared, group/role resource and plan structures; shared public domain types and fixed helper identifiers | +| `source.go` | Clone raw Projection, preserve complete roles, fold replica presence, combine separately supplied facts/operation; pure `Build` entry | +| `definition_validation.go`, `input.go`, `cluster_config.go` | Supported data profile and fixed public/product/cluster configuration resolution | +| `image_config.go`, `affinity_domain.go`, `duration_domain.go` | Presence-aware image selection and fixed native domain rules | +| `naming.go`, `role_config.go` | Complete source identity checks, derived resource names, role-only config and PDBs from all declared replicas | +| `prepare.go`, `generation.go`, `build.go` | Isolated effective inputs, explicit fact outcomes, product validation/generation, fixed pipeline order and shared output result validation | +| `runtime_validation.go`, `overrides.go`, `file_overrides.go` | Runtime references, isolated declarations and layered file/env/CLI overrides | +| `resource_build.go`, `vector_assembly.go` | Platform collector selection, materialization plan, fixed resource assembly and final PodTemplate patches | +| `resource_checks.go`, `retained_data.go`, `helpers.go` | Independent final structure and modeled-consumer checks, retained slot declaration and common lookup helpers | +| `materialization.go` | Versioned file plan, static encoding, bounded PodName binding and confined filesystem writes | +| `testinput/`, `trino_*_fixture_test.go` | Test-only generated input/CRD and existing Trino adapter shapes; no production Trino import or runtime proof | + +`cmd/materialize` is a separate executable in the root module. It imports this +formal implementation and does not use the discussion prototype or old SDK execution packages. + +## Execution and ownership of data + +`Build` takes a generated raw Projection, a ProductDefinition, base facts and +assembly options. Its default operation is normal execution. Internal execution +code can use `SourceFromProjection`, then `PrepareInputs`, then +`BuildPreparedResources` to provide independent operation and per-group facts. +These functions construct plans and do not read or write Kubernetes objects. +`GeneratePreparedGroups` and `AssemblePreparedGroups` split generation from +assembly for runtime-dependent references: the controller inspects actual +LogOutputs between them, without running product generation twice. + +The source boundary folds replicas as 1 -> role -> group. It retains empty roles, +raw config layers and independently copied overrides. `PrepareInputs` validates +shape/definition, resolves clusterConfig/image/config and records all declared +identities without invoking product callbacks or performing external IO. + +A non-nil facts map must supply each configuration-valid group. Missing results +become Invalid, unresolved groups get no new resource set, and unknown group keys +are errors. A nil map uses base facts. Product validation gets isolated snapshots; +generation sees input failures in the complete topology. Final group outcomes, +including assembly failures, are supplied to the shared-output callback. + +Resource assembly composes platform files before file overrides, then applies +role/group file, env and CLI overrides. It assembles the complete Pod before +role.podOverrides and then group.podOverrides. A role Pod patch therefore outranks +group env/CLI. Patches never feed back into effective configuration or regeneration. + +`Check` has consistent/conflict/unknown states. Conflict withholds that group's +resource set. Unknown preserves a limited premise; it cannot suppress an independent +known conflict. Product final validation receives copies and cannot repair the plan. +The framework copies declared data, not arbitrary mutable state captured by callbacks +or codecs. + +## Platform collector and helpers + +`composeVector` consumes `Common.Logging.EnableVectorAgent`. `LogOutput` declares +actual files and has no Collect field. Enabled collection consumes all declared +outputs; disabled or empty output inventories create no collector. No outputs +means no Vector image requirement. No configuration files means no materializer +image requirement either. + +Selected Vector uses generated `vector.yaml` and file sources. A resolved +`AssemblyOptions.VectorDestination` selects a native Vector protocol sink; nil +selects stdout JSON. The destination also enters the collector's template env so +a discovery update reaches the running process without a separate restarter. +Its file participates in ordinary file overrides. Consumer checks compare the +framework-selected baseline with the final Pod and files. A user-added container, +or a main process called vector while collection is unselected, cannot produce +`LogCollectionKnown=true`. That boolean is structural evidence, not log delivery. + +Materialization plan version is `v1`; runtime Properties codec is `properties-v1`. +Static custom codecs execute while building the plan. Deferred PodName binding +requires the built-in PropertiesCodec. The executable accepts required `--plan` +and `--root`, takes the binding from `POD_NAME`, and rejects positional arguments. +The assembler uses `/plan/materialization.json` and `/materialized`. + +Materialize resolves all values before writing within an exclusively owned output +tree. It replaces individual files through temporary inodes, does not clean stale +files, and does not provide whole-plan atomicity or global ownership changes. +Image packaging and kubelet execution are verified by the formal delivery harness, +not by this package's unit tests. + +## Identity and lifecycle limits + +Complete source identities validate group names and the headless suffix before +successful group outputs can narrow the inventory. Ordinary/headless Service names +use DNS1035 label rules; StatefulSet/ConfigMap/PDB names use DNS1123 subdomain rules. +The inventory checks ordinary/headless cross-group collisions without truncation. +Generated addresses and resources use the same GroupIdentity naming methods. + +Role PDBs use all declared replicas, including groups with failed config or pending +facts. Stopped plans change only successful StatefulSet execution replicas to zero; +this package does not stop or observe existing Pods. + +`storage.go` folds the standard storage discriminator outside generic object merging. +Changing type clears the inherited branch; the final persistent branch requires class and positive capacity. +One `Directory.Data` binds effective `Resources.Storage`: ephemeral becomes emptyDir; +persistent becomes RWO/Filesystem with Retain/Retain. Persistent input without a Data consumer fails. It cannot +also hold generated configuration or declared log outputs. Final checks reject +incompatible data mount changes. No live PVC/PV reads, provenance receipts, binding +checks, reclaim checks, data adoption or cleanup are implemented here. + +ResourcePlan carries `ClusterOutput` plus `ClusterError`. Ready is a complete shared +ConfigMap set, including empty withdrawal; Pending requires a reason and no partial +set. An absent callback is Ready/empty. Invalid output or conflicting producers fail +the shared output without discarding otherwise valid group plans. These are plan +contracts; ownership, application and withdrawal cleanup belong to U03. +Shared ConfigMaps cannot occupy any declared group's reserved ConfigMap slot, +including a group withheld by invalid config or pending facts. + +## Verification and generated fixture + +Pure tests cover configuration/presence, image/native fields, names, PDBs, overrides, +materialization, retained mounts, Vector selection and independent final checks. +The helper command tests build and execute a local binary. Neither is evidence of a +container image, kubelet, Trino or Vector running. + +The test-only generated API is checked by `TestInputFixtureCurrent`. To intentionally +regenerate it after changing the test definition or inputgen: + +```sh +go test ./internal/framework/pipeline -run '^TestInputFixtureCurrent$' -update-input-fixture +``` + +The persisted-input test uses envtest to create/read the generated CR, build a plan, +create its resources and materialize bytes locally. It requires real assets and fails +when they are unavailable. It does not run Kubernetes workload controllers or Pods. +Focused commands include: + +```sh +go test ./internal/framework/pipeline -run 'Test(BuildGroup|Vector|AssemblyChecks)' -count=1 +go test ./cmd/materialize -count=1 +go test ./internal/framework/pipeline -run '^TestPersistedInputBuildsResourcesAndMaterializedBytes$' -count=1 +``` + +Supply `KUBEBUILDER_ASSETS` for API acceptance. The current checkout also locates the +repository's Kubernetes 1.35 assets when that variable is unset. Run root `make lint` +and `make test GOTESTFLAGS='-p=2'` for the final gate, and record actual results in the +U02 implementation record before describing the unit as accepted. + +`lifecycle.go` validates native probes/ordered initializer declarations and reports lifecycle +premise changes after Pod overrides. Assembly runs initializers after materialization, uses explicit +OrderedReady/RollingUpdate for coordinated workloads and clones every mutable lifecycle/probe value. diff --git a/internal/framework/pipeline/affinity_domain.go b/internal/framework/pipeline/affinity_domain.go new file mode 100644 index 00000000..2aa8c3f1 --- /dev/null +++ b/internal/framework/pipeline/affinity_domain.go @@ -0,0 +1,90 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + kubernetesjson "sigs.k8s.io/json" +) + +const affinityConfigField = "affinity" + +// Affinity is one fixed common domain. Each supplied scheduling branch replaces +// that complete branch; absent branches inherit. An empty affinity object has no +// new branches, while nodeAffinity: {} explicitly clears node scheduling rules. +func mergeAffinityJSON(base, patch json.RawMessage) (json.RawMessage, error) { + var lower, upper corev1.Affinity + if err := json.Unmarshal(base, &lower); err != nil { + return nil, err + } + if err := json.Unmarshal(patch, &upper); err != nil { + return nil, err + } + result := lower.DeepCopy() + if upper.NodeAffinity != nil { + result.NodeAffinity = upper.NodeAffinity.DeepCopy() + } + if upper.PodAffinity != nil { + result.PodAffinity = upper.PodAffinity.DeepCopy() + } + if upper.PodAntiAffinity != nil { + result.PodAntiAffinity = upper.PodAntiAffinity.DeepCopy() + } + return json.Marshal(result) +} + +func validateAffinityJSON(data json.RawMessage, path string) error { + if err := rejectInputNulls(data, path); err != nil { + return err + } + var affinity corev1.Affinity + strict, err := kubernetesjson.UnmarshalStrict(data, &affinity) + if failure := errors.Join(append(strict, err)...); failure != nil { + return fmt.Errorf("%s: %w", path, failure) + } + return nil +} + +// RawMessage keeps each domain unnormalized until its own validation. The outer +// strict decode rejects duplicate config field names before selecting a domain. +func decodeConfigLayer(data json.RawMessage, path string) (map[string]json.RawMessage, error) { + var values map[string]json.RawMessage + strict, err := kubernetesjson.UnmarshalStrict(data, &values) + if failure := errors.Join(append(strict, err)...); failure != nil { + return nil, fmt.Errorf("%s: %w", path, failure) + } + if values == nil { + return nil, fmt.Errorf("%s must be an object", path) + } + return values, nil +} + +func rejectInputNulls(data json.RawMessage, path string) error { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return err + } + return rejectNullValue(value, path) +} + +func rejectNullValue(value any, path string) error { + switch node := value.(type) { + case nil: + return fmt.Errorf("%s: explicit null is not allowed", path) + case map[string]any: + for _, key := range sortedKeys(node) { + if err := rejectNullValue(node[key], fmt.Sprintf("%s[%q]", path, key)); err != nil { + return err + } + } + case []any: + for index, child := range node { + if err := rejectNullValue(child, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/framework/pipeline/affinity_domain_test.go b/internal/framework/pipeline/affinity_domain_test.go new file mode 100644 index 00000000..05fd357a --- /dev/null +++ b/internal/framework/pipeline/affinity_domain_test.go @@ -0,0 +1,104 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + frameworkinput "github.com/zncdatadev/operator-go/pkg/framework/input" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func affinityDefaults() framework.Config[testProductConfig] { + defaults := testDefaults() + defaults.Common.Affinity = corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{{ + Weight: 20, Preference: corev1.NodeSelectorTerm{MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "pool", Operator: corev1.NodeSelectorOpIn, Values: []string{"old"}}}}, + }}}, + PodAffinity: &corev1.PodAffinity{RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{{ + TopologyKey: "kubernetes.io/hostname", LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "neighbor"}}, + }}}, + PodAntiAffinity: &corev1.PodAntiAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.WeightedPodAffinityTerm{{ + Weight: 40, PodAffinityTerm: corev1.PodAffinityTerm{TopologyKey: "topology.kubernetes.io/zone"}, + }}, + }, + } + return defaults +} + +func TestAffinityBranchesInheritOrReplaceAsCompletePolicies(t *testing.T) { + defaults := affinityDefaults() + role := json.RawMessage(`{"affinity":{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{ + "nodeSelectorTerms":[{"matchExpressions":[{"key":"pool","operator":"In","values":["new"]}]}]}}}}`) + group := json.RawMessage(`{"affinity":{"podAntiAffinity":{}}}`) + resolved, err := ResolveConfig(defaults, role, group) + if err != nil { + t.Fatal(err) + } + got := resolved.Common.Affinity + if got.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil || + len(got.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution) != 0 { + t.Fatalf("node branch was deep-merged instead of replaced: %+v", got.NodeAffinity) + } + if !reflect.DeepEqual(got.PodAffinity, defaults.Common.Affinity.PodAffinity) || + got.PodAntiAffinity == nil || len(got.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution) != 0 { + t.Fatalf("sibling inheritance or explicit branch clearing failed: %+v", got) + } + inherited, err := ResolveConfig(defaults, nil, json.RawMessage(`{"affinity":{}}`)) + if err != nil || !reflect.DeepEqual(inherited.Common.Affinity, defaults.Common.Affinity) { + t.Fatalf("an empty top-level affinity lost inherited branches: %+v %v", inherited.Common.Affinity, err) + } +} + +func TestNativeAffinityInputRoundTripDoesNotResurrectClearedRules(t *testing.T) { + input := struct { + Affinity *corev1.Affinity `json:"affinity,omitempty"` + }{Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{}, + }}} + layer, err := frameworkinput.ConfigJSON(&input) + if err != nil { + t.Fatal(err) + } + if string(layer) != `{"affinity":{"nodeAffinity":{}}}` { + t.Fatalf("unexpected native empty-list normalization: %s", layer) + } + resolved, err := ResolveConfig(affinityDefaults(), nil, layer) + if err != nil || resolved.Common.Affinity.NodeAffinity == nil || + len(resolved.Common.Affinity.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution) != 0 || + resolved.Common.Affinity.PodAffinity == nil { + t.Fatalf("typed empty list inherited removed rules: %+v %v", resolved.Common.Affinity, err) + } + clone := CloneInput(resolved) + clonedTerms := clone.Common.Affinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution + clonedTerms[0].LabelSelector.MatchLabels["app"] = "new" + originalTerms := resolved.Common.Affinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution + if originalTerms[0].LabelSelector.MatchLabels["app"] != "neighbor" { + t.Fatal("native affinity clone shared mutable selectors") + } +} + +func TestAffinityStrictInputRejectsUnknownDuplicateAndNull(t *testing.T) { + for _, layer := range []string{ + `{"affinity":null}`, `{"affinity":[]}`, `{"affinity":{"nodeAffinity":null}}`, + `{"affinity":{"nodeAffinitY":{}}}`, `{"affinity":{"nodeAffinity":{"unknown":1}}}`, + `{"affinity":{"podAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":[null]}}}`, + `{"affinity":{"nodeAffinity":{},"nodeAffinity":{}}}`, + `{"affinity":{"nodeAffinity":{}},"affinity":{}}`, + } { + _, err := ResolveConfig(affinityDefaults(), json.RawMessage(layer), json.RawMessage(`{"affinity":{}}`)) + if err == nil { + t.Fatalf("invalid lower affinity layer was hidden: %s", layer) + } + } + if err := checkProfile(reflect.TypeFor[struct { + Scheduling corev1.Affinity `json:"scheduling"` + }](), make(map[reflect.Type]bool)); err == nil { + t.Fatal("native common affinity opened arbitrary product pointer graphs") + } +} diff --git a/internal/framework/pipeline/build.go b/internal/framework/pipeline/build.go new file mode 100644 index 00000000..ef62ecd8 --- /dev/null +++ b/internal/framework/pipeline/build.go @@ -0,0 +1,215 @@ +package pipeline + +import ( + "fmt" + "slices" + + "github.com/zncdatadev/operator-go/pkg/framework" + "k8s.io/apimachinery/pkg/util/validation" +) + +// BuildResources executes the pure fixed-order pipeline. Returned plans carry +// diagnostics and desired resources, never client operations or observed health. +func BuildResources[C, S, F any](definition framework.ProductDefinition[C, S, F], source SourceSnapshot[F], + options AssemblyOptions, +) (ResourcePlan[C, S, F], error) { + prepared, err := PrepareInputs(definition, source) + if err != nil { + return ResourcePlan[C, S, F]{}, err + } + return BuildPreparedResources(definition, prepared, nil, options) +} + +// BuildPreparedResources consumes an explicit result for each valid group when +// facts is non-nil. A missing or unresolved result withholds only its group plan. +func BuildPreparedResources[C, S, F any]( + definition framework.ProductDefinition[C, S, F], prepared PreparedInputs[C, S, F], + facts map[GroupKey]framework.FactResult[F], options AssemblyOptions, +) (ResourcePlan[C, S, F], error) { + generated, err := GeneratePreparedGroups(definition, prepared, facts) + if err != nil { + return ResourcePlan[C, S, F]{}, err + } + return AssemblePreparedGroups(definition, prepared, generated, options) +} + +// AssemblePreparedGroups consumes descriptions exactly once after runtime-dependent +// platform references have been resolved outside the pure pipeline. Unresolved +// groups retain their outcome but do not acquire a desired resource set. +func AssemblePreparedGroups[C, S, F any]( + definition framework.ProductDefinition[C, S, F], prepared PreparedInputs[C, S, F], + generated []GeneratedGroup[C, S, F], options AssemblyOptions, +) (ResourcePlan[C, S, F], error) { + prepared = CloneInput(prepared) + plan := ResourcePlan[C, S, F]{Prepared: &prepared, ClusterOutput: ClusterOutput{State: ClusterOutputReady}} + source := prepared.Source + roles, err := BuildRoleResources(definition, source) + if err != nil { + return plan, err + } + plan.Roles = roles + layers := make(map[string]GroupSource, len(source.Groups)) + for _, group := range source.Groups { + layers[group.Role+"/"+group.Name] = group + } + for _, group := range generated { + built := BuiltGroup[C, S, F]{Outcome: group.Outcome, Input: group.Input, Runtime: group.Runtime} + if group.Outcome.Error == "" && group.Runtime != nil { + if prepared.Platform.VectorAgentConfigMap != "" && group.Input.Config.Common.Logging.EnableVectorAgent && + len(group.Runtime.LogOutputs) > 0 && options.VectorDestination == nil { + built.Outcome.Error = "vectorAgentConfigMap requires a resolved destination before assembly" + built.Outcome.GeneratedEndpoints = nil + plan.Groups = append(plan.Groups, built) + continue + } + layer := layers[group.Outcome.Group.Role+"/"+group.Outcome.Group.Name] + built.Resources, built.Files, built.Checks, err = buildGroup(group.Outcome.Group, + group.Input.Config.Common, group.Input.Image, *group.Runtime, layer, options, definition.ValidateFinal) + if err != nil { + built.Outcome.Error = err.Error() + built.Outcome.GeneratedEndpoints = nil + } else if source.Operation.Stopped { + zero := int32(0) + built.Resources.StatefulSet.Spec.Replicas = &zero + } + } + plan.Groups = append(plan.Groups, built) + } + if err := checkResourceInventory(plan); err != nil { + return plan, err + } + if definition.GenerateCluster != nil { + plan.ClusterOutput, err = generateClusterOutput(definition, prepared, plan.Groups) + if err == nil { + err = checkResourceInventory(plan) + } + if err != nil { + plan.ClusterError = err.Error() + plan.ClusterOutput = ClusterOutput{} + } + } + return plan, nil +} + +// GeneratePreparedGroups validates effective input and invokes each eligible +// product generator once. Actual LogOutputs then decide platform dependencies. +func GeneratePreparedGroups[C, S, F any](definition framework.ProductDefinition[C, S, F], + prepared PreparedInputs[C, S, F], facts map[GroupKey]framework.FactResult[F], +) ([]GeneratedGroup[C, S, F], error) { + prepared = CloneInput(prepared) + facts, err := normalizeFacts(prepared.Topology, facts) + if err != nil { + return nil, err + } + source := prepared.Source + topology, err := validateTopology( + definition, prepared.Platform, prepared.ClusterConfig, prepared.Image, source.Shared, prepared.Topology, facts) + if err != nil { + return nil, err + } + groups := make([]GeneratedGroup[C, S, F], 0, len(topology)) + for _, group := range topology { + generated := GeneratedGroup[C, S, F]{Outcome: GroupOutcome{Group: group.Group, Error: group.Error}} + fact, external := facts[GroupKey{Role: group.Group.Role, Name: group.Group.Name}] + if external { + generated.Outcome.Facts = &fact.Diagnostic + } + if group.Error == "" && (!external || fact.Diagnostic.State == FactsResolved) { + value := source.Shared + if external { + value = *fact.Value + } + in, err := snapshotInput(group, prepared.ClusterConfig, prepared.Image, value, topology) + if err != nil { + return nil, fmt.Errorf("input snapshot: %w", err) + } + in.Platform = CloneInput(prepared.Platform) + generated.Input = &in + generated.Runtime, err = generateOne(definition, in) + if err != nil { + generated.Outcome.Error = err.Error() + } else { + generated.Outcome.GeneratedEndpoints = slices.Clone(generated.Runtime.Endpoints) + for _, directory := range generated.Runtime.Directories { + if directory.Secret != nil || directory.Listener != nil { + generated.Outcome.Platform = &framework.PlatformObservation{Phase: "Preparing", + Diagnostic: framework.FactDiagnostic{State: framework.FactsPending, Reason: "PlatformNotObserved", + Message: "Platform producer has not been observed"}} + break + } + } + } + } + groups = append(groups, generated) + } + return groups, nil +} + +func generateClusterOutput[C, S, F any]( + definition framework.ProductDefinition[C, S, F], prepared PreparedInputs[C, S, F], + groups []BuiltGroup[C, S, F], +) (ClusterOutput, error) { + outcomes := make([]GroupOutcome, 0, len(groups)) + for _, group := range groups { + outcomes = append(outcomes, group.Outcome) + } + in, err := copyJSON(framework.ClusterOutputInput[S, F]{Cluster: prepared.Source.Cluster, + ClusterConfig: prepared.ClusterConfig, Shared: prepared.Source.Shared, Groups: outcomes}) + if err != nil { + return ClusterOutput{}, err + } + output, err := definition.GenerateCluster(in) + if err != nil { + return ClusterOutput{}, err + } + if err := framework.ValidateClusterOutput(output); err != nil { + return ClusterOutput{}, err + } + // A callback's retained map must not mutate the returned resource plan. + output.ConfigMaps = slices.Clone(output.ConfigMaps) + for index := range output.ConfigMaps { + cm := output.ConfigMaps[index].DeepCopy() + if cm.Namespace != in.Cluster.Namespace || len(validation.IsDNS1123Subdomain(cm.Name)) != 0 { + return ClusterOutput{}, fmt.Errorf("cluster ConfigMap must have a valid name in namespace %q", in.Cluster.Namespace) + } + if err := checkSharedConfigMapSlot(prepared.Topology, cm.Namespace, cm.Name); err != nil { + return ClusterOutput{}, err + } + cm.Labels = resourceLabels(prepared.Source.Cluster, cm.Labels) + output.ConfigMaps[index] = *cm + } + return output, nil +} + +// A group's fixed ConfigMap slot remains reserved while its configuration or +// facts are unavailable. Shared outputs cannot occupy a temporarily absent slot. +func checkSharedConfigMapSlot[C any](topology []framework.ResolvedGroup[C], namespace, name string) error { + for _, group := range topology { + identity := group.Group + if identity.Namespace == namespace && identity.ServiceName() == name { + return fmt.Errorf("cluster ConfigMap %s/%s conflicts with reserved ConfigMap slot for role group %s/%s", + namespace, name, identity.Role, identity.Name) + } + } + return nil +} + +// RefreshClusterOutput uses post-apply platform observations while preserving +// the same pure generation and reserved-slot validation as the original plan. +func RefreshClusterOutput[C, S, F any](definition framework.ProductDefinition[C, S, F], plan *ResourcePlan[C, S, F]) { + if definition.GenerateCluster == nil || plan.Prepared == nil { + return + } + output, err := generateClusterOutput(definition, *plan.Prepared, plan.Groups) + if err != nil { + plan.ClusterError = err.Error() + plan.ClusterOutput = ClusterOutput{} + return + } + plan.ClusterOutput = output + plan.ClusterError = "" + if err = checkResourceInventory(*plan); err != nil { + plan.ClusterError = err.Error() + plan.ClusterOutput = ClusterOutput{} + } +} diff --git a/internal/framework/pipeline/cluster_config.go b/internal/framework/pipeline/cluster_config.go new file mode 100644 index 00000000..f4444576 --- /dev/null +++ b/internal/framework/pipeline/cluster_config.go @@ -0,0 +1,106 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func checkClusterConfigType(typ reflect.Type) error { + if typ.Kind() != reflect.Struct || typ == quantityType || typ == durationType { + return fmt.Errorf("product cluster config must be a struct") + } + if err := checkProfile(typ, make(map[reflect.Type]bool)); err != nil { + return fmt.Errorf("product cluster config: %w", err) + } + return checkFlatFields([]reflect.Type{reflect.TypeFor[ClusterOperation](), + reflect.TypeFor[framework.ClusterConfig](), typ}) +} + +// ResolveClusterConfig folds one cluster-wide user layer over product defaults. +// It uses the same fixed data profile and object/key inheritance as role config; +// sequences replace, and explicit null is invalid. CommonConfig and role layers +// do not participate. Product business validation remains in product callbacks. +func ResolveClusterConfig[S any](defaults S, raw json.RawMessage) (S, error) { + var out S + typ := reflect.TypeFor[S]() + if err := checkClusterConfigType(typ); err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + data, err := json.Marshal(defaults) + if err != nil { + return out, fmt.Errorf("clusterConfig defaults: %w", err) + } + var base map[string]json.RawMessage + if err := json.Unmarshal(data, &base); err != nil { + return out, fmt.Errorf("clusterConfig defaults: %w", err) + } + if len(raw) != 0 { + var err error + _, raw, err = splitPlatformConfig(raw) + if err != nil { + return out, err + } + if err := validateJSON(raw, typ, "clusterConfig"); err != nil { + return out, err + } + values, err := decodeConfigLayer(raw, "clusterConfig") + if err != nil { + return out, err + } + fields, _ := jsonFields(typ) + if err := foldS3Domains(base, values, fields); err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + base = mergeObjects(base, values) + } + data, err = json.Marshal(base) + if err != nil { + return out, err + } + if err := json.Unmarshal(data, &out); err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + if err := validateS3Domains(reflect.ValueOf(out)); err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + return out, nil +} + +// splitPlatformConfig validates framework fields independently of product S. +func splitPlatformConfig(raw json.RawMessage) (framework.ClusterConfig, json.RawMessage, error) { + var out framework.ClusterConfig + if len(raw) == 0 { + return out, nil, nil + } + values, err := decodeConfigLayer(raw, "clusterConfig") + if err != nil { + return out, nil, err + } + fields, err := jsonFields(reflect.TypeFor[framework.ClusterConfig]()) + if err != nil { + return out, nil, err + } + common := make(map[string]json.RawMessage) + for _, key := range sortedKeys(fields) { + typ := fields[key] + if value, ok := values[key]; ok { + if err := validateJSON(value, typ, "clusterConfig."+key); err != nil { + return out, nil, err + } + common[key] = value + delete(values, key) + } + } + data, err := json.Marshal(common) + if err != nil { + return out, nil, err + } + if err = json.Unmarshal(data, &out); err != nil { + return out, nil, err + } + product, err := json.Marshal(values) + return out, product, err +} diff --git a/internal/framework/pipeline/cluster_config_test.go b/internal/framework/pipeline/cluster_config_test.go new file mode 100644 index 00000000..ac2692bf --- /dev/null +++ b/internal/framework/pipeline/cluster_config_test.go @@ -0,0 +1,112 @@ +package pipeline + +import ( + "encoding/json" + "math" + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type clusterConfigFixture struct { + Enabled bool `json:"enabled"` + Count int `json:"count"` + Name string `json:"name"` + Resources string `json:"resources"` + Labels map[string]map[string]string `json:"labels"` + Args []string `json:"args"` + Capacity resource.Quantity `json:"capacity"` + Interval metav1.Duration `json:"interval"` +} + +func clusterConfigDefaults() clusterConfigFixture { + return clusterConfigFixture{Enabled: true, Count: 5, Name: "default", Resources: "product-owned", + Labels: map[string]map[string]string{"shared": {"keep": "yes", "replace": "old"}}, + Args: []string{"one", "two"}, Capacity: resource.MustParse("1Gi"), + Interval: metav1.Duration{Duration: time.Second}} +} + +func TestClusterConfigFixedInheritanceAndPresence(t *testing.T) { + defaults := clusterConfigDefaults() + before := CloneInput(defaults) + raw := json.RawMessage(`{"enabled":false,"count":0,"name":"","resources":"cluster-owned", + "labels":{"shared":{"replace":"new"},"added":{"key":"value"}},"args":[], + "capacity":"2Gi","interval":"-500ms"}`) + actual, err := ResolveClusterConfig(defaults, raw) + if err != nil { + t.Fatal(err) + } + want := clusterConfigDefaults() + want.Enabled, want.Count, want.Name, want.Resources = false, 0, "", "cluster-owned" + want.Labels = map[string]map[string]string{"shared": {"keep": "yes", "replace": "new"}, "added": {"key": "value"}} + want.Args, want.Capacity = []string{}, resource.MustParse("2Gi") + want.Interval.Duration = -500 * time.Millisecond + if !reflect.DeepEqual(actual, want) || !reflect.DeepEqual(defaults, before) { + t.Fatalf("cluster fixed-rule fold changed: %+v; defaults %+v", actual, defaults) + } + actual.Labels["shared"]["keep"] = "caller mutation" + if defaults.Labels["shared"]["keep"] != "yes" { + t.Fatal("effective cluster config aliases defaults") + } +} + +func TestClusterConfigOmittedAndEmptyObjectsInherit(t *testing.T) { + for _, raw := range []json.RawMessage{nil, json.RawMessage(`{}`), json.RawMessage(`{"labels":{"shared":{}}}`)} { + defaults := clusterConfigDefaults() + actual, err := ResolveClusterConfig(defaults, raw) + if err != nil || !reflect.DeepEqual(actual, defaults) { + t.Fatalf("empty object cleared defaults: %s %+v %v", raw, actual, err) + } + actual.Args[0], actual.Labels["shared"]["keep"] = "changed", "changed" + if defaults.Args[0] != "one" || defaults.Labels["shared"]["keep"] != "yes" { + t.Fatal("omitted cluster input returned mutable defaults") + } + } + if _, err := ResolveClusterConfig(struct{}{}, json.RawMessage(`{}`)); err != nil { + t.Fatalf("product without cluster fields requires fake defaults: %v", err) + } +} + +func TestClusterConfigRejectsMalformedUserLayer(t *testing.T) { + for _, raw := range []string{ + `null`, `[]`, `{"unknown":1}`, `{"enabled":null}`, `{"labels":{"shared":null}}`, + `{"labels":{"shared":{"keep":null}}}`, `{"args":null}`, `{"args":[null]}`, `{"args":"scalar"}`, + `{"enabled":true,"enabled":false}`, `{"labels":{"shared":{"keep":"a","keep":"b"}}}`, + `{"labels":{"shared":{},"shared":{}}}`, `{"capacity":"invalid"}`, `{"interval":"later"}`, + } { + t.Run(raw, func(t *testing.T) { + _, err := ResolveClusterConfig(clusterConfigDefaults(), json.RawMessage(raw)) + if err == nil || !strings.Contains(err.Error(), "clusterConfig") { + t.Fatalf("malformed layer lost its path or was accepted: %v", err) + } + }) + } +} + +func TestClusterConfigRejectsUnsupportedProfilesAndDefaults(t *testing.T) { + errors := make([]error, 0, 7) + _, err := ResolveClusterConfig("scalar", nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(map[string]string{}, nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(resource.MustParse("1Gi"), nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(metav1.Duration{}, nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(struct{ Value *string }{}, nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(struct{ Scheduling corev1.Affinity }{}, nil) + errors = append(errors, err) + _, err = ResolveClusterConfig(struct{ Value float64 }{Value: math.NaN()}, nil) + errors = append(errors, err) + for i, err := range errors { + if err == nil || !strings.Contains(err.Error(), "clusterConfig") { + t.Fatalf("unsupported cluster profile/default %d accepted: %v", i, err) + } + } +} diff --git a/internal/framework/pipeline/contracts.go b/internal/framework/pipeline/contracts.go new file mode 100644 index 00000000..047da65f --- /dev/null +++ b/internal/framework/pipeline/contracts.go @@ -0,0 +1,170 @@ +// Package pipeline owns the SDK's pure config-to-resource execution. Its plans +// and stages are internal; products declare framework values, not this pipeline. +package pipeline + +import ( + "encoding/json" + + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" +) + +// Aliases keep one domain model shared with the public author contract. No +// product-facing types are redefined by the implementation package. +type CommonConfig = framework.CommonConfig +type Resources = framework.Resources +type CPU = framework.CPU +type Memory = framework.Memory +type Logging = framework.Logging +type ContainerLogging = framework.ContainerLogging +type Logger = framework.Logger +type RoleConfig = framework.RoleConfig +type PodDisruptionBudgetConfig = framework.PodDisruptionBudgetConfig +type ImageConfig = framework.ImageConfig +type ResolvedImage = framework.ResolvedImage +type ClusterOperation = framework.ClusterOperation +type AssemblyOptions = framework.AssemblyOptions +type ClusterIdentity = framework.ClusterIdentity +type GroupIdentity = framework.GroupIdentity +type RoleIdentity = framework.RoleIdentity +type GroupOutcome = framework.GroupOutcome +type ClusterOutputState = framework.ClusterOutputState +type ClusterOutput = framework.ClusterOutput +type FinalView = framework.FinalView +type FactResource = framework.FactResource +type FactsReader = framework.FactsReader +type FactState = framework.FactState +type FactObject = framework.FactObject +type FactDiagnostic = framework.FactDiagnostic +type RuntimeDescription = framework.RuntimeDescription +type Process = framework.Process +type Directory = framework.Directory + +// RetainedData is the resolved physical request, owned by assembly. +type RetainedData struct { + StorageClassName string + Capacity resource.Quantity +} +type DirectoryAccess = framework.DirectoryAccess +type File = framework.File +type FileContent = framework.FileContent +type KeyValues = framework.KeyValues +type Lines = framework.Lines +type Text = framework.Text +type PropertyCodec = framework.PropertyCodec +type PropertiesCodec = framework.PropertiesCodec +type PropertyValue = framework.PropertyValue +type Literal = framework.Literal +type PodNameBinding = framework.PodNameBinding +type Endpoint = framework.Endpoint +type LogOutput = framework.LogOutput +type CheckState = framework.CheckState +type Check = framework.Check +type ImageInput = input.ImageInput +type RoleConfigInput = input.RoleConfigInput +type PodDisruptionBudgetInput = input.PodDisruptionBudgetInput +type Overrides = input.Overrides +type FileOverride = input.FileOverride +type PropertyOverride = input.PropertyOverride + +const ( + Consistent = framework.Consistent + Conflict = framework.Conflict + Unknown = framework.Unknown + FactsResolved = framework.FactsResolved + FactsPending = framework.FactsPending + FactsInvalid = framework.FactsInvalid + FactsReadError = framework.FactsReadError + ClusterOutputReady = framework.ClusterOutputReady + ClusterOutputPending = framework.ClusterOutputPending + materializerContainerName = "prepare-files" + materializationPlanVolume = "config-plan" + materializationPlanFile = "materialization.json" + materializationRoot = "/materialized" + materializationPlanPath = "/plan/materialization.json" + vectorContainerName = "vector" + vectorConfigFile = "vector.yaml" + vectorConfigPath = "/etc/vector" +) + +// SourceSnapshot is built from a raw Projection plus independently supplied +// facts and execution intent. It is never emitted by external generated code. +type SourceSnapshot[F any] struct { + Operation ClusterOperation + ClusterConfig json.RawMessage + Cluster ClusterIdentity + Image json.RawMessage + Shared F + Groups []GroupSource + Roles []RoleSource +} + +type RoleSource struct { + Name string + Config json.RawMessage +} +type GroupSource struct { + Role, Name string + Replicas int32 + RoleConfigLayer, Config json.RawMessage + RoleOverrides, Overrides *Overrides +} +type GroupKey struct{ Role, Name string } + +type PreparedInputs[C, S, F any] struct { + Platform framework.ClusterConfig + ClusterConfig S + Source SourceSnapshot[F] + Image ResolvedImage + Topology []framework.ResolvedGroup[C] +} + +type GeneratedGroup[C, S, F any] struct { + Outcome GroupOutcome + Input *framework.EffectiveInput[C, S, F] + Runtime *RuntimeDescription +} + +type GroupResources struct { + Coordination *framework.WorkloadCoordination + ConfigMap corev1.ConfigMap + StatefulSet appsv1.StatefulSet + Service corev1.Service + HeadlessService corev1.Service + RetainedData *RetainedDataSlot +} +type RetainedDataSlot struct { + Name string + RetainedData +} +type BuiltRole struct { + Role RoleIdentity + Config *RoleConfig + PodDisruptionBudget *policyv1.PodDisruptionBudget + Error string +} +type BuiltGroup[C, S, F any] struct { + Outcome GroupOutcome + Input *framework.EffectiveInput[C, S, F] + Runtime *RuntimeDescription + Files []File + Resources *GroupResources + Checks []Check +} + +type ResourcePlan[C, S, F any] struct { + Prepared *PreparedInputs[C, S, F] + Groups []BuiltGroup[C, S, F] + Roles []BuiltRole + ClusterOutput ClusterOutput + ClusterError string +} + +// CloneInput is restricted to generated/config data, never arbitrary resources, +// callbacks or runtime descriptions containing codecs. +func CloneInput[T any](value T) T { return input.Clone(value) } diff --git a/internal/framework/pipeline/definition_validation.go b/internal/framework/pipeline/definition_validation.go new file mode 100644 index 00000000..4d6269af --- /dev/null +++ b/internal/framework/pipeline/definition_validation.go @@ -0,0 +1,62 @@ +package pipeline + +import ( + "fmt" + "reflect" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +// ValidateDefinition checks the static registration contract without executing +// product callbacks or resolving default values. A CR can still override an +// otherwise incomplete or invalid default before effective-input validation. +// This is framework plumbing, not another product-author lifecycle callback. +func ValidateDefinition[C, S, F any](definition framework.ProductDefinition[C, S, F]) error { + if definition.Name == "" || len(definition.Roles) == 0 || definition.GenerateGroup == nil { + return fmt.Errorf("product name, role defaults and GenerateGroup are required") + } + if err := checkConfigType(reflect.TypeFor[C]()); err != nil { + return err + } + if err := checkClusterConfigType(reflect.TypeFor[S]()); err != nil { + return err + } + if err := checkProfile(reflect.TypeFor[F](), make(map[reflect.Type]bool)); err != nil { + return fmt.Errorf("shared facts: %w", err) + } + return nil +} + +// Only static field/profile compatibility is checked here. Invalid default +// business values may be repaired by user layers before effective validation. +func checkConfigType(product reflect.Type) error { + if product.Kind() != reflect.Struct || product == quantityType || product == durationType { + return fmt.Errorf("product config must be a struct") + } + if err := checkProfile(product, make(map[reflect.Type]bool)); err != nil { + return fmt.Errorf("product config: %w", err) + } + return checkFlatFields([]reflect.Type{reflect.TypeFor[CommonConfig](), product}) +} + +func checkFlatFields(sources []reflect.Type) error { + goNames, jsonNames := make(map[string]bool), make(map[string]bool) + for _, source := range sources { + if _, err := jsonFields(source); err != nil { + return err + } + for index := 0; index < source.NumField(); index++ { + field := source.Field(index) + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "" { + name = field.Name + } + if goNames[field.Name] || jsonNames[name] { + return fmt.Errorf("common/product field %s (%q) collides", field.Name, name) + } + goNames[field.Name], jsonNames[name] = true, true + } + } + return nil +} diff --git a/internal/framework/pipeline/definition_validation_test.go b/internal/framework/pipeline/definition_validation_test.go new file mode 100644 index 00000000..0ad941f5 --- /dev/null +++ b/internal/framework/pipeline/definition_validation_test.go @@ -0,0 +1,66 @@ +package pipeline + +import ( + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func validationDefinition[C, S, F any]() framework.ProductDefinition[C, S, F] { + return framework.ProductDefinition[C, S, F]{ + Name: "fixture", Roles: map[string]framework.RoleDefinition[C]{"workers": {}}, + GenerateGroup: func(framework.EffectiveInput[C, S, F]) (RuntimeDescription, error) { + return RuntimeDescription{}, nil + }, + } +} + +func TestDefinitionValidationDoesNotEvaluateDefaultsOrCallbacks(t *testing.T) { + definition := validationDefinition[testProductConfig, clusterConfigFixture, struct{}]() + definition.Roles["workers"] = framework.RoleDefinition[testProductConfig]{RoleConfig: RoleConfig{ + PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: -1}}} + definition.ValidateInput = func(framework.EffectiveInput[testProductConfig, clusterConfigFixture, struct{}]) error { + t.Fatal("static definition validation invoked product validation") + return nil + } + definition.GenerateGroup = func(framework.EffectiveInput[testProductConfig, clusterConfigFixture, struct{}]) ( + RuntimeDescription, error, + ) { + t.Fatal("static definition validation invoked generation") + return RuntimeDescription{}, nil + } + if err := ValidateDefinition(definition); err != nil { + t.Fatalf("static validation rejected defaults that higher user layers can repair: %v", err) + } +} + +func TestDefinitionValidationRejectsMissingContractAndTypeConflicts(t *testing.T) { + for _, mutate := range []func(*framework.ProductDefinition[struct{}, struct{}, struct{}]){ + func(d *framework.ProductDefinition[struct{}, struct{}, struct{}]) { d.Name = "" }, + func(d *framework.ProductDefinition[struct{}, struct{}, struct{}]) { d.Roles = nil }, + func(d *framework.ProductDefinition[struct{}, struct{}, struct{}]) { d.GenerateGroup = nil }, + } { + definition := validationDefinition[struct{}, struct{}, struct{}]() + mutate(&definition) + if err := ValidateDefinition(definition); err == nil { + t.Fatal("incomplete static product contract accepted") + } + } + for _, err := range []error{ + ValidateDefinition(validationDefinition[struct{ Value *string }, struct{}, struct{}]()), + ValidateDefinition(validationDefinition[struct{ Resources string }, struct{}, struct{}]()), + ValidateDefinition(validationDefinition[struct{}, struct{ Stopped string }, struct{}]()), + ValidateDefinition(validationDefinition[struct{}, struct{}, any]()), + } { + if err == nil { + t.Fatal("unsupported config/facts or colliding operation field accepted") + } + } + _, err := ResolveConfig(framework.Config[struct { + Resources string `json:"productResources"` + }]{}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("direct resolver bypassed generated Go field collision protection: %v", err) + } +} diff --git a/internal/framework/pipeline/duration_domain.go b/internal/framework/pipeline/duration_domain.go new file mode 100644 index 00000000..27710f1e --- /dev/null +++ b/internal/framework/pipeline/duration_domain.go @@ -0,0 +1,29 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "time" + "unicode/utf8" +) + +const durationInputLimit int64 = 128 + +func validateDurationJSON(data json.RawMessage, path string) error { + var value string + if err := json.Unmarshal(data, &value); err != nil || utf8.RuneCountInString(value) > int(durationInputLimit) { + return fmt.Errorf("%s: duration must be a string of at most %d characters", path, durationInputLimit) + } + _, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("%s: invalid duration: %w", path, err) + } + return nil +} + +func validateShutdownDuration(duration time.Duration) error { + if duration < 0 || duration%time.Second != 0 { + return fmt.Errorf("graceful shutdown duration must be nonnegative and an exact number of seconds") + } + return nil +} diff --git a/internal/framework/pipeline/duration_domain_test.go b/internal/framework/pipeline/duration_domain_test.go new file mode 100644 index 00000000..55ca9173 --- /dev/null +++ b/internal/framework/pipeline/duration_domain_test.go @@ -0,0 +1,92 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + frameworkinput "github.com/zncdatadev/operator-go/pkg/framework/input" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestShutdownDurationPresenceAndExactSeconds(t *testing.T) { + defaults := testDefaults() + defaults.Common.GracefulShutdownTimeout = metav1.Duration{Duration: 30 * time.Second} + for _, item := range []struct { + role, group string + want time.Duration + }{ + {"", "", 30 * time.Second}, + {`{"gracefulShutdownTimeout":"90s"}`, `{"port":9000}`, 90 * time.Second}, + {`{"gracefulShutdownTimeout":"90s"}`, `{"gracefulShutdownTimeout":"0s"}`, 0}, + {"", `{"gracefulShutdownTimeout":"1.5m"}`, 90 * time.Second}, + {"", `{"gracefulShutdownTimeout":"1000ms"}`, time.Second}, + {`{"gracefulShutdownTimeout":"0.5s"}`, `{"gracefulShutdownTimeout":"1s"}`, time.Second}, + {`{"gracefulShutdownTimeout":"-2s"}`, `{"gracefulShutdownTimeout":"3s"}`, 3 * time.Second}, + } { + resolved, err := ResolveConfig(defaults, json.RawMessage(item.role), json.RawMessage(item.group)) + if err != nil || resolved.Common.GracefulShutdownTimeout.Duration != item.want { + t.Fatalf("duration inheritance %q/%q: got=%v want=%v err=%v", + item.role, item.group, resolved.Common.GracefulShutdownTimeout, item.want, err) + } + } + input := struct { + GracefulShutdownTimeout *metav1.Duration `json:"gracefulShutdownTimeout,omitempty"` + }{GracefulShutdownTimeout: &metav1.Duration{}} + layer, err := frameworkinput.ConfigJSON(&input) + if err != nil || string(layer) != `{"gracefulShutdownTimeout":"0s"}` { + t.Fatalf("explicit zero duration disappeared: %s %v", layer, err) + } + copy := CloneInput(input) + copy.GracefulShutdownTimeout.Duration = time.Minute + if input.GracefulShutdownTimeout.Duration != 0 { + t.Fatal("duration presence clone shared its pointer") + } +} + +func TestShutdownDurationSeparatesLayerSyntaxFromEffectivePolicy(t *testing.T) { + for _, value := range []string{`null`, `0`, `""`, + `"9223372036854775807s"`, `"30"`, `"one minute"`} { + role := json.RawMessage(`{"gracefulShutdownTimeout":` + value + `}`) + if _, err := ResolveConfig(testDefaults(), role, + json.RawMessage(`{"gracefulShutdownTimeout":"30s"}`)); err == nil { + t.Fatalf("invalid duration was hidden by a later value: %s", value) + } + } + for _, value := range []string{`"-1s"`, `"1.5s"`, `"1ms"`} { + group := json.RawMessage(`{"gracefulShutdownTimeout":` + value + `}`) + if _, err := ResolveConfig(testDefaults(), nil, group); err == nil { + t.Fatalf("invalid effective shutdown policy was accepted: %s", value) + } + } + for _, value := range []time.Duration{-time.Second, time.Millisecond} { + defaults := testDefaults() + defaults.Common.GracefulShutdownTimeout.Duration = value + if _, err := ResolveConfig(defaults, nil, nil); err == nil { + t.Fatalf("invalid effective default was accepted: %v", value) + } + } + if err := checkProfile(reflect.TypeFor[struct { + Period metav1.Duration `json:"period"` + }](), make(map[reflect.Type]bool)); err != nil { + t.Fatalf("known duration scalar is not supported: %v", err) + } + if err := checkProfile(reflect.TypeFor[struct { + Period *metav1.Duration `json:"period"` + }](), make(map[reflect.Type]bool)); err == nil { + t.Fatal("known scalar support opened arbitrary product pointer fields") + } +} + +func TestProductDurationDoesNotInheritShutdownPolicy(t *testing.T) { + type product struct { + Offset metav1.Duration `json:"offset"` + } + defaults := framework.Config[product]{Common: testDefaults().Common} + resolved, err := ResolveConfig(defaults, nil, json.RawMessage(`{"offset":"-0.5s"}`)) + if err != nil || resolved.Product.Offset.Duration != -500*time.Millisecond { + t.Fatalf("known duration scalar received unrelated shutdown policy: %+v %v", resolved.Product, err) + } +} diff --git a/internal/framework/pipeline/file_overrides.go b/internal/framework/pipeline/file_overrides.go new file mode 100644 index 00000000..2db35063 --- /dev/null +++ b/internal/framework/pipeline/file_overrides.go @@ -0,0 +1,221 @@ +package pipeline + +import ( + "fmt" + "maps" + "path" + "slices" + "strings" +) + +// ValidateFileOverride checks action syntax, not a file's encoding capability. +func ValidateFileOverride(override FileOverride) error { + modes := 0 + for _, present := range []bool{ + override.Properties != nil, override.Lines != nil, override.Text != nil, override.Remove != nil, + } { + if present { + modes++ + } + } + if modes != 1 { + return fmt.Errorf("exactly one of properties, lines, text or remove is required") + } + if override.Remove != nil && !*override.Remove { + return fmt.Errorf("remove must be true") + } + if override.Lines != nil { + if *override.Lines == nil { + return fmt.Errorf("lines cannot be null") + } + if err := validateContent(Lines(*override.Lines)); err != nil { + return err + } + } + if override.Properties != nil { + return validatePropertyOverride(*override.Properties) + } + return nil +} + +func validatePropertyOverride(override PropertyOverride) error { + if override.Set != nil && *override.Set == nil { + return fmt.Errorf("properties.set cannot be null") + } + if override.Remove != nil && *override.Remove == nil { + return fmt.Errorf("properties.remove cannot be null") + } + if override.Replace != nil && *override.Replace == nil { + return fmt.Errorf("properties.replace cannot be null") + } + if override.Replace != nil && (override.Set != nil || override.Remove != nil) { + return fmt.Errorf("properties.replace cannot be combined with set or remove") + } + if override.Set != nil && override.Remove != nil { + for _, key := range *override.Remove { + if _, exists := (*override.Set)[key]; exists { + return fmt.Errorf("properties key %q is both set and removed", key) + } + } + } + return nil +} + +type overrideFileKey struct{ directory, path string } + +// ApplyFileOverrides executes each layer as actions against current contents. +// Only configDirectory is addressable. Other directories are independently +// copied, and the result is sorted by directory/path. Codecs are retained as +// stateless capabilities; deleting a file never resurrects its original values. +func ApplyFileOverrides( + original []File, configDirectory string, layers ...map[string]FileOverride, +) ([]File, error) { + if configDirectory == "" { + return nil, fmt.Errorf("config directory is required") + } + current, codecs, err := initialOverrideFiles(original, configDirectory) + if err != nil { + return nil, err + } + for index, layer := range layers { + for _, name := range slices.Sorted(maps.Keys(layer)) { + override := layer[name] + if !relativeFile(name) { + return nil, fmt.Errorf("layer %d file %q: invalid relative path", index+1, name) + } + if err := ValidateFileOverride(override); err != nil { + return nil, fmt.Errorf("layer %d file %q: %w", index+1, name, err) + } + key := overrideFileKey{configDirectory, name} + if err := applyFileOverride(current, key, codecs[name], override); err != nil { + return nil, fmt.Errorf("layer %d file %q: %w", index+1, name, err) + } + } + // A layer is a map, so deleting a parent and creating a child in that + // same layer must not depend on the lexical order of the file names. + if err := validateOverrideFilePaths(current); err != nil { + return nil, fmt.Errorf("layer %d: %w", index+1, err) + } + } + return sortedOverrideFiles(current), nil +} + +func initialOverrideFiles( + original []File, configDirectory string, +) (map[overrideFileKey]File, map[string]PropertyCodec, error) { + current := make(map[overrideFileKey]File, len(original)) + codecs := map[string]PropertyCodec{} + for _, file := range original { + key := overrideFileKey{file.Directory, file.Path} + if file.Directory == "" || !relativeFile(file.Path) { + return nil, nil, fmt.Errorf("original file %q/%q: invalid directory or relative path", file.Directory, file.Path) + } + if _, exists := current[key]; exists { + return nil, nil, fmt.Errorf("original file %q/%q: duplicate path", file.Directory, file.Path) + } + if err := validateContent(file.Content); err != nil { + return nil, nil, fmt.Errorf("original file %q/%q: %w", file.Directory, file.Path, err) + } + current[key] = cloneFileForOverride(file) + if kv, ok := file.Content.(KeyValues); ok && file.Directory == configDirectory { + codecs[file.Path] = kv.Codec + } + } + if err := validateOverrideFilePaths(current); err != nil { + return nil, nil, fmt.Errorf("original files: %w", err) + } + return current, codecs, nil +} + +func cloneFileForOverride(file File) File { + switch content := file.Content.(type) { + case KeyValues: + content.Values = maps.Clone(content.Values) + file.Content = content + case Lines: + file.Content = slices.Clone(content) + } + return file +} + +func applyFileOverride( + current map[overrideFileKey]File, key overrideFileKey, codec PropertyCodec, override FileOverride, +) error { + file := File{Directory: key.directory, Path: key.path} + switch { + case override.Remove != nil: + delete(current, key) + case override.Text != nil: + file.Content = Text(*override.Text) + current[key] = file + case override.Lines != nil: + file.Content = Lines(slices.Clone(*override.Lines)) + current[key] = file + case override.Properties != nil: + return applyPropertyOverride(current, key, codec, *override.Properties) + } + return nil +} + +func applyPropertyOverride( + current map[overrideFileKey]File, key overrideFileKey, codec PropertyCodec, override PropertyOverride, +) error { + if nilCodec(codec) { + return fmt.Errorf("properties require an original structured encoding declaration") + } + content := KeyValues{Codec: codec, Values: map[string]PropertyValue{}} + if override.Replace != nil { + for name, value := range *override.Replace { + content.Values[name] = Literal(value) + } + current[key] = File{Directory: key.directory, Path: key.path, Content: content} + return nil + } + if file, exists := current[key]; exists { + var structured bool + content, structured = file.Content.(KeyValues) + if !structured { + return fmt.Errorf("properties patch cannot edit text or lines; use properties.replace") + } + } else if override.Set == nil || len(*override.Set) == 0 { + // An empty patch or removal of keys cannot recreate a deleted file. + return nil + } + if content.Values == nil { + content.Values = map[string]PropertyValue{} + } + if override.Set != nil { + for name, value := range *override.Set { + content.Values[name] = Literal(value) + } + } + if override.Remove != nil { + for _, name := range *override.Remove { + delete(content.Values, name) + } + } + current[key] = File{Directory: key.directory, Path: key.path, Content: content} + return nil +} + +func validateOverrideFilePaths(current map[overrideFileKey]File) error { + for _, file := range sortedOverrideFiles(current) { + for parent := path.Dir(file.Path); parent != "."; parent = path.Dir(parent) { + if _, exists := current[overrideFileKey{file.Directory, parent}]; exists { + return fmt.Errorf("file %q/%q conflicts with parent file %q", file.Directory, file.Path, parent) + } + } + } + return nil +} + +func sortedOverrideFiles(current map[overrideFileKey]File) []File { + files := slices.Collect(maps.Values(current)) + slices.SortFunc(files, func(a, b File) int { + if directory := strings.Compare(a.Directory, b.Directory); directory != 0 { + return directory + } + return strings.Compare(a.Path, b.Path) + }) + return files +} diff --git a/internal/framework/pipeline/file_overrides_test.go b/internal/framework/pipeline/file_overrides_test.go new file mode 100644 index 00000000..0bd72936 --- /dev/null +++ b/internal/framework/pipeline/file_overrides_test.go @@ -0,0 +1,218 @@ +package pipeline + +import ( + "reflect" + "strings" + "testing" +) + +func overridePtr[T any](value T) *T { return &value } + +func propertySet(values map[string]string) FileOverride { + return FileOverride{Properties: &PropertyOverride{Set: &values}} +} + +func propertyReplace(values map[string]string) FileOverride { + return FileOverride{Properties: &PropertyOverride{Replace: &values}} +} + +func TestFileOverridesExecuteLayersAndCancelBindings(t *testing.T) { + deleted := FileOverride{Remove: overridePtr(true)} + emptyPatch := FileOverride{Properties: &PropertyOverride{}} + removeIdentity := FileOverride{Properties: &PropertyOverride{Remove: overridePtr([]string{"node.id"})}} + content := func(values map[string]PropertyValue) FileContent { + return KeyValues{Codec: &unusedCodec{}, Values: values} + } + cases := []struct { + name string + actions []FileOverride + want FileContent + }{ + {"empty-patch-keeps-binding", []FileOverride{emptyPatch}, content(map[string]PropertyValue{ + "node.environment": Literal("test"), "node.id": PodNameBinding{}, + })}, + {"literal-cancels-binding", []FileOverride{propertySet(map[string]string{"node.id": ""})}, + content(map[string]PropertyValue{"node.environment": Literal("test"), "node.id": Literal("")})}, + {"remove-key-cancels-binding", []FileOverride{removeIdentity}, + content(map[string]PropertyValue{"node.environment": Literal("test")})}, + {"set-after-remove", []FileOverride{removeIdentity, propertySet(map[string]string{"node.id": "new"})}, + content(map[string]PropertyValue{"node.environment": Literal("test"), "node.id": Literal("new")})}, + {"replace-is-empty-file", []FileOverride{propertyReplace(map[string]string{})}, content(map[string]PropertyValue{})}, + {"text-is-empty-file", []FileOverride{{Text: overridePtr("")}}, Text("")}, + {"lines-is-empty-file", []FileOverride{{Lines: overridePtr([]string{})}}, Lines{}}, + {"remove-is-absent", []FileOverride{deleted}, nil}, + {"empty-patch-after-remove-stays-absent", []FileOverride{deleted, emptyPatch}, nil}, + {"empty-set-after-remove-stays-absent", []FileOverride{deleted, propertySet(map[string]string{})}, nil}, + {"key-remove-after-file-remove-stays-absent", []FileOverride{deleted, removeIdentity}, nil}, + {"recreate-does-not-restore-defaults", []FileOverride{deleted, propertySet(map[string]string{"node.id": "new"})}, + content(map[string]PropertyValue{"node.id": Literal("new")})}, + {"recreate-empty-structured-file", []FileOverride{deleted, propertyReplace(map[string]string{})}, + content(map[string]PropertyValue{})}, + {"replace-after-text-keeps-codec-only", []FileOverride{ + {Text: overridePtr("foreign")}, + propertyReplace(map[string]string{"remove": "literal", "set": "also-literal", "a.b": "a=b"}), + }, content(map[string]PropertyValue{ + "remove": Literal("literal"), "set": Literal("also-literal"), "a.b": Literal("a=b"), + })}, + {"replace-after-lines-keeps-codec-only", []FileOverride{{Lines: overridePtr([]string{"foreign"})}, + propertyReplace(map[string]string{})}, content(map[string]PropertyValue{})}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + original := runtimeFixture().Files + layers := make([]map[string]FileOverride, 0, len(tc.actions)) + for _, action := range tc.actions { + layers = append(layers, map[string]FileOverride{"node.properties": action}) + } + files, err := ApplyFileOverrides(original, "config", layers...) + if err != nil { + t.Fatal(err) + } + if tc.want == nil { + if len(files) != 0 { + t.Fatalf("deleted file reappeared: %#v", files) + } + } else if len(files) != 1 || !reflect.DeepEqual(files[0].Content, tc.want) { + t.Fatalf("wanted %#v, got %#v", tc.want, files) + } + if !reflect.DeepEqual(original, runtimeFixture().Files) { + t.Fatal("overrides mutated the original declaration") + } + }) + } +} + +func TestFileOverridesRejectAmbiguousActions(t *testing.T) { + cases := []struct { + name, want string + override FileOverride + }{ + {"empty-action", "exactly one", FileOverride{}}, + {"two-modes", "exactly one", FileOverride{Text: overridePtr(""), Remove: overridePtr(true)}}, + {"false-remove", "must be true", FileOverride{Remove: overridePtr(false)}}, + {"nil-lines", "cannot be null", FileOverride{Lines: overridePtr([]string(nil))}}, + {"nil-set", "cannot be null", propertySet(nil)}, + {"nil-replace", "cannot be null", propertyReplace(nil)}, + {"nil-key-remove", "cannot be null", FileOverride{ + Properties: &PropertyOverride{Remove: overridePtr([]string(nil))}, + }}, + {"replace-and-empty-set", "cannot be combined", FileOverride{Properties: &PropertyOverride{ + Set: overridePtr(map[string]string{}), Replace: overridePtr(map[string]string{}), + }}}, + {"replace-and-empty-remove", "cannot be combined", FileOverride{Properties: &PropertyOverride{ + Remove: overridePtr([]string{}), Replace: overridePtr(map[string]string{}), + }}}, + {"set-and-remove-same-key", "both set and removed", FileOverride{Properties: &PropertyOverride{ + Set: overridePtr(map[string]string{"a.b": ""}), Remove: overridePtr([]string{"a.b"}), + }}}, + {"embedded-newline", "CR or LF", FileOverride{Lines: overridePtr([]string{"a\nb"})}}, + {"embedded-carriage-return", "CR or LF", FileOverride{Lines: overridePtr([]string{"a\rb"})}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateFileOverride(tc.override) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("wanted %q, got %v", tc.want, err) + } + }) + } +} + +func TestFileOverridesKeepEncodingCapabilitiesAndReportSources(t *testing.T) { + cases := []struct { + name, path, want string + role, group FileOverride + }{ + {"unknown-extension", "new.properties", "original structured encoding", FileOverride{Text: overridePtr("a=1")}, + propertyReplace(map[string]string{"a": "2"})}, + {"unknown-empty-patch", "new.properties", "original structured encoding", FileOverride{Remove: overridePtr(true)}, + FileOverride{Properties: &PropertyOverride{}}}, + {"patch-after-text", "node.properties", "cannot edit text or lines", FileOverride{Text: overridePtr("a=1")}, + propertySet(map[string]string{"a": "2"})}, + {"empty-patch-after-lines", "node.properties", "cannot edit text or lines", + FileOverride{Lines: overridePtr([]string{})}, FileOverride{Properties: &PropertyOverride{}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ApplyFileOverrides(runtimeFixture().Files, "config", + map[string]FileOverride{tc.path: tc.role}, map[string]FileOverride{tc.path: tc.group}) + if err == nil || !strings.Contains(err.Error(), tc.want) || + !strings.Contains(err.Error(), "layer 2 file "+`"`+tc.path+`"`) { + t.Fatalf("expected layer and file diagnostic containing %q, got %v", tc.want, err) + } + }) + } + original := []File{{Directory: "config", Path: "plain.properties", Content: Text("a=1")}} + if _, err := ApplyFileOverrides(original, "config", map[string]FileOverride{ + "plain.properties": propertyReplace(map[string]string{}), + }); err == nil { + t.Fatal("a file extension must not create a codec for an original text declaration") + } +} + +func TestFileOverridesRespectDirectoryNamespacesAndOwnership(t *testing.T) { + original := runtimeFixture().Files + other := cloneFileForOverride(original[0]) + other.Directory = "platform" + original = append(original, other) + lines := []string{"first", "second=a=b"} + files, err := ApplyFileOverrides(original, "config", map[string]FileOverride{ + "node.properties": {Remove: overridePtr(true)}, + "custom.conf": {Lines: &lines}, + "missing.conf": {Remove: overridePtr(true)}, + "exact.conf": {Text: overridePtr("first\nlast")}, + }) + if err != nil { + t.Fatal(err) + } + if len(files) != 3 || files[0].Directory != "config" || files[0].Path != "custom.conf" || + files[1].Content != Text("first\nlast") || files[2].Directory != "platform" { + t.Fatalf("namespace or deterministic ordering lost: %#v", files) + } + files[0].Content.(Lines)[0] = "changed" + files[2].Content.(KeyValues).Values["node.id"] = Literal("changed") + if lines[0] != "first" { + t.Fatal("returned lines alias the override input") + } + if _, bound := original[1].Content.(KeyValues).Values["node.id"].(PodNameBinding); !bound { + t.Fatal("a non-target directory aliases the original declaration") + } + // Neither a structured declaration in another directory nor an extension + // grants the target directory an encoding capability. + if _, err := ApplyFileOverrides([]File{other}, "config", map[string]FileOverride{ + "node.properties": propertyReplace(map[string]string{}), + }); err == nil { + t.Fatal("codec from another directory leaked into the target namespace") + } +} + +func TestFileOverridesRejectPathCollisions(t *testing.T) { + for _, name := range []string{"", ".", "..", "../a", "/absolute", "a/../b", "a/", "a\x00b"} { + t.Run(name, func(t *testing.T) { + _, err := ApplyFileOverrides(nil, "config", map[string]FileOverride{name: {Text: overridePtr("")}}) + if err == nil || !strings.Contains(err.Error(), "layer 1 file") || !strings.Contains(err.Error(), "relative path") { + t.Fatalf("wanted layer/path diagnostic for %q, got %v", name, err) + } + }) + } + original := []File{ + {Directory: "config", Path: "a", Content: Text("parent")}, + {Directory: "config", Path: "a-file", Content: Text("sorts before a/b")}, + } + if _, err := ApplyFileOverrides(append(original, original[0]), "config"); err == nil || + !strings.Contains(err.Error(), "duplicate path") { + t.Fatalf("duplicate original path accepted: %v", err) + } + child := map[string]FileOverride{"a/b": {Text: overridePtr("")}} + if _, err := ApplyFileOverrides(original, "config", child); err == nil || + !strings.Contains(err.Error(), "layer 1") || !strings.Contains(err.Error(), `"a/b"`) { + t.Fatalf("prefix collision hidden by another lexically adjacent file: %v", err) + } + child["a"] = FileOverride{Remove: overridePtr(true)} + if files, err := ApplyFileOverrides(original, "config", child); err != nil || len(files) != 2 { + t.Fatalf("same-layer replacement of parent by child should be order independent: %v, %v", files, err) + } + if _, err := ApplyFileOverrides(nil, ""); err == nil { + t.Fatal("an absent config directory must not create an implicit namespace") + } +} diff --git a/internal/framework/pipeline/generation.go b/internal/framework/pipeline/generation.go new file mode 100644 index 00000000..3fb592b4 --- /dev/null +++ b/internal/framework/pipeline/generation.go @@ -0,0 +1,120 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func copyJSON[T any](value T) (T, error) { + var out T + data, err := json.Marshal(value) + if err != nil { + return out, err + } + err = json.Unmarshal(data, &out) + return out, err +} + +func resolveTopology[C, S, F any]( + definition framework.ProductDefinition[C, S, F], source SourceSnapshot[F], +) ([]framework.ResolvedGroup[C], error) { + identities, err := SourceGroupIdentities(source) + if err != nil { + return nil, err + } + layers := make(map[string]GroupSource, len(source.Groups)) + for _, group := range source.Groups { + layers[group.Role+"/"+group.Name] = group + } + topology := make([]framework.ResolvedGroup[C], 0, len(identities)) + for _, identity := range identities { + group := layers[identity.Role+"/"+identity.Name] + resolved := framework.ResolvedGroup[C]{Group: identity} + defaults, ok := definition.Roles[group.Role] + if !ok { + resolved.Error = "role is not declared by the product" + } else if config, err := ResolveConfig(defaults.Config, group.RoleConfigLayer, group.Config); err != nil { + resolved.Error = err.Error() + } else { + resolved.Config = &config + } + topology = append(topology, resolved) + } + return topology, nil +} + +func snapshotInput[C, S, F any]( + group framework.ResolvedGroup[C], clusterConfig S, image ResolvedImage, shared F, + topology []framework.ResolvedGroup[C], +) (framework.EffectiveInput[C, S, F], error) { + return copyJSON(framework.EffectiveInput[C, S, F]{Group: group.Group, Config: *group.Config, + ClusterConfig: clusterConfig, Image: image, Facts: shared, Topology: topology}) +} + +// Input validators all see the same resolved snapshot, not one another's partial +// validation results. Generators then see all input failures. This is not a +// dependency scheduler: a later generation failure is only in GroupOutcome. +func validateTopology[C, S, F any]( + definition framework.ProductDefinition[C, S, F], platform framework.ClusterConfig, clusterConfig S, + image ResolvedImage, shared F, + resolved []framework.ResolvedGroup[C], facts map[GroupKey]framework.FactResult[F], +) ([]framework.ResolvedGroup[C], error) { + if definition.ValidateInput == nil { + return resolved, nil + } + validated := slices.Clone(resolved) + for i, group := range resolved { + if group.Error != "" { + continue + } + value := shared + if fact, ok := facts[GroupKey{Role: group.Group.Role, Name: group.Group.Name}]; ok { + if fact.Diagnostic.State != FactsResolved { + continue // unavailable facts do not invalidate declared configuration + } + value = *fact.Value + } + input, err := snapshotInput(group, clusterConfig, image, value, resolved) + if err != nil { + return nil, fmt.Errorf("validation snapshot: %w", err) + } + input.Platform = CloneInput(platform) + if err := definition.ValidateInput(input); err != nil { + validated[i].Config = nil + validated[i].Error = err.Error() + } + } + return validated, nil +} + +func generateOne[C, S, F any]( + definition framework.ProductDefinition[C, S, F], input framework.EffectiveInput[C, S, F], +) (*RuntimeDescription, error) { + generationInput, err := copyJSON(input) + if err != nil { + return nil, err + } + runtime, err := definition.GenerateGroup(generationInput) + if err != nil { + return nil, err + } + runtime = CloneRuntime(runtime) + if runtime.Main.Image == "" { + runtime.Main.Image = input.Image.Reference + } else if runtime.Main.Image != input.Image.Reference { + return nil, fmt.Errorf("Main.Image %q conflicts with resolved spec.image %q", + runtime.Main.Image, input.Image.Reference) + } + for i := range runtime.Initializers { + if runtime.Initializers[i].Image == "" { + runtime.Initializers[i].Image = runtime.Main.Image + } + } + if err := ValidateRuntime(runtime); err != nil { + return nil, err + } + return &runtime, nil +} diff --git a/internal/framework/pipeline/helpers.go b/internal/framework/pipeline/helpers.go new file mode 100644 index 00000000..dae63671 --- /dev/null +++ b/internal/framework/pipeline/helpers.go @@ -0,0 +1,33 @@ +package pipeline + +import ( + "reflect" + "slices" + + corev1 "k8s.io/api/core/v1" +) + +func findContainer(pod corev1.PodTemplateSpec, name string) *corev1.Container { + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == name { + return &pod.Spec.Containers[i] + } + } + return nil +} + +func findFile(files []File, directory, name string) *File { + for i := range files { + if files[i].Directory == directory && files[i].Path == name { + return &files[i] + } + } + return nil +} + +func processPremise(process Process, container *corev1.Container) bool { + return container != nil && len(container.EnvFrom) == 0 && container.WorkingDir == "" && + process.Image == container.Image && slices.Equal(process.Command, container.Command) && + slices.Equal(process.Args, container.Args) && slices.EqualFunc(process.Env, container.Env, + func(a, b corev1.EnvVar) bool { return reflect.DeepEqual(a, b) }) +} diff --git a/internal/framework/pipeline/image_config.go b/internal/framework/pipeline/image_config.go new file mode 100644 index 00000000..7c2e85a7 --- /dev/null +++ b/internal/framework/pipeline/image_config.go @@ -0,0 +1,155 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/validation" + kubernetesjson "sigs.k8s.io/json" +) + +// ResolveImage chooses a source by presence, then validates its effective value. +// It does not contact a registry, infer versions from custom, or add a latest tag. +func ResolveImage(productName string, defaults ImageConfig, raw json.RawMessage) (ResolvedImage, error) { + input, err := decodeImageInput(raw) + if err != nil { + return ResolvedImage{}, err + } + resolved := ResolvedImage{PullPolicy: defaults.PullPolicy, PullSecretName: defaults.PullSecretName} + if input.PullPolicy != nil { + resolved.PullPolicy = *input.PullPolicy + } + if input.PullSecretName != nil { + resolved.PullSecretName = *input.PullSecretName + } + switch resolved.PullPolicy { + case corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever: + default: + return ResolvedImage{}, fmt.Errorf("image.pullPolicy must explicitly be Always, IfNotPresent or Never") + } + if resolved.PullSecretName != "" && len(validation.IsDNS1123Subdomain(resolved.PullSecretName)) != 0 { + return ResolvedImage{}, fmt.Errorf("image.pullSecretName must be empty or a valid Secret name") + } + resolved.Reference, err = resolveImageSource(productName, defaults, input) + if err != nil { + return ResolvedImage{}, err + } + if err := validateImageReference(resolved.Reference); err != nil { + return ResolvedImage{}, fmt.Errorf("image reference: %w", err) + } + return resolved, nil +} + +func decodeImageInput(raw json.RawMessage) (ImageInput, error) { + var input ImageInput + if len(raw) == 0 { + return input, nil + } + if err := validateJSON(raw, reflect.TypeFor[ImageConfig](), "image"); err != nil { + return input, err + } + strict, err := kubernetesjson.UnmarshalStrict(raw, &input) + if failure := errors.Join(err, errors.Join(strict...)); failure != nil { + return ImageInput{}, fmt.Errorf("image: %w", failure) + } + return input, nil +} + +func resolveImageSource(productName string, defaults ImageConfig, input ImageInput) (string, error) { + if input.Custom != nil && *input.Custom != "" { + return *input.Custom, nil + } + structured := input.Custom != nil || input.Repo != nil || input.ProductVersion != nil || input.KubedoopVersion != nil + if !structured && defaults.Custom != "" { + return defaults.Custom, nil + } + repo, productVersion, kubedoopVersion := defaults.Repo, defaults.ProductVersion, defaults.KubedoopVersion + for _, field := range []struct { + input *string + effective *string + }{ + {input.Repo, &repo}, {input.ProductVersion, &productVersion}, {input.KubedoopVersion, &kubedoopVersion}, + } { + if field.input != nil { + *field.effective = *field.input + } + } + if repo == "" || productVersion == "" { + return "", fmt.Errorf("structured image requires nonempty repo and productVersion") + } + if !imagePathComponent.MatchString(productName) { + return "", fmt.Errorf("structured image requires a valid product repository component") + } + tag := productVersion + if kubedoopVersion != "" { + tag += "-kubedoop" + kubedoopVersion + } + if !imageTag.MatchString(tag) { + return "", fmt.Errorf("image versions must produce a valid tag of at most 128 characters") + } + return repo + "/" + productName + ":" + tag, nil +} + +var ( + imagePathComponent = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$`) + imageTag = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`) + imageDigest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) +) + +// This is a deliberately bounded reference profile, not a registry resolver: +// lowercase names, DNS/IPv4 registries with optional ports, tags, and SHA-256. +// IPv6 registries and other digest algorithms are rejected explicitly. Grammar +// reference: github.com/distribution/reference v0.6.0, reference.go and regexp.go. +func validateImageReference(reference string) error { + name, digest, hasDigest := strings.Cut(reference, "@") + if hasDigest && !imageDigest.MatchString(digest) { + return fmt.Errorf("only a sha256 digest with 64 lowercase hexadecimal digits is supported") + } + if colon := strings.LastIndex(name, ":"); colon > strings.LastIndex(name, "/") { + if !imageTag.MatchString(name[colon+1:]) { + return fmt.Errorf("invalid or empty image tag") + } + name = name[:colon] + } + if len(name) == 0 || len(name) > 255 { + return fmt.Errorf("repository name must contain 1 to 255 characters") + } + components := strings.Split(name, "/") + if len(components) > 1 && (strings.ContainsAny(components[0], ".:") || components[0] == "localhost") { + if err := validateImageRegistry(components[0]); err != nil { + return err + } + components = components[1:] + } + for _, component := range components { + if !imagePathComponent.MatchString(component) { + return fmt.Errorf("repository path must contain lowercase image name components") + } + } + return nil +} + +func validateImageRegistry(registry string) error { + host, port, hasPort := strings.Cut(registry, ":") + if len(validation.IsDNS1123Subdomain(host)) != 0 { + return fmt.Errorf("image registry must be a lowercase DNS name or IPv4 address; IPv6 is unsupported") + } + if hasPort { + for _, char := range port { + if char < '0' || char > '9' { + return fmt.Errorf("image registry port must be numeric") + } + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return fmt.Errorf("image registry port must be between 1 and 65535") + } + } + return nil +} diff --git a/internal/framework/pipeline/image_config_test.go b/internal/framework/pipeline/image_config_test.go new file mode 100644 index 00000000..857dd824 --- /dev/null +++ b/internal/framework/pipeline/image_config_test.go @@ -0,0 +1,165 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +const ( + imageTestProduct = "trino" + imageTestCustom = "registry.example/team/custom:fixed" +) + +func imageTestDefaults() ImageConfig { + return ImageConfig{Custom: imageTestCustom, Repo: "quay.io/zncdatadev", + ProductVersion: "476", KubedoopVersion: "0.2.0", PullPolicy: corev1.PullIfNotPresent, PullSecretName: "pull-secret"} +} + +func TestImageSourceSelectionUsesPresence(t *testing.T) { + for _, item := range []struct{ name, raw, reference string }{ + {"omitted", "", imageTestCustom}, + {"empty object", `{}`, imageTestCustom}, + {"policy only", `{"pullPolicy":"Never"}`, imageTestCustom}, + {"secret only", `{"pullSecretName":"other"}`, imageTestCustom}, + {"custom wins", `{"custom":"busybox:stable","repo":"","productVersion":""}`, "busybox:stable"}, + {"clear custom", `{"custom":""}`, "quay.io/zncdatadev/trino:476-kubedoop0.2.0"}, + {"repo switches source", `{"repo":"localhost:5000/team"}`, "localhost:5000/team/trino:476-kubedoop0.2.0"}, + {"product switches source", `{"productVersion":"477"}`, "quay.io/zncdatadev/trino:477-kubedoop0.2.0"}, + {"kubedoop switches source", `{"kubedoopVersion":"1"}`, "quay.io/zncdatadev/trino:476-kubedoop1"}, + {"clear kubedoop", `{"kubedoopVersion":""}`, "quay.io/zncdatadev/trino:476"}, + } { + t.Run(item.name, func(t *testing.T) { + defaults := imageTestDefaults() + before := defaults + raw := json.RawMessage(item.raw) + resolved, err := ResolveImage(imageTestProduct, defaults, raw) + if err != nil || resolved.Reference != item.reference { + t.Fatalf("wrong selected source: %+v %v", resolved, err) + } + if defaults != before || string(raw) != item.raw { + t.Fatal("image resolution mutated its inputs") + } + }) + } + defaults := imageTestDefaults() + defaults.Custom = "" + resolved, err := ResolveImage(imageTestProduct, defaults, nil) + if err != nil || resolved.Reference != "quay.io/zncdatadev/trino:476-kubedoop0.2.0" { + t.Fatalf("default structured source was not inherited: %+v %v", resolved, err) + } +} + +func TestImageExplicitEmptyValuesAreNotOmission(t *testing.T) { + for _, raw := range []string{`{"repo":""}`, `{"productVersion":""}`, `{"pullPolicy":""}`} { + if _, err := ResolveImage(imageTestProduct, imageTestDefaults(), json.RawMessage(raw)); err == nil { + t.Fatalf("explicit empty required value was ignored: %s", raw) + } + } + resolved, err := ResolveImage(imageTestProduct, imageTestDefaults(), json.RawMessage(`{"pullSecretName":""}`)) + if err != nil || resolved.PullSecretName != "" || resolved.Reference != imageTestDefaults().Custom { + t.Fatalf("pull Secret could not be cleared independently: %+v %v", resolved, err) + } + empty := "" + input := ImageInput{Custom: &empty, KubedoopVersion: &empty, PullSecretName: &empty} + raw, err := json.Marshal(input) + if err != nil || string(raw) != `{"custom":"","kubedoopVersion":"","pullSecretName":""}` { + t.Fatalf("input pointers lost explicit empty strings: %s %v", raw, err) + } + var decoded ImageInput + if err := json.Unmarshal(raw, &decoded); err != nil || !reflect.DeepEqual(decoded, input) { + t.Fatalf("image presence roundtrip failed: %+v %v", decoded, err) + } +} + +func TestImagePullPolicyIsExplicitAndIndependent(t *testing.T) { + for _, policy := range []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever} { + defaults := imageTestDefaults() + defaults.PullPolicy = "" + raw, err := json.Marshal(ImageInput{PullPolicy: &policy}) + if err != nil { + t.Fatal(err) + } + resolved, err := ResolveImage(imageTestProduct, defaults, raw) + if err != nil || resolved.PullPolicy != policy || resolved.Reference != defaults.Custom { + t.Fatalf("explicit policy could not fill an omitted default: %+v %v", resolved, err) + } + } + for _, policy := range []corev1.PullPolicy{"", "always", "Sometimes"} { + defaults := imageTestDefaults() + defaults.PullPolicy = policy + if _, err := ResolveImage(imageTestProduct, defaults, nil); err == nil { + t.Fatalf("invalid effective policy was guessed or accepted: %q", policy) + } + } + defaults := imageTestDefaults() + defaults.PullPolicy, defaults.PullSecretName = "invalid", "INVALID" + resolved, err := ResolveImage(imageTestProduct, defaults, + json.RawMessage(`{"pullPolicy":"Always","pullSecretName":""}`)) + if err != nil || resolved.PullPolicy != corev1.PullAlways || resolved.PullSecretName != "" { + t.Fatalf("explicit valid values could not repair defaults: %+v %v", resolved, err) + } +} + +func TestImageInputRejectsUnknownNullAndDuplicate(t *testing.T) { + for _, raw := range []string{ + `null`, `[]`, `"image"`, `{`, `{"unknown":"x"}`, `{"Custom":"busybox"}`, + `{"custom":null}`, `{"repo":null}`, `{"productVersion":null}`, `{"kubedoopVersion":null}`, + `{"pullPolicy":null}`, `{"pullSecretName":null}`, `{"custom":123}`, + `{"custom":"first:1","custom":"second:2"}`, `{"repo":"one","repo":"two"}`, + `{"pullSecretName":"first","pullSecretName":""}`, + } { + if _, err := ResolveImage(imageTestProduct, imageTestDefaults(), json.RawMessage(raw)); err == nil { + t.Fatalf("ambiguous or malformed image input was accepted: %s", raw) + } + } +} + +func TestImageCustomReferencesArePreservedWithoutVersionInference(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + for _, reference := range []string{ + "busybox", "busybox:latest", "quay.io/zncdatadev/trino:476", "localhost:5000/team/trino:v1", + "127.0.0.1:5000/team/image:Tag_1", "team/part__name--next:stable", "trino@" + digest, "trino:476@" + digest, + } { + raw, err := json.Marshal(map[string]string{"custom": reference}) + if err != nil { + t.Fatal(err) + } + resolved, err := ResolveImage("", imageTestDefaults(), raw) + if err != nil || resolved.Reference != reference { + t.Fatalf("custom reference was rewritten or rejected: %q %+v %v", reference, resolved, err) + } + } +} + +func TestImageRejectsInvalidSelectedReferences(t *testing.T) { + for _, reference := range []string{ + " ", "https://quay.io/team/trino:476", "quay.io/Team/trino:476", "trino:", "trino:bad tag", + "team//trino:1", "team/../trino:1", "team/trino:1\n", "localhost:0/trino", "localhost:65536/trino", + "localhost:abc/trino", "trino@sha256:abc", "trino@sha512:" + strings.Repeat("a", 128), + "trino@sha256:" + strings.Repeat("a", 64) + "@sha256:" + strings.Repeat("b", 64), + "[::1]:5000/trino:1", "trino:" + strings.Repeat("a", 129), strings.Repeat("a", 256), + } { + raw, err := json.Marshal(map[string]string{"custom": reference}) + if err != nil { + t.Fatal(err) + } + if _, err := ResolveImage(imageTestProduct, imageTestDefaults(), raw); err == nil { + t.Fatalf("invalid reference was accepted: %q", reference) + } + } + for _, raw := range []string{ + `{"repo":"quay.io/team/"}`, `{"productVersion":"bad/tag"}`, `{"kubedoopVersion":"bad tag"}`, + `{"pullSecretName":"Bad_Name"}`, + } { + if _, err := ResolveImage(imageTestProduct, imageTestDefaults(), json.RawMessage(raw)); err == nil { + t.Fatalf("invalid effective image setting was accepted: %s", raw) + } + } + if _, err := ResolveImage("../other", imageTestDefaults(), json.RawMessage(`{"custom":""}`)); err == nil { + t.Fatal("product name escaped its repository component") + } +} diff --git a/internal/framework/pipeline/input.go b/internal/framework/pipeline/input.go new file mode 100644 index 00000000..b85ad604 --- /dev/null +++ b/internal/framework/pipeline/input.go @@ -0,0 +1,280 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var quantityType = reflect.TypeFor[resource.Quantity]() +var durationType = reflect.TypeFor[metav1.Duration]() +var affinityType = reflect.TypeFor[corev1.Affinity]() + +const jsonNull = "null" + +// ResolveConfig is a bounded fixed-rule interpreter, not a schema/type generator. +// C must be a non-embedded public struct using ordinary JSON scalars, structs, +// string-keyed maps, slices, and Quantity/Duration leaves. Pointers, interfaces, byte +// slices and custom codecs are unsupported. No field can select a merge policy. +// Objects inherit by field/key; sequences replace. Each user layer is checked +// before merging, so a later layer cannot conceal an invalid earlier layer. +func ResolveConfig[C any](defaults framework.Config[C], role, group json.RawMessage) (framework.Config[C], error) { + var empty framework.Config[C] + commonType, productType := reflect.TypeFor[CommonConfig](), reflect.TypeFor[C]() + if err := checkConfigType(productType); err != nil { + return empty, err + } + commonFields, _ := jsonFields(commonType) + fields, err := jsonFields(productType) + if err != nil { + return empty, err + } + for name, typ := range commonFields { + if _, exists := fields[name]; exists { + return empty, fmt.Errorf("product config field %q collides with common config", name) + } + fields[name] = typ + } + base := make(map[string]json.RawMessage) + for _, source := range []any{defaults.Common, defaults.Product} { + data, err := json.Marshal(source) + if err != nil { + return empty, fmt.Errorf("defaults: %w", err) + } + var values map[string]json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return empty, err + } + for key, value := range values { + base[key] = value + } + } + for index, layer := range []json.RawMessage{role, group} { + if len(layer) == 0 { + continue + } + scope := []string{"role.config", "roleGroup.config"}[index] + values, err := decodeConfigLayer(layer, scope) + if err != nil { + return empty, err + } + for _, key := range sortedKeys(values) { + typ, exists := fields[key] + if !exists { + return empty, fmt.Errorf("%s.%s: unknown field", scope, key) + } + if err := validateJSON(values[key], typ, scope+"."+key); err != nil { + return empty, err + } + } + // Scheduling branches are a fixed common domain; their internal rules + // never enter the generic object merger. + if affinity, present := values[affinityConfigField]; present { + merged, err := mergeAffinityJSON(base[affinityConfigField], affinity) + if err != nil { + return empty, fmt.Errorf("%s.affinity: %w", scope, err) + } + base[affinityConfigField] = merged + delete(values, affinityConfigField) + } + if err := foldStorageLayer(base, values); err != nil { + return empty, fmt.Errorf("%s.resources.storage: %w", scope, err) + } + if err := foldS3Domains(base, values, fields); err != nil { + return empty, fmt.Errorf("%s: %w", scope, err) + } + base = mergeObjects(base, values) + } + common, product := make(map[string]json.RawMessage), make(map[string]json.RawMessage) + for key, value := range base { + if _, exists := commonFields[key]; exists { + common[key] = value + } else { + product[key] = value + } + } + var result framework.Config[C] + for _, part := range []struct{ source, destination any }{{common, &result.Common}, {product, &result.Product}} { + data, err := json.Marshal(part.source) + if err != nil { + return empty, err + } + if err := json.Unmarshal(data, part.destination); err != nil { + return empty, err + } + } + if result.Common.Resources.Storage.Type == "" { + result.Common.Resources.Storage.Type = framework.StorageEphemeral + } + if err := ValidateCommon(result.Common); err != nil { + return empty, err + } + if err := validateS3Domains(reflect.ValueOf(result.Product)); err != nil { + return empty, fmt.Errorf("config: %w", err) + } + return result, nil +} + +// ValidateCommon checks final effective resource values. This bounded profile +// requires all three quantities; it does not model unset resources. An invalid +// business value in a lower layer may be corrected by a higher layer. +func ValidateCommon(config CommonConfig) error { + cpu, memory := config.Resources.CPU, config.Resources.Memory + if cpu.Min.Sign() <= 0 || cpu.Max.Sign() <= 0 { + return fmt.Errorf("config.resources.cpu.min and max must be positive") + } + if cpu.Min.Cmp(cpu.Max) > 0 { + return fmt.Errorf("config.resources.cpu.min must not exceed max") + } + if memory.Limit.Sign() <= 0 { + return fmt.Errorf("config.resources.memory.limit must be positive") + } + if err := validateStorage(config.Resources.Storage); err != nil { + return err + } + return validateShutdownDuration(config.GracefulShutdownTimeout.Duration) +} + +func jsonFields(typ reflect.Type) (map[string]reflect.Type, error) { + fields := make(map[string]reflect.Type) + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + if !field.IsExported() || field.Anonymous { + return nil, fmt.Errorf("%s: fields must be public and non-embedded", typ) + } + tag := strings.Split(field.Tag.Get("json"), ",") + name := tag[0] + if name == "-" { + return nil, fmt.Errorf("%s.%s: ignored fields are unsupported", typ, field.Name) + } + if name == "" { + name = field.Name + } + for _, option := range tag[1:] { + if option != "omitempty" { + return nil, fmt.Errorf("%s.%s: unsupported JSON option %q", typ, field.Name, option) + } + } + if _, exists := fields[name]; exists { + return nil, fmt.Errorf("%s: duplicate JSON field %q", typ, name) + } + fields[name] = field.Type + } + return fields, nil +} + +func checkProfile(typ reflect.Type, _ map[reflect.Type]bool) error { + return input.ValidateProductType(typ) +} + +func validateJSON(data json.RawMessage, typ reflect.Type, path string) error { + // Generated presence fields wrap the same domain types in pointers. Unwrap + // only for shape validation; this never decodes away an explicit null. + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if strings.TrimSpace(string(data)) == jsonNull { + return fmt.Errorf("%s: null is not an inheritance or deletion operation", path) + } + if typ == reflect.TypeFor[json.RawMessage]() { + // The generated input uses RawMessage only for the PodTemplate patch. + // Its internal null and $patch directives have Kubernetes patch semantics. + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil || object == nil { + return fmt.Errorf("%s: pod override must be an object", path) + } + return nil + } + if typ == affinityType { + return validateAffinityJSON(data, path) + } + if typ == durationType { + return validateDurationJSON(data, path) + } + if typ == quantityType { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("%s: quantity must be a string", path) + } + if _, err := resource.ParseQuantity(value); err != nil { + return fmt.Errorf("%s: invalid quantity: %w", path, err) + } + return nil + } + if typ == s3ConnectionType { + if err := validateS3Layer(data, path); err != nil { + return err + } + } + if typ == reflect.TypeFor[framework.Storage]() { + if err := validateStorageLayer(data, path); err != nil { + return err + } + } + switch typ.Kind() { + case reflect.Struct, reflect.Map: + values, err := decodeConfigLayer(data, path) + if err != nil { + return err + } + var fields map[string]reflect.Type + if typ.Kind() == reflect.Struct { + fields, _ = jsonFields(typ) + } + for _, key := range sortedKeys(values) { + var child reflect.Type + if typ.Kind() == reflect.Map { + child = typ.Elem() + } else if child = fields[key]; child == nil { + return fmt.Errorf("%s[%q]: unknown field", path, key) + } + if err := validateJSON(values[key], child, fmt.Sprintf("%s[%q]", path, key)); err != nil { + return err + } + } + case reflect.Slice: + var values []json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return fmt.Errorf("%s: expected an array", path) + } + for index, value := range values { + if err := validateJSON(value, typ.Elem(), fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + default: + if err := json.Unmarshal(data, reflect.New(typ).Interface()); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return nil +} + +func mergeObjects(base, patch map[string]json.RawMessage) map[string]json.RawMessage { + for key, value := range patch { + var left, right map[string]json.RawMessage + if json.Unmarshal(base[key], &left) == nil && left != nil && + json.Unmarshal(value, &right) == nil && right != nil { + value, _ = json.Marshal(mergeObjects(left, right)) + } + base[key] = value + } + return base +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/framework/pipeline/input_test.go b/internal/framework/pipeline/input_test.go new file mode 100644 index 00000000..b48a2c44 --- /dev/null +++ b/internal/framework/pipeline/input_test.go @@ -0,0 +1,179 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + "k8s.io/apimachinery/pkg/api/resource" +) + +type testProductConfig struct { + Port int32 `json:"port"` + Enabled bool `json:"enabled"` + Name string `json:"name"` + Args []string `json:"args"` + Servers map[string]testServer `json:"servers"` + Limits map[string]resource.Quantity `json:"limits"` +} + +type testServer struct { + Host string `json:"host"` + Labels map[string]string `json:"labels"` +} + +func testDefaults() framework.Config[testProductConfig] { + return framework.Config[testProductConfig]{ + Common: CommonConfig{ + Resources: Resources{CPU: CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("2")}, + Memory: Memory{Limit: resource.MustParse("4Gi")}}, + Logging: Logging{EnableVectorAgent: true, Containers: map[string]ContainerLogging{ + "trino": {Loggers: map[string]Logger{"io.trino": {Level: "INFO"}}}, + }}, + }, + Product: testProductConfig{Port: 8080, Enabled: true, Name: "default", Args: []string{"run"}, + Servers: map[string]testServer{"db.example": {Host: "db", Labels: map[string]string{"env": "test"}}}}, + } +} + +func TestResolveConfigInheritance(t *testing.T) { + cases := []struct { + name, role, group string + check func(*testing.T, framework.Config[testProductConfig]) + }{ + {"explicit values", `{"port":9090,"enabled":true,"name":"role","args":["role"]}`, + `{"port":0,"enabled":false,"name":"","args":[]}`, func(t *testing.T, got framework.Config[testProductConfig]) { + if got.Product.Port != 0 || got.Product.Enabled || got.Product.Name != "" || + got.Product.Args == nil || len(got.Product.Args) != 0 { + t.Fatalf("explicit empty and zero values were not preserved: %#v", got.Product) + } + }}, + {"field and map inheritance", `{"servers":{"db.example":{"host":"role","labels":{"a":"1"}}}}`, + `{"servers":{"db.example":{"labels":{"a":"2"}}}}`, func(t *testing.T, got framework.Config[testProductConfig]) { + want := testServer{Host: "role", Labels: map[string]string{"env": "test", "a": "2"}} + if !reflect.DeepEqual(got.Product.Servers["db.example"], want) { + t.Fatalf("dotted map key lost its identity or siblings: %#v", got.Product.Servers) + } + }}, + {"common domain", `{"resources":{"cpu":{"min":"250m"},"memory":{"limit":"8Gi"}}}`, + `{"logging":{"enableVectorAgent":false,"containers":{"trino":{"loggers":{"io.trino":{"level":""}}}}}}`, + func(t *testing.T, got framework.Config[testProductConfig]) { + if got.Common.Resources.CPU.Min.String() != "250m" || got.Common.Resources.CPU.Max.String() != "2" || + got.Common.Resources.Memory.Limit.String() != "8Gi" || got.Common.Logging.EnableVectorAgent || + got.Common.Logging.Containers["trino"].Loggers["io.trino"].Level != "" { + t.Fatalf("common fields did not inherit independently: %#v", got.Common) + } + }}, + {"empty object inherits", `{"servers":{"db.example":{"host":"role"}}}`, `{"servers":{}}`, + func(t *testing.T, got framework.Config[testProductConfig]) { + if got.Product.Servers["db.example"].Host != "role" { + t.Fatal("empty object unexpectedly cleared inherited entries") + } + }}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + got, err := ResolveConfig(testDefaults(), json.RawMessage(item.role), json.RawMessage(item.group)) + if err != nil { + t.Fatal(err) + } + item.check(t, got) + }) + } +} + +func TestResolveConfigRejectsInvalidLayers(t *testing.T) { + cases := []struct{ name, role, group, message string }{ + {"unknown product", `{"typo":1}`, `{}`, "role.config.typo"}, + {"unknown nested", `{"servers":{"db.example":{"typo":1}}}`, `{}`, `"typo"`}, + {"unknown common", `{}`, `{"resources":{"unknown":{}}}`, `"unknown"`}, + {"invalid lower layer", `{"port":"wrong"}`, `{"port":8080}`, "role.config.port"}, + {"overflow", `{"port":2147483648}`, `{}`, "role.config.port"}, + {"null layer", jsonNull, `{}`, "must be an object"}, + {"null scalar", `{"name":null}`, `{"name":"fixed"}`, jsonNull}, + {"null map value", `{"servers":{"db.example":null}}`, `{}`, jsonNull}, + {"null list item", `{"args":[null]}`, `{}`, jsonNull}, + {"wrong list", `{"args":"run"}`, `{}`, "expected an array"}, + {"wrong quantity", `{"resources":{"memory":{"limit":42}}}`, `{}`, "quantity must be a string"}, + {"malformed quantity", `{"resources":{"memory":{"limit":"lots"}}}`, `{}`, "invalid quantity"}, + {"wrong map quantity", `{"limits":{"heap":{}}}`, `{}`, "quantity must be a string"}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + _, err := ResolveConfig(testDefaults(), json.RawMessage(item.role), json.RawMessage(item.group)) + if err == nil || !strings.Contains(err.Error(), item.message) { + t.Fatalf("want %q, got %v", item.message, err) + } + }) + } +} + +func TestResolveConfigRejectsUnsupportedTypes(t *testing.T) { + _, err := ResolveConfig(framework.Config[struct { + Resources string `json:"resources"` + }]{}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("common/product collision accepted: %v", err) + } + for _, typ := range []reflect.Type{ + reflect.TypeFor[*string](), reflect.TypeFor[any](), reflect.TypeFor[[]byte](), + reflect.TypeFor[map[int]string](), reflect.TypeFor[json.RawMessage](), + reflect.TypeFor[struct{ testServer }](), + } { + t.Run(typ.String(), func(t *testing.T) { + if err := checkProfile(typ, make(map[reflect.Type]bool)); err == nil { + t.Fatalf("unsupported type %s accepted", typ) + } + }) + } +} + +func TestResolveConfigValidatesFinalCommonValues(t *testing.T) { + cases := []struct{ name, role, group, message string }{ + {"explicit zero is not inheritance", `{}`, `{"resources":{"memory":{"limit":"0"}}}`, "limit"}, + {"zero corrected by group", `{"resources":{"memory":{"limit":"0"}}}`, + `{"resources":{"memory":{"limit":"1Gi"}}}`, ""}, + {"range corrected by group", `{"resources":{"cpu":{"min":"3"}}}`, + `{"resources":{"cpu":{"max":"4"}}}`, ""}, + {"negative minimum", `{"resources":{"cpu":{"min":"-1"}}}`, `{}`, "positive"}, + {"zero maximum", `{"resources":{"cpu":{"max":"0"}}}`, `{}`, "positive"}, + {"minimum exceeds maximum", `{"resources":{"cpu":{"min":"3"}}}`, `{}`, "exceed"}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + _, err := ResolveConfig(testDefaults(), json.RawMessage(item.role), json.RawMessage(item.group)) + if item.message == "" { + if err != nil { + t.Fatalf("higher layer did not correct the final business value: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), item.message) { + t.Fatalf("want %q, got %v", item.message, err) + } + }) + } +} + +func TestResolveConfigDoesNotMutateDefaults(t *testing.T) { + defaults := testDefaults() + before, err := json.Marshal(defaults) + if err != nil { + t.Fatal(err) + } + resolved, err := ResolveConfig(defaults, nil, nil) + if err != nil { + t.Fatal(err) + } + resolved.Product.Servers["db.example"].Labels["env"] = "changed" + resolved.Product.Args[0] = "changed" + resolved.Common.Logging.Containers["trino"].Loggers["io.trino"] = Logger{Level: "ERROR"} + resolved.Common.Resources.Memory.Limit.Add(resource.MustParse("1Gi")) + after, err := json.Marshal(defaults) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("resolved config aliases defaults: before %s, after %s", before, after) + } +} diff --git a/internal/framework/pipeline/lifecycle.go b/internal/framework/pipeline/lifecycle.go new file mode 100644 index 00000000..b0b221c7 --- /dev/null +++ b/internal/framework/pipeline/lifecycle.go @@ -0,0 +1,123 @@ +package pipeline + +import ( + "fmt" + "path" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" +) + +const lifecycleSubject = "assembly.lifecycle" + +func validateLifecycle(r RuntimeDescription) error { + if c := r.Coordination; c != nil { + if c.ProgressDeadline.Duration < time.Second || c.ProgressDeadline.Duration > time.Hour { + return fmt.Errorf("coordination progressDeadline must be between 1s and 1h") + } + } + if len(r.Initializers) > 0 && r.Coordination == nil { + return fmt.Errorf("initializers require bounded workload coordination") + } + if err := validateProbes(r.Main); err != nil { + return err + } + names := map[string]bool{r.Main.Name: true, materializerContainerName: true, vectorContainerName: true} + dirs := map[string]bool{} + for _, dir := range r.Directories { + dirs[dir.Name] = true + } + for _, init := range r.Initializers { + if err := validateProcess(init, r.SharedGroup); err != nil { + return fmt.Errorf("initializer %q: %w", init.Name, err) + } + if names[init.Name] { + return fmt.Errorf("initializer %q has a reserved or duplicate name", init.Name) + } + names[init.Name] = true + if init.Lifecycle != nil || init.StartupProbe != nil || init.ReadinessProbe != nil || init.LivenessProbe != nil { + return fmt.Errorf("initializer %q cannot declare lifecycle or probes", init.Name) + } + paths := map[string]bool{} + for _, access := range init.Access { + if !dirs[access.Directory] || !path.IsAbs(access.MountPath) || path.Clean(access.MountPath) != access.MountPath || + strings.ContainsRune(access.MountPath, '\x00') || paths[access.MountPath] { + return fmt.Errorf("initializer %q has invalid directory access", init.Name) + } + paths[access.MountPath] = true + } + } + return nil +} + +func checkLifecycle(expected, actual GroupResources, runtime RuntimeDescription) []Check { + if len(runtime.Initializers) == 0 && runtime.Main.Lifecycle == nil && runtime.Main.StartupProbe == nil && + runtime.Main.ReadinessProbe == nil && runtime.Main.LivenessProbe == nil && runtime.Coordination == nil { + return nil + } + before, after := expected.StatefulSet.Spec.Template.Spec, actual.StatefulSet.Spec.Template.Spec + main := findContainer(actual.StatefulSet.Spec.Template, runtime.Main.Name) + baseline := findContainer(expected.StatefulSet.Spec.Template, runtime.Main.Name) + check := Check{Subject: lifecycleSubject, State: Consistent, + Reason: "declared initialization, probes and lifecycle are retained; execution success is separately observed"} + if main == nil || !apiequality.Semantic.DeepEqual(main.Lifecycle, baseline.Lifecycle) || + !apiequality.Semantic.DeepEqual(main.StartupProbe, baseline.StartupProbe) || + !apiequality.Semantic.DeepEqual(main.ReadinessProbe, baseline.ReadinessProbe) || + !apiequality.Semantic.DeepEqual(main.LivenessProbe, baseline.LivenessProbe) || + !apiequality.Semantic.DeepEqual(before.TerminationGracePeriodSeconds, after.TerminationGracePeriodSeconds) { + check.State, check.Reason = Unknown, "podOverrides changed the declared lifecycle, probes or termination budget" + } + // Ordinary overrides still win. Changed initializer execution invalidates the + // product premise instead of being silently described as initialized. + for _, init := range runtime.Initializers { + var a, b *corev1.Container + ai, bi := -1, -1 + for i := range before.InitContainers { + if before.InitContainers[i].Name == init.Name { + b = &before.InitContainers[i] + bi = i + } + } + for i := range after.InitContainers { + if after.InitContainers[i].Name == init.Name { + a = &after.InitContainers[i] + ai = i + } + } + if !apiequality.Semantic.DeepEqual(a, b) || ai != bi { + check.State, check.Reason = Unknown, "podOverrides changed declared initializer execution or order" + } + } + return []Check{check} +} + +func validateProbes(main Process) error { + for _, probe := range []*corev1.Probe{main.StartupProbe, main.ReadinessProbe, main.LivenessProbe} { + if probe == nil { + continue + } + n := 0 + if probe.Exec != nil { + n++ + if len(probe.Exec.Command) == 0 { + return fmt.Errorf("probe exec requires a command") + } + } + if probe.HTTPGet != nil { + n++ + } + if probe.TCPSocket != nil { + n++ + } + if probe.GRPC != nil { + n++ + } + if n != 1 || probe.InitialDelaySeconds < 0 || probe.TimeoutSeconds < 0 || + probe.PeriodSeconds < 0 || probe.SuccessThreshold < 0 || probe.FailureThreshold < 0 { + return fmt.Errorf("probe requires exactly one action and nonnegative thresholds") + } + } + return nil +} diff --git a/internal/framework/pipeline/lifecycle_test.go b/internal/framework/pipeline/lifecycle_test.go new file mode 100644 index 00000000..c8246c24 --- /dev/null +++ b/internal/framework/pipeline/lifecycle_test.go @@ -0,0 +1,83 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestLifecycleAssemblyOrdersInitializationAndPreservesOverrideAuthority(t *testing.T) { + r := assemblyRuntimeFixture() + r.Coordination = &framework.WorkloadCoordination{ProgressDeadline: metav1.Duration{Duration: time.Minute}} + r.Initializers = []Process{{Name: "initialize", Image: r.Main.Image, + Command: []string{"initialize"}, Access: r.Main.Access}} + r.Main.Lifecycle = &corev1.Lifecycle{PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"drain"}}}} + r.Main.StartupProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{Command: []string{"ready"}}}} + if err := ValidateRuntime(r); err != nil { + t.Fatal(err) + } + identity := GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "life", Namespace: "test"}, + Role: "workers", Name: "default", Replicas: 2} + resources, _, checks, err := buildGroup(identity, CommonConfig{}, ResolvedImage{}, r, GroupSource{}, + AssemblyOptions{MaterializerImage: "helper:1"}, nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(resources.Coordination, r.Coordination) { + t.Fatal("final resource clone lost lifecycle coordination") + } + resources.Coordination.ShutdownPriority = 7 + if r.Coordination.ShutdownPriority != 0 { + t.Fatal("final resource policy aliases runtime") + } + set := resources.StatefulSet + if len(set.Spec.Template.Spec.InitContainers) != 2 || + set.Spec.Template.Spec.InitContainers[0].Name != materializerContainerName || + set.Spec.Template.Spec.InitContainers[1].Name != "initialize" || + set.Spec.Template.Spec.Containers[0].Lifecycle.PreStop.Exec.Command[0] != "drain" || + set.Spec.PodManagementPolicy != appsv1.OrderedReadyPodManagement || + set.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType { + t.Fatalf("initialization/lifecycle assembly missing: %+v", set.Spec) + } + for _, check := range checks { + if check.Subject == lifecycleSubject && check.State != Consistent { + t.Fatal("unchanged initialization incorrectly invalidated", check) + } + } + if len(checks) == 0 { + t.Fatal("no final checks") + } + source := GroupSource{Overrides: &Overrides{PodOverrides: json.RawMessage(`{"spec":{"containers":[ + {"name":"trino","lifecycle":{"preStop":{"exec":{"command":["custom"]}}}}]}}`)}} + _, _, checks, err = buildGroup(identity, CommonConfig{}, ResolvedImage{}, r, source, + AssemblyOptions{MaterializerImage: "helper:1"}, nil) + if err != nil { + t.Fatal(err) + } + found := false + for _, check := range checks { + if check.Subject == lifecycleSubject && check.State == Unknown { + found = true + } + } + if !found { + t.Fatal("override won without invalidating lifecycle premise") + } + cloned := CloneRuntime(r) + cloned.Initializers[0].Command[0] = "changed" + cloned.Main.Lifecycle.PreStop.Exec.Command[0] = "changed" + cloned.Main.StartupProbe.Exec.Command[0] = "changed" + cloned.Coordination.ProgressDeadline.Duration = time.Second + if r.Initializers[0].Command[0] != "initialize" || r.Main.Lifecycle.PreStop.Exec.Command[0] != "drain" || + r.Main.StartupProbe.Exec.Command[0] != "ready" || r.Coordination.ProgressDeadline.Duration != time.Minute { + t.Fatal("mutable lifecycle alias") + } +} diff --git a/internal/framework/pipeline/materialization.go b/internal/framework/pipeline/materialization.go new file mode 100644 index 00000000..4fc502ef --- /dev/null +++ b/internal/framework/pipeline/materialization.go @@ -0,0 +1,272 @@ +package pipeline + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "slices" + "strings" + "unicode/utf8" + + "k8s.io/apimachinery/pkg/util/validation" +) + +const ( + materializationVersion = "v1" + propertiesCodecID = "properties-v1" +) + +// MaterializationPlan is data, never a shell script or interpolation template. +// Files are relative to root/; each file has exactly one content form. +type MaterializationPlan struct { + Version string `json:"version"` + Files []PlannedFile `json:"files"` +} + +type PlannedFile struct { + Directory string `json:"directory"` + Path string `json:"path"` + Encoded *string `json:"encoded,omitempty"` + Properties *PlannedProperties `json:"properties,omitempty"` +} + +type PlannedProperties struct { + Codec string `json:"codec"` + Values map[string]PlannedProperty `json:"values"` +} + +type PlannedProperty struct { + Literal *string `json:"literal,omitempty"` + PodName bool `json:"podName,omitempty"` +} + +// PrepareMaterialization encodes all static content. Only built-in PropertiesCodec +// may carry unresolved bindings across the process boundary; arbitrary Go codecs +// remain usable for static files and are never guessed from a file extension. +func PrepareMaterialization(files []File) (MaterializationPlan, error) { + plan := MaterializationPlan{Version: materializationVersion, Files: make([]PlannedFile, 0, len(files))} + for _, file := range files { + if err := validateContent(file.Content); err != nil { + return MaterializationPlan{}, fmt.Errorf("file %s/%s: %w", file.Directory, file.Path, err) + } + item := PlannedFile{Directory: file.Directory, Path: file.Path} + if err := prepareFileContent(&item, file.Content); err != nil { + return MaterializationPlan{}, fmt.Errorf("file %s/%s: %w", file.Directory, file.Path, err) + } + plan.Files = append(plan.Files, item) + } + slices.SortFunc(plan.Files, func(a, b PlannedFile) int { + return strings.Compare(a.Directory+"/"+a.Path, b.Directory+"/"+b.Path) + }) + if err := validateMaterializationPlan(plan); err != nil { + return MaterializationPlan{}, err + } + return plan, nil +} + +func prepareFileContent(item *PlannedFile, content FileContent) error { + var encoded string + switch c := content.(type) { + case Text: + encoded = string(c) + case Lines: + if len(c) > 0 { + encoded = strings.Join(c, "\n") + "\n" + } + case KeyValues: + values := make(map[string]string, len(c.Values)) + deferred := make(map[string]PlannedProperty, len(c.Values)) + bound := false + for key, value := range c.Values { + switch v := value.(type) { + case Literal: + literal := string(v) + values[key] = literal + deferred[key] = PlannedProperty{Literal: &literal} + case PodNameBinding: + deferred[key] = PlannedProperty{PodName: true} + bound = true + } + } + if bound { + if _, ok := c.Codec.(PropertiesCodec); !ok { + return fmt.Errorf("PodNameBinding requires built-in PropertiesCodec, got %T", c.Codec) + } + item.Properties = &PlannedProperties{Codec: propertiesCodecID, Values: deferred} + return nil + } + var err error + encoded, err = c.Codec.Encode(values) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported file content %T", content) + } + item.Encoded = &encoded + return nil +} + +func validatePlannedFile(file PlannedFile) error { + if len(validation.IsDNS1123Label(file.Directory)) != 0 || !relativeFile(file.Path) || + strings.Contains(file.Path, "\\") { + return fmt.Errorf("invalid materialization path %q/%q", file.Directory, file.Path) + } + if (file.Encoded == nil) == (file.Properties == nil) { + return fmt.Errorf("file %s/%s requires exactly one content form", file.Directory, file.Path) + } + if file.Properties == nil { + if !utf8.ValidString(*file.Encoded) { + return fmt.Errorf("file %s/%s is not UTF-8 text", file.Directory, file.Path) + } + return nil + } + if file.Properties.Codec != propertiesCodecID { + return fmt.Errorf("unsupported materialization codec %q", file.Properties.Codec) + } + for _, key := range sortedKeys(file.Properties.Values) { + value := file.Properties.Values[key] + if (value.Literal != nil) == value.PodName { + return fmt.Errorf("property %q requires exactly one literal or PodName source", key) + } + if !utf8.ValidString(key) || (value.Literal != nil && !utf8.ValidString(*value.Literal)) { + return fmt.Errorf("property %q is not UTF-8 text", key) + } + } + return nil +} + +func validateMaterializationPlan(plan MaterializationPlan) error { + if plan.Version != materializationVersion { + return fmt.Errorf("unsupported materialization version %q", plan.Version) + } + names := make(map[string]bool, len(plan.Files)) + for _, file := range plan.Files { + if err := validatePlannedFile(file); err != nil { + return err + } + name := file.Directory + "/" + file.Path + if names[name] { + return fmt.Errorf("duplicate materialization path %q", name) + } + names[name] = true + } + for _, name := range sortedKeys(names) { + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if names[parent] { + return fmt.Errorf("materialization paths %q and %q collide", parent, name) + } + } + } + return nil +} + +func EncodeMaterializationPlan(plan MaterializationPlan) ([]byte, error) { + if err := validateMaterializationPlan(plan); err != nil { + return nil, err + } + return json.Marshal(plan) +} + +func DecodeMaterializationPlan(data []byte) (MaterializationPlan, error) { + var plan MaterializationPlan + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&plan); err != nil { + return MaterializationPlan{}, err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return MaterializationPlan{}, fmt.Errorf("materialization plan must contain exactly one JSON document") + } + if err := validateMaterializationPlan(plan); err != nil { + return MaterializationPlan{}, err + } + return plan, nil +} + +func resolvedMaterializedFiles(plan MaterializationPlan, podName string) (map[string]string, error) { + if err := validateMaterializationPlan(plan); err != nil { + return nil, err + } + files := make(map[string]string, len(plan.Files)) + for _, file := range plan.Files { + name := file.Directory + "/" + file.Path + if file.Encoded != nil { + files[name] = *file.Encoded + continue + } + values := make(map[string]string, len(file.Properties.Values)) + for key, value := range file.Properties.Values { + if value.PodName { + if len(validation.IsDNS1123Subdomain(podName)) != 0 { + return nil, fmt.Errorf("file %q requires a valid POD_NAME", name) + } + values[key] = podName + } else { + values[key] = *value.Literal + } + } + encoded, err := (PropertiesCodec{}).Encode(values) + if err != nil { + return nil, fmt.Errorf("file %q: %w", name, err) + } + files[name] = encoded + } + return files, nil +} + +// Materialize resolves every value before writing root//. +// The caller gives it exclusive ownership of the output tree (an init-container +// EmptyDir in the assembly). Files are individually replaced atomically; +// a partial write failure fails the init process, not a running product reload. +// It neither deletes files from older plans nor changes UID/GID or mode globally. +func Materialize(outputRoot string, plan MaterializationPlan, podName string) error { + files, err := resolvedMaterializedFiles(plan, podName) + if err != nil { + return err + } + if err := os.MkdirAll(outputRoot, 0755); err != nil { + return err + } + root, err := os.OpenRoot(outputRoot) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + for _, name := range sortedKeys(files) { + if err := writeMaterializedFile(root, name, files[name]); err != nil { + return fmt.Errorf("materialize %q: %w", name, err) + } + } + return nil +} + +func writeMaterializedFile(root *os.Root, name, content string) error { + if err := root.MkdirAll(path.Dir(name), 0755); err != nil { + return err + } + // A fresh temporary inode plus Rename avoids following an existing file's + // symlink or overwriting a hard-link target. os.Root confines path traversal. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return err + } + temp := path.Join(path.Dir(name), ".materialize-"+hex.EncodeToString(nonce)) + file, err := root.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return err + } + defer func() { _ = root.Remove(temp) }() + _, writeErr := file.WriteString(content) + if err := errors.Join(writeErr, file.Close()); err != nil { + return err + } + return root.Rename(temp, name) +} diff --git a/internal/framework/pipeline/materialization_test.go b/internal/framework/pipeline/materialization_test.go new file mode 100644 index 00000000..7b9735fc --- /dev/null +++ b/internal/framework/pipeline/materialization_test.go @@ -0,0 +1,201 @@ +package pipeline + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type staticOnlyCodec struct{} + +func (staticOnlyCodec) Encode(values map[string]string) (string, error) { + return "custom:" + values["value"], nil +} + +func materializationFixture(t *testing.T) (MaterializationPlan, map[string]string) { + t.Helper() + values := map[string]string{ + "node.id": "example-workers-large-0", " edge:key=\\": " leading trailing ", + "multiline": "first\nsecond\r\ttab\\", "unicode": "日志🦆", "literal": "$(touch NEVER); `exit 42` ${POD_NAME}", + } + properties := map[string]PropertyValue{} + for key, value := range values { + properties[key] = Literal(value) + } + properties["node.id"] = PodNameBinding{} + plan, err := PrepareMaterialization([]File{ + {Directory: "config", Path: "node.properties", Content: KeyValues{Codec: PropertiesCodec{}, Values: properties}}, + {Directory: "config", Path: "catalog/custom", Content: KeyValues{ + Codec: staticOnlyCodec{}, Values: map[string]PropertyValue{"value": Literal("${POD_NAME}")}, + }}, + {Directory: "config", Path: "jvm.config", Content: Lines{"-Xmx1152m", "-Dfile.encoding=UTF-8"}}, + {Directory: "config", Path: "empty", Content: Text("")}, + }) + if err != nil { + t.Fatal(err) + } + return plan, values +} + +func assertMaterializedFixture(t *testing.T, root string, want map[string]string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "config", "node.properties")) + if err != nil { + t.Fatal(err) + } + expected := `\ edge\:key\=\\=\ leading trailing\ ` + "\n" + + `literal=$(touch NEVER); ` + "`exit 42`" + ` ${POD_NAME}` + "\n" + + `multiline=first\nsecond\r\ttab\\` + "\nnode.id=" + want["node.id"] + "\nunicode=日志🦆\n" + if string(data) != expected { + t.Fatalf("properties were not encoded as values: got=%q want=%q", data, expected) + } + for name, expected := range map[string]string{ + "config/catalog/custom": "custom:${POD_NAME}", + "config/jvm.config": "-Xmx1152m\n-Dfile.encoding=UTF-8\n", + "config/empty": "", + } { + content, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || string(content) != expected { + t.Fatalf("file %s: got=%q want=%q err=%v", name, content, expected, err) + } + } +} + +func TestMaterializationResolvesBindingsAsEncodedData(t *testing.T) { + plan, want := materializationFixture(t) + encoded, err := EncodeMaterializationPlan(plan) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeMaterializationPlan(encoded) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := Materialize(root, decoded, want["node.id"]); err != nil { + t.Fatal(err) + } + assertMaterializedFixture(t, root, want) + // A failed init can retry against the same EmptyDir without appending data. + if err := Materialize(root, decoded, want["node.id"]); err != nil { + t.Fatal(err) + } + assertMaterializedFixture(t, root, want) + for _, file := range plan.Files { + if file.Properties != nil && file.Properties.Values["node.id"].Literal != nil { + t.Fatal("binding execution must not mutate the shared serialized plan") + } + } +} + +func TestMaterializationRejectsUnknownRuntimeCodecsAndAmbiguousPlans(t *testing.T) { + _, err := PrepareMaterialization([]File{{Directory: "config", Path: "node.properties", Content: KeyValues{ + Codec: staticOnlyCodec{}, Values: map[string]PropertyValue{"node.id": PodNameBinding{}}, + }}}) + if err == nil || !strings.Contains(err.Error(), "built-in PropertiesCodec") { + t.Fatalf("arbitrary Go codec must not cross the runtime boundary: %v", err) + } + _, err = PrepareMaterialization([]File{{Directory: "config", Path: "binary", Content: Text("\xff")}}) + if err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Fatalf("JSON transport must not silently corrupt non-text bytes: %v", err) + } + empty := "" + base := PlannedFile{Directory: "config", Path: "node.properties", Encoded: &empty} + for name, mutate := range map[string]func(*MaterializationPlan){ + "version": func(p *MaterializationPlan) { p.Version = "v2" }, + "directory": func(p *MaterializationPlan) { p.Files[0].Directory = "../escape" }, + "absolute": func(p *MaterializationPlan) { p.Files[0].Path = "/escape" }, + "traversal": func(p *MaterializationPlan) { p.Files[0].Path = "nested/../../escape" }, + "backslash": func(p *MaterializationPlan) { p.Files[0].Path = "nested\\escape" }, + "duplicate": func(p *MaterializationPlan) { p.Files = append(p.Files, base) }, + "prefix": func(p *MaterializationPlan) { + p.Files = []PlannedFile{ + {Directory: "config", Path: "a", Encoded: &empty}, + {Directory: "config", Path: "a-b", Encoded: &empty}, + {Directory: "config", Path: "a/child", Encoded: &empty}, + } + }, + "missing content": func(p *MaterializationPlan) { p.Files[0].Encoded = nil }, + "two forms": func(p *MaterializationPlan) { + p.Files[0].Properties = &PlannedProperties{Codec: propertiesCodecID} + }, + "codec": func(p *MaterializationPlan) { + p.Files[0].Encoded = nil + p.Files[0].Properties = &PlannedProperties{Codec: "shell"} + }, + "two sources": func(p *MaterializationPlan) { + p.Files[0].Encoded = nil + p.Files[0].Properties = &PlannedProperties{Codec: propertiesCodecID, Values: map[string]PlannedProperty{ + "node.id": {Literal: &empty, PodName: true}, + }} + }, + } { + t.Run(name, func(t *testing.T) { + plan := MaterializationPlan{Version: materializationVersion, Files: []PlannedFile{base}} + mutate(&plan) + root := filepath.Join(t.TempDir(), "uncreated") + if err := Materialize(root, plan, "pod-0"); err == nil { + t.Fatal("invalid plan must fail") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("plan validation must precede all filesystem writes") + } + }) + } + for _, raw := range []string{ + `{"version":"v1","files":[],"unknown":true}`, `{"version":"v1","files":[]} {}`, + `{"version":"v1","files":[{"directory":"config","path":"x","encoded":null}]}`, + } { + if _, err := DecodeMaterializationPlan([]byte(raw)); err == nil { + t.Fatalf("ambiguous JSON accepted: %s", raw) + } + } +} + +func TestMaterializationConfinesFilesystemWrites(t *testing.T) { + plan, want := materializationFixture(t) + for _, podName := range []string{"", "$(touch escaped)", "pod\nsecond"} { + root := filepath.Join(t.TempDir(), "uncreated") + if err := Materialize(root, plan, podName); err == nil { + t.Fatalf("invalid Pod name accepted: %q", podName) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("all bindings must resolve before the first write") + } + } + root, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "config")); err != nil { + t.Fatal(err) + } + if err := Materialize(root, plan, want["node.id"]); err == nil { + t.Fatal("a directory symlink cannot escape the output root") + } + entries, err := os.ReadDir(outside) + if err != nil || len(entries) != 0 { + t.Fatalf("escaped directory changed: %v %v", entries, err) + } + // A pre-existing file link is replaced as a directory entry; its target is untouched. + root = t.TempDir() + if err := os.Mkdir(filepath.Join(root, "config"), 0755); err != nil { + t.Fatal(err) + } + protected := filepath.Join(outside, "protected") + if err := os.WriteFile(protected, []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(protected, filepath.Join(root, "config", "node.properties")); err != nil { + t.Fatal(err) + } + if err := os.Link(protected, filepath.Join(root, "config", "empty")); err != nil { + t.Fatal(err) + } + if err := Materialize(root, plan, want["node.id"]); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(protected) + if err != nil || string(content) != "keep" { + t.Fatalf("existing link target changed: %q %v", content, err) + } + assertMaterializedFixture(t, root, want) +} diff --git a/internal/framework/pipeline/naming.go b/internal/framework/pipeline/naming.go new file mode 100644 index 00000000..c8ae6fe1 --- /dev/null +++ b/internal/framework/pipeline/naming.go @@ -0,0 +1,99 @@ +package pipeline + +import ( + "fmt" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +// SourceGroupIdentities validates the complete desired identity inventory before +// configuration resolution or product callbacks. A failed build is still desired. +func SourceGroupIdentities[F any](source SourceSnapshot[F]) ([]GroupIdentity, error) { + if len(validation.IsDNS1123Label(source.Cluster.Name)) != 0 || + len(validation.IsDNS1123Label(source.Cluster.Namespace)) != 0 { + return nil, fmt.Errorf("cluster name and namespace must be valid DNS labels") + } + groups := slices.Clone(source.Groups) + slices.SortFunc(groups, func(a, b GroupSource) int { + if a.Role != b.Role { + return strings.Compare(a.Role, b.Role) + } + return strings.Compare(a.Name, b.Name) + }) + identities := make([]GroupIdentity, 0, len(groups)) + names := make(map[string]string, len(groups)*4) + for i, group := range groups { + if group.Name == "" || group.Replicas < 0 || + (i > 0 && groups[i-1].Role == group.Role && groups[i-1].Name == group.Name) { + return nil, fmt.Errorf("invalid or duplicate role group %s/%s", group.Role, group.Name) + } + identity := GroupIdentity{ClusterIdentity: CloneInput(source.Cluster), + Role: group.Role, Name: group.Name, Replicas: group.Replicas} + if len(validation.IsDNS1123Label(group.Role)) != 0 || len(validation.IsDNS1123Label(group.Name)) != 0 { + return nil, fmt.Errorf("role group %s/%s must use valid DNS labels", group.Role, group.Name) + } + if err := checkGroupNames(identity, names); err != nil { + return nil, err + } + identities = append(identities, identity) + } + return identities, nil +} + +// Check every fixed resource name in the complete declared topology, including +// the headless suffix and ordinary/headless Service collisions between groups. +// These checks run before config/facts can remove any buildable group outputs. +func checkGroupNames(group GroupIdentity, names map[string]string) error { + base := group.ServiceName() + owner := group.Role + "/" + group.Name + for _, item := range []struct { + kind, name, description string + validate func(string) []string + }{ + {kindService, base, kindService, validation.IsDNS1035Label}, + {kindService, base + "-headless", "headless Service", validation.IsDNS1035Label}, + {kindStatefulSet, base, kindStatefulSet, validation.IsDNS1123Subdomain}, + {kindConfigMap, base, kindConfigMap, validation.IsDNS1123Subdomain}, + } { + if len(item.validate(item.name)) != 0 { + return fmt.Errorf("role group %s cannot produce a valid %s name %q", owner, item.description, item.name) + } + key := item.kind + "/" + group.Namespace + "/" + item.name + if previous, exists := names[key]; exists { + return fmt.Errorf("role groups %s and %s produce the same %s name %q", previous, owner, item.kind, item.name) + } + names[key] = owner + } + return nil +} + +// SourceRoleIdentities checks the complete CR inventory without product callbacks +// or configuration validation. Roles are never inferred from surviving groups. +func SourceRoleIdentities[F any](source SourceSnapshot[F]) ([]RoleIdentity, error) { + if _, err := SourceGroupIdentities(source); err != nil { + return nil, err + } + roles := slices.Clone(source.Roles) + slices.SortFunc(roles, func(a, b RoleSource) int { return strings.Compare(a.Name, b.Name) }) + identities := make([]RoleIdentity, 0, len(roles)) + known := make(map[string]bool, len(roles)) + for _, role := range roles { + if len(validation.IsDNS1123Label(role.Name)) != 0 || known[role.Name] { + return nil, fmt.Errorf("invalid or duplicate role %q", role.Name) + } + identity := RoleIdentity{ClusterIdentity: CloneInput(source.Cluster), Name: role.Name} + if len(validation.IsDNS1123Subdomain(identity.PodDisruptionBudgetName())) != 0 { + return nil, fmt.Errorf("role %q cannot produce a valid PodDisruptionBudget name", role.Name) + } + known[role.Name] = true + identities = append(identities, identity) + } + for _, group := range source.Groups { + if !known[group.Role] { + return nil, fmt.Errorf("role group %s/%s references a role absent from the source", group.Role, group.Name) + } + } + return identities, nil +} diff --git a/internal/framework/pipeline/naming_test.go b/internal/framework/pipeline/naming_test.go new file mode 100644 index 00000000..afb5d6a6 --- /dev/null +++ b/internal/framework/pipeline/naming_test.go @@ -0,0 +1,64 @@ +package pipeline + +import ( + "encoding/json" + "strings" + "testing" +) + +func namingSource(groups ...GroupSource) SourceSnapshot[struct{}] { + return SourceSnapshot[struct{}]{ + Cluster: ClusterIdentity{Name: "c", Namespace: "fixture", Labels: map[string]string{"team": "data"}}, + Roles: []RoleSource{{Name: "w"}}, Groups: groups, + } +} + +func TestSourceNamesCheckHeadlessBeforeConfiguration(t *testing.T) { + // c-w- plus 50 characters is the largest base fitting the headless suffix. + source := namingSource(GroupSource{Role: "w", Name: strings.Repeat("g", 50), Replicas: 1, + Config: json.RawMessage(`{"invalid":"unresolved configuration"}`)}) + identities, err := SourceGroupIdentities(source) + if err != nil || len(identities) != 1 || len(identities[0].ServiceName()+"-headless") != 63 { + t.Fatalf("legal boundary or complete declared group lost: %+v %v", identities, err) + } + identities[0].Labels["team"] = "changed" + if source.Cluster.Labels["team"] != "data" { + t.Fatal("identity inventory aliases source metadata") + } + source.Groups[0].Name += "g" + identities, err = SourceGroupIdentities(source) + if err == nil || identities != nil || !strings.Contains(err.Error(), "headless Service name") { + t.Fatalf("valid ordinary Service hid an invalid headless name: %+v %v", identities, err) + } + if _, err := SourceRoleIdentities(source); err == nil { + t.Fatal("role inventory accepted a known-invalid group resource name") + } +} + +func TestSourceNamesRejectCrossGroupResourceCollisions(t *testing.T) { + for _, groups := range [][]GroupSource{ + {{Role: "w", Name: "a"}, {Role: "w", Name: "a-headless", Config: json.RawMessage(`null`)}}, + {{Role: "a-b", Name: "c"}, {Role: "a", Name: "b-c"}}, + } { + identities, err := SourceGroupIdentities(namingSource(groups...)) + if err == nil || identities != nil || !strings.Contains(err.Error(), "same Service name") { + t.Fatalf("name collision escaped the complete identity stage: %+v %v", identities, err) + } + } +} + +func TestSourceNamesRespectResourceSpecificRules(t *testing.T) { + source := namingSource(GroupSource{Role: "w", Name: "a"}) + source.Cluster.Name = "9cluster" + if _, err := SourceGroupIdentities(source); err == nil { + t.Fatal("numeric prefix cannot produce a DNS1035 Service name") + } + // A role with no groups has a PDB, which follows DNS subdomain rules instead. + source.Groups = nil + source.Cluster.Name = strings.Repeat("c", 63) + source.Roles = []RoleSource{{Name: strings.Repeat("r", 63)}} + roles, err := SourceRoleIdentities(source) + if err != nil || len(roles) != 1 || len(roles[0].PodDisruptionBudgetName()) <= 63 { + t.Fatalf("PDB name incorrectly received the Service length limit: %+v %v", roles, err) + } +} diff --git a/internal/framework/pipeline/overrides.go b/internal/framework/pipeline/overrides.go new file mode 100644 index 00000000..57b105e1 --- /dev/null +++ b/internal/framework/pipeline/overrides.go @@ -0,0 +1,81 @@ +package pipeline + +import ( + "fmt" + "maps" + "slices" + + corev1 "k8s.io/api/core/v1" +) + +// CloneRuntime copies mutable declaration data while retaining stateless codec +// identities. Product callbacks transfer ownership of their returned values. +func CloneRuntime(in RuntimeDescription) RuntimeDescription { + out := in + out.Main = cloneProcess(in.Main) + out.Initializers = slices.Clone(in.Initializers) + for i := range in.Initializers { + out.Initializers[i] = cloneProcess(in.Initializers[i]) + } + out.Coordination = CloneInput(in.Coordination) + out.Directories = CloneInput(in.Directories) + if in.SharedGroup != nil { + value := *in.SharedGroup + out.SharedGroup = &value + } + out.Endpoints = slices.Clone(in.Endpoints) + out.LogOutputs = slices.Clone(in.LogOutputs) + out.Files = cloneDeclaredFiles(in.Files) + return out +} + +func cloneDeclaredFiles(files []File) []File { + out := slices.Clone(files) + for i, file := range out { + switch content := file.Content.(type) { + case KeyValues: + content.Values = maps.Clone(content.Values) + out[i].Content = content + case Lines: + out[i].Content = slices.Clone(content) + } + } + return out +} + +func applyProcessOverrides(process *Process, layers ...*Overrides) error { + for _, layer := range layers { + if layer == nil { + continue + } + for _, name := range sortedKeys(layer.EnvOverrides) { + // Replacing the entire entry removes any prior ValueFrom source. + process.Env = slices.DeleteFunc(process.Env, func(env corev1.EnvVar) bool { return env.Name == name }) + process.Env = append(process.Env, corev1.EnvVar{Name: name, Value: layer.EnvOverrides[name]}) + } + if layer.CLIOverrides != nil { + if *layer.CLIOverrides == nil { + return fmt.Errorf("cliOverrides must not be null") + } + process.Args = slices.Clone(*layer.CLIOverrides) + } + } + return nil +} + +func cloneProcess(in Process) Process { + out := in + out.Command = slices.Clone(in.Command) + out.Args = slices.Clone(in.Args) + out.Env = make([]corev1.EnvVar, len(in.Env)) + for i := range in.Env { + in.Env[i].DeepCopyInto(&out.Env[i]) + } + out.Identity = in.Identity.DeepCopy() + out.Access = slices.Clone(in.Access) + out.Lifecycle = in.Lifecycle.DeepCopy() + out.StartupProbe = in.StartupProbe.DeepCopy() + out.ReadinessProbe = in.ReadinessProbe.DeepCopy() + out.LivenessProbe = in.LivenessProbe.DeepCopy() + return out +} diff --git a/internal/framework/pipeline/overrides_test.go b/internal/framework/pipeline/overrides_test.go new file mode 100644 index 00000000..5e7986ff --- /dev/null +++ b/internal/framework/pipeline/overrides_test.go @@ -0,0 +1,67 @@ +package pipeline + +import ( + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestProcessOverridesReplaceSourcesAndKeepInputIsolated(t *testing.T) { + process := Process{Args: []string{"original"}, Env: []corev1.EnvVar{ + {Name: "SOURCE", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}, + {Name: "UNTOUCHED", Value: "keep"}, + }} + roleArgs, empty := []string{"role"}, []string{} + role := &Overrides{EnvOverrides: map[string]string{"SOURCE": "role", "ADDED": "role"}, CLIOverrides: &roleArgs} + group := &Overrides{EnvOverrides: map[string]string{"SOURCE": ""}, CLIOverrides: &empty} + if err := applyProcessOverrides(&process, nil, role, group); err != nil { + t.Fatal(err) + } + want := []corev1.EnvVar{{Name: "UNTOUCHED", Value: "keep"}, + {Name: "ADDED", Value: "role"}, {Name: "SOURCE", Value: ""}} + if !reflect.DeepEqual(process.Env, want) || process.Args == nil || len(process.Args) != 0 { + t.Fatalf("overrides lost empty values or retained ValueFrom: %+v", process) + } + if err := applyProcessOverrides(&process, &Overrides{CLIOverrides: &roleArgs}); err != nil { + t.Fatal(err) + } + process.Args[0] = "changed" + if roleArgs[0] != "role" || role.EnvOverrides["SOURCE"] != "role" || group.EnvOverrides["SOURCE"] != "" { + t.Fatal("process overrides mutated source layers") + } + var nullArgs []string + if err := applyProcessOverrides(&process, &Overrides{CLIOverrides: &nullArgs}); err == nil { + t.Fatal("explicit null CLI was accepted") + } +} + +func TestCloneRuntimeIsolatesMutableDeclarationData(t *testing.T) { + source := retainedRuntime() + uid := int64(1001) + source.SharedGroup = &uid + source.Main.Identity = &corev1.SecurityContext{RunAsUser: &uid} + source.Main.Command, source.Main.Args = []string{"launcher"}, []string{"run"} + source.Main.Env = []corev1.EnvVar{{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }}} + source.Files = append(source.Files, File{Directory: "config", Path: "jvm.config", Content: Lines{"original"}}) + before := CloneRuntime(source) + copy := CloneRuntime(source) + *copy.SharedGroup = 0 + *copy.Main.Identity.RunAsUser = 0 + copy.Main.Env[0].ValueFrom.FieldRef.FieldPath = "metadata.uid" + copy.Main.Command[0], copy.Main.Args[0] = "changed", "changed" + copy.Main.Access[0].MountPath = "/changed" + copy.Directories[2].Data = false + copy.Files[0].Content.(KeyValues).Values["node.id"] = Literal("changed") + copy.Files[1].Content.(Lines)[0] = "changed" + copy.Endpoints[0].Port = 9000 + copy.LogOutputs[0].RelativePath = "changed" + if !reflect.DeepEqual(source, before) { + t.Fatal("cloned runtime shares mutable declaration state with the input") + } + if copy.Files[0].Content.(KeyValues).Codec != source.Files[0].Content.(KeyValues).Codec { + t.Fatal("cloning replaced a stateless codec capability") + } +} diff --git a/internal/framework/pipeline/pipeline_integration_test.go b/internal/framework/pipeline/pipeline_integration_test.go new file mode 100644 index 00000000..d186dbba --- /dev/null +++ b/internal/framework/pipeline/pipeline_integration_test.go @@ -0,0 +1,249 @@ +package pipeline + +import ( + "flag" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/internal/framework/pipeline/testinput" + "github.com/zncdatadev/operator-go/pkg/framework/inputgen" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var updateInputFixture = flag.Bool("update-input-fixture", false, "regenerate the pipeline's test-only input fixture") + +func pipelineDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate pipeline test fixture") + } + return filepath.Dir(file) +} + +func TestInputFixtureCurrent(t *testing.T) { + artifacts, err := inputgen.Generate[TrinoConfig, TrinoClusterConfig](inputgen.Names{ + Package: "testinput", Group: testinput.GroupVersion.Group, Version: testinput.GroupVersion.Version, + Kind: "TrinoCluster", Plural: "trinoclusters", + }, sortedKeys(TrinoDefinition().Roles)) + if err != nil { + t.Fatal(err) + } + paths := []string{"zz_generated.input.go", "crd.yaml"} + expected := [][]byte{artifacts.GoSource, artifacts.CRD} + actual := make([][]byte, len(paths)) + for i, name := range paths { + path := filepath.Join(pipelineDirectory(t), "testinput", name) + if *updateInputFixture { + if err := os.WriteFile(path, expected[i], 0o600); err != nil { + t.Fatal(err) + } + } + actual[i], err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + } + if err := inputgen.Check(artifacts, actual[0], actual[1]); err != nil { + t.Fatalf("regenerate with -run TestInputFixtureCurrent -update-input-fixture: %v", err) + } +} + +func pipelineAPI(t *testing.T) client.Client { + t.Helper() + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + assets = filepath.Join(pipelineDirectory(t), "..", "..", "..", "bin", "k8s", + "1.35.0-"+runtime.GOOS+"-"+runtime.GOARCH) + } + if _, err := os.Stat(filepath.Join(assets, "kube-apiserver")); err != nil { + t.Fatalf("real API acceptance requires envtest assets: %v", err) + } + environment := &envtest.Environment{BinaryAssetsDirectory: assets, + CRDDirectoryPaths: []string{filepath.Join(pipelineDirectory(t), "testinput")}, ErrorIfCRDPathMissing: true} + config, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Errorf("stop envtest: %v", err) + } + }) + scheme := kruntime.NewScheme() + for _, add := range []func(*kruntime.Scheme) error{ + corev1.AddToScheme, appsv1.AddToScheme, policyv1.AddToScheme, testinput.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + return c +} + +// The control plane persists the real generated CR and accepts the resulting +// resources. Local materialization proves bytes, not kubelet or Trino execution. +func TestPersistedInputBuildsResourcesAndMaterializedBytes(t *testing.T) { + c := pipelineAPI(t) + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "u02-input"}} + if err := c.Create(t.Context(), ns); err != nil { + t.Fatal(err) + } + cr, err := testinput.Decode([]byte(`{ + "apiVersion":"pipeline.tests.kubedoop.dev/v1alpha1", "kind":"TrinoCluster", + "metadata":{"name":"demo","namespace":"u02-input"}, + "spec":{ + "clusterConfig":{"nodeEnvironment":"pipeline_env"}, + "coordinators":{"roleGroups":{"default":{}}}, + "workers":{ + "replicas":3, + "config":{"gracefulShutdownTimeout":"15s","resources":{"cpu":{"min":"750m"}}}, + "envOverrides":{"FROM_ROLE":"role"}, + "cliOverrides":["--etc-dir=/etc/trino","run"], + "podOverrides":{"spec":{"containers":[{"name":"trino","env":[{"name":"FROM_ROLE","value":"pod-role"}]}]}}, + "roleGroups":{ + "default":{"replicas":2,"config":{"resources":{"memory":{"limit":"2Gi"}}}, + "envOverrides":{"FROM_ROLE":"group","GROUP":"yes"},"cliOverrides":[], + "configOverrides":{"config.properties":{"properties":{"set":{"task.concurrency":"17"}}}, + "jvm.config":{"lines":["-Xmx512m"]}}}, + "batch":{"replicas":0,"config":{"logging":{"enableVectorAgent":false}}} + } + } + } + }`)) + if err != nil { + t.Fatal(err) + } + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + persisted := &testinput.TrinoCluster{} + if err := c.Get(t.Context(), types.NamespacedName{Name: cr.Name, Namespace: cr.Namespace}, persisted); err != nil { + t.Fatal(err) + } + before := persisted.DeepCopy() + if persisted.Spec.Coordinators.Replicas != nil || persisted.Spec.Workers.RoleGroups["default"].CLIOverrides == nil || + persisted.Spec.Workers.RoleGroups["batch"].Config.Logging.EnableVectorAgent == nil { + t.Fatal("API persistence lost omitted, empty or explicit false input") + } + projection, err := testinput.Project(persisted) + if err != nil { + t.Fatal(err) + } + plan, err := Build(TrinoDefinition(), projection, trinoTestFacts(), assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + if plan.ClusterError != "" || plan.ClusterOutput.State != ClusterOutputReady || + len(plan.ClusterOutput.ConfigMaps) != 1 { + t.Fatalf("shared output: %+v / %s", plan.ClusterOutput, plan.ClusterError) + } + if len(plan.Groups) != 3 || len(plan.Roles) != 2 { + t.Fatalf("incomplete resource plan: %d groups, %d roles", len(plan.Groups), len(plan.Roles)) + } + for _, group := range plan.Groups { + if group.Outcome.Error != "" || group.Resources == nil { + t.Fatalf("group %s: %s", group.Outcome.Group.Name, group.Outcome.Error) + } + resources := group.Resources + for _, object := range []client.Object{ + &resources.ConfigMap, &resources.Service, &resources.HeadlessService, &resources.StatefulSet, + } { + if err := c.Create(t.Context(), object); err != nil { + t.Fatalf("create %T %s: %v", object, object.GetName(), err) + } + } + assertPersistedWorker(t, group) + } + for _, role := range plan.Roles { + if role.Error != "" || role.PodDisruptionBudget == nil { + t.Fatalf("role budget: %+v", role) + } + if role.Role.Name == trinoWorkerRole && role.PodDisruptionBudget.Spec.MinAvailable.IntVal != 1 { + t.Fatalf("budget must use all declared replicas: %+v", role.PodDisruptionBudget.Spec) + } + if err := c.Create(t.Context(), role.PodDisruptionBudget); err != nil { + t.Fatal(err) + } + } + for i := range plan.ClusterOutput.ConfigMaps { + if err := c.Create(t.Context(), &plan.ClusterOutput.ConfigMaps[i]); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(before, persisted) { + t.Fatal("pipeline mutated the persisted input") + } +} + +func assertPersistedWorker(t *testing.T, group BuiltGroup[TrinoConfig, TrinoClusterConfig, TrinoFacts]) { + t.Helper() + resources := group.Resources + if group.Outcome.Group.Role != trinoWorkerRole { + return + } + pod := resources.StatefulSet.Spec.Template + if group.Outcome.Group.Name == "batch" { + if findContainer(pod, vectorContainerName) != nil || *resources.StatefulSet.Spec.Replicas != 0 { + t.Fatal("explicit false/zero did not survive API roundtrip and folding") + } + return + } + main := findContainer(pod, trinoName) + if *resources.StatefulSet.Spec.Replicas != 2 || len(main.Args) != 0 || + findContainer(pod, vectorContainerName) == nil || *pod.Spec.TerminationGracePeriodSeconds != 15 || + main.Resources.Requests.Cpu().String() != "750m" || main.Resources.Limits.Memory().String() != "2Gi" { + t.Fatalf("incorrect folded workload: %+v", main) + } + env := map[string]string{} + for _, variable := range main.Env { + env[variable.Name] = variable.Value + } + if env["FROM_ROLE"] != "pod-role" || env["GROUP"] != "yes" { + t.Fatalf("role podOverrides must beat group envOverrides: %v", env) + } + materialization, err := DecodeMaterializationPlan([]byte(resources.ConfigMap.Data[materializationPlanFile])) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := Materialize(root, materialization, resources.StatefulSet.Name+"-0"); err != nil { + t.Fatal(err) + } + assertMaterializedContains(t, root, map[string][]string{ + "config/config.properties": {"task.concurrency=17\n"}, + "config/node.properties": {"node.environment=pipeline_env\n", "node.id=demo-workers-default-0\n"}, + "config/jvm.config": {"-Xmx512m\n"}, + "config/catalog/tpch.properties": {"connector.name=tpch\n"}, + }) +} + +func assertMaterializedContains(t *testing.T, root string, expected map[string][]string) { + t.Helper() + for name, fragments := range expected { + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if !strings.Contains(string(data), fragment) { + t.Fatalf("materialized %s lacks %q: %s", name, fragment, data) + } + } + } +} diff --git a/internal/framework/pipeline/pipeline_test.go b/internal/framework/pipeline/pipeline_test.go new file mode 100644 index 00000000..7b8b35c4 --- /dev/null +++ b/internal/framework/pipeline/pipeline_test.go @@ -0,0 +1,203 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func trinoTestFacts() TrinoFacts { + return TrinoFacts{Catalogs: map[string]map[string]string{"tpch": {"connector.name": "tpch"}}} +} + +func trinoTestProjection() input.Projection { + return input.Projection{Cluster: ClusterIdentity{Namespace: "default", Name: "demo"}, + Roles: []input.Role{ + {Name: trinoCoordinatorRole, Groups: []input.Group{{Name: "default"}}}, + {Name: trinoWorkerRole, Replicas: ptr.To(int32(3)), Groups: []input.Group{ + {Name: "default", Replicas: ptr.To(int32(2))}, {Name: "batch", Replicas: ptr.To(int32(0))}, + }}, + }} +} + +func TestSourceProjectionRetainsPresenceAndOwnsSnapshots(t *testing.T) { + projection := trinoTestProjection() + projection.Roles[1].Config = json.RawMessage(`{"httpPort":8081}`) + projection.Roles[1].Overrides = &Overrides{EnvOverrides: map[string]string{"LEVEL": "role"}} + projection.Roles = append(projection.Roles, input.Role{Name: "empty"}) + facts := trinoTestFacts() + source, err := SourceFromProjection(projection, facts, ClusterOperation{Stopped: true}) + if err != nil { + t.Fatal(err) + } + if len(source.Roles) != 3 || len(source.Groups) != 3 || !source.Operation.Stopped || + source.Groups[0].Replicas != 1 || source.Groups[1].Replicas != 2 || source.Groups[2].Replicas != 0 { + t.Fatalf("wrong internal inventory: %+v", source) + } + if projection.Roles[0].Groups[0].Replicas != nil || projection.Roles[0].Replicas != nil { + t.Fatal("projection must remain raw") + } + source.Groups[1].RoleConfigLayer[0] = '[' + source.Groups[1].RoleOverrides.EnvOverrides["LEVEL"] = "changed" + source.Shared.Catalogs["tpch"]["connector.name"] = "changed" + if source.Groups[2].RoleConfigLayer[0] != '{' || + source.Groups[2].RoleOverrides.EnvOverrides["LEVEL"] != "role" || + projection.Roles[1].Overrides.EnvOverrides["LEVEL"] != "role" || + facts.Catalogs["tpch"]["connector.name"] != "tpch" { + t.Fatal("a source group aliases its siblings, projection or caller facts") + } +} + +func TestPipelineCallbacksAreIsolatedAndBuildIsDeterministic(t *testing.T) { + definition := TrinoDefinition() + projection, facts := trinoTestProjection(), trinoTestFacts() + beforeProjection, beforeFacts := CloneInput(projection), CloneInput(facts) + validate, generate, shared := definition.ValidateInput, definition.GenerateGroup, definition.GenerateCluster + var retained []RuntimeDescription + var retainedShared []corev1.ConfigMap + definition.ValidateInput = func(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) error { + if in.Facts.Catalogs["tpch"]["connector.name"] != "tpch" || in.Topology[0].Error != "" { + t.Fatal("another callback contaminated validation input") + } + err := validate(in) + in.Facts.Catalogs["tpch"]["connector.name"] = "validator mutation" + in.Topology[0].Error = "validator mutation" + return err + } + definition.GenerateGroup = func(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) ( + RuntimeDescription, error, + ) { + if in.Facts.Catalogs["tpch"]["connector.name"] != "tpch" || in.Topology[0].Error != "" { + t.Fatal("validation contaminated generation") + } + runtime, err := generate(in) + in.Facts.Catalogs["tpch"]["connector.name"] = "generator mutation" + retained = append(retained, runtime) + return runtime, err + } + definition.GenerateCluster = func(in framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]) ( + ClusterOutput, error, + ) { + out, err := shared(in) + in.Shared.Catalogs["tpch"]["connector.name"] = "cluster mutation" + in.Groups[0].GeneratedEndpoints[0].Port = 1 + retainedShared = out.ConfigMaps + return out, err + } + first, err := Build(definition, projection, facts, assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + second, err := Build(definition, projection, facts, assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + if first.ClusterError != "" || !reflect.DeepEqual(first, second) || + !reflect.DeepEqual(projection, beforeProjection) || !reflect.DeepEqual(facts, beforeFacts) { + t.Fatal("identical builds differ or callbacks changed caller data") + } + for _, runtime := range retained { + runtime.Main.Command[0] = "mutated-after-build" + runtime.Files[0].Content.(KeyValues).Values["coordinator"] = Literal("mutated-after-build") + } + retainedShared[0].Data["TRINO_URI"] = "mutated-after-build" + for _, group := range second.Groups { + if group.Resources == nil || group.Runtime.Main.Command[0] == "mutated-after-build" || + group.Runtime.Files[0].Content.(KeyValues).Values["coordinator"] == Literal("mutated-after-build") { + t.Fatal("returned plan aliases a product-owned runtime") + } + } + if second.ClusterOutput.ConfigMaps[0].Data["TRINO_URI"] == "mutated-after-build" { + t.Fatal("returned shared output aliases a product-owned slice or map") + } +} + +func TestPipelineFactsAndStoppedDoNotChangeDeclaredBudgets(t *testing.T) { + source, err := SourceFromProjection(trinoTestProjection(), trinoTestFacts(), ClusterOperation{Stopped: true}) + if err != nil { + t.Fatal(err) + } + prepared, err := PrepareInputs(TrinoDefinition(), source) + if err != nil { + t.Fatal(err) + } + results := map[GroupKey]framework.FactResult[TrinoFacts]{ + {Role: trinoCoordinatorRole, Name: "default"}: { + Diagnostic: FactDiagnostic{State: FactsPending, Reason: "CatalogPending"}, + }, + {Role: trinoWorkerRole, Name: "default"}: { + Value: ptr.To(trinoTestFacts()), Diagnostic: FactDiagnostic{State: FactsResolved}, + }, + // The zero-replica worker is deliberately missing a resolver result. + } + plan, err := BuildPreparedResources(TrinoDefinition(), prepared, results, assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + if plan.ClusterError != "" || plan.ClusterOutput.State != ClusterOutputPending || + len(plan.ClusterOutput.ConfigMaps) != 0 { + t.Fatalf("missing coordinator facts must withhold shared output: %+v", plan.ClusterOutput) + } + for _, group := range plan.Groups { + if group.Outcome.Group.Role == trinoWorkerRole && group.Outcome.Group.Name == "default" { + if group.Resources == nil || *group.Resources.StatefulSet.Spec.Replicas != 0 || + group.Input.Group.Replicas != 2 || len(group.Input.Topology) != 3 { + t.Fatal("stopped must affect execution only, and an unrelated pending group must not block this group") + } + } else if group.Resources != nil || group.Outcome.Facts == nil || group.Outcome.Facts.State == FactsResolved { + t.Fatal("unresolved groups must have diagnostics and no desired resources") + } + } + role := roleConfigBuilt(t, plan.Roles, trinoWorkerRole) + if role.PodDisruptionBudget.Spec.MinAvailable.IntVal != 1 { + t.Fatal("role PDB lost declared replicas due to stop or missing facts") + } +} + +func TestPipelineSharedOutputExplicitStateAndFailuresPreserveGroups(t *testing.T) { + tests := []struct { + name string + value ClusterOutput + err error + valid bool + }{ + {name: "empty ready", value: ClusterOutput{State: ClusterOutputReady}, valid: true}, + {name: "pending", value: ClusterOutput{State: ClusterOutputPending, Reason: "dependency pending"}, valid: true}, + {name: "missing state"}, + {name: "generator error", err: errors.New("shared generation failed")}, + {name: "partial pending", value: ClusterOutput{State: ClusterOutputPending, Reason: "pending", + ConfigMaps: []corev1.ConfigMap{{ObjectMeta: metav1.ObjectMeta{Name: "partial", Namespace: "default"}}}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := TrinoDefinition() + definition.GenerateCluster = func(framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]) ( + ClusterOutput, error, + ) { + return test.value, test.err + } + plan, err := Build(definition, trinoTestProjection(), trinoTestFacts(), assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + if (plan.ClusterError == "") != test.valid { + t.Fatalf("shared error = %q, valid = %v", plan.ClusterError, test.valid) + } + if !test.valid && (plan.ClusterOutput.State != "" || len(plan.ClusterOutput.ConfigMaps) != 0) { + t.Fatal("failed shared output must not expose partial resources") + } + for _, group := range plan.Groups { + if group.Resources == nil || group.Outcome.Error != "" { + t.Fatal("cluster output failure suppressed valid group resources") + } + } + }) + } +} diff --git a/internal/framework/pipeline/platform_volumes.go b/internal/framework/pipeline/platform_volumes.go new file mode 100644 index 00000000..84af88fe --- /dev/null +++ b/internal/framework/pipeline/platform_volumes.go @@ -0,0 +1,228 @@ +package pipeline + +import ( + "fmt" + "reflect" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/utils/ptr" +) + +const secretDriver = "secrets.kubedoop.dev" +const listenerDriver = "listeners.kubedoop.dev" + +func validatePlatformVolumes(runtime RuntimeDescription) error { + for _, d := range runtime.Directories { + sources := 0 + if d.Data { + sources++ + } + if d.Secret != nil { + sources++ + } + if d.Listener != nil { + sources++ + } + if sources > 1 { + return fmt.Errorf("directory %s declares multiple volume sources", d.Name) + } + if d.Secret == nil && d.Listener == nil { + continue + } + if d.Name == runtime.ConfigDirectory { + return fmt.Errorf("platform directory cannot be the generated config directory") + } + for _, file := range runtime.Files { + if file.Directory == d.Name { + return fmt.Errorf("generated files cannot write platform directory %s", d.Name) + } + } + for _, log := range runtime.LogOutputs { + if log.Directory == d.Name { + return fmt.Errorf("logs cannot write platform directory %s", d.Name) + } + } + accessed := false + for _, process := range append([]Process{runtime.Main}, runtime.Initializers...) { + for _, a := range process.Access { + if a.Directory == d.Name { + accessed = true + if !a.ReadOnly { + return fmt.Errorf("platform directory %s requires read-only access", d.Name) + } + } + } + } + if !accessed { + return fmt.Errorf("platform directory %s has no declared consumer", d.Name) + } + if err := validatePlatformSource(d); err != nil { + return err + } + } + return nil +} + +func platformVolume(d Directory) (corev1.Volume, bool) { + volume := corev1.Volume{Name: d.Name} + if d.Secret == nil && d.Listener == nil { + return volume, false + } + annotations := map[string]string{} + class := listenerDriver + if s := d.Secret; s != nil { + if s.SecretName != "" { + volume.Secret = &corev1.SecretVolumeSource{SecretName: s.SecretName, DefaultMode: ptr.To(int32(0440))} + return volume, true + } + class = secretDriver + annotations[secretDriver+"/class"] = s.SecretClass + if s.Format != "" { + annotations[secretDriver+"/format"] = s.Format + } + if len(s.Scope) > 0 { + annotations[secretDriver+"/scope"] = strings.Join(s.Scope, ",") + } + if len(s.KerberosServiceNames) > 0 { + annotations[secretDriver+"/kerberosServiceNames"] = strings.Join(s.KerberosServiceNames, ",") + } + } else { + if d.Listener.Class != "" { + annotations[listenerDriver+"/class"] = d.Listener.Class + } + if d.Listener.Name != "" { + annotations[listenerDriver+"/listenerName"] = d.Listener.Name + } + } + volume.Ephemeral = &corev1.EphemeralVolumeSource{VolumeClaimTemplate: &corev1.PersistentVolumeClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{Annotations: annotations}, Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, StorageClassName: &class, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Mi")}}}}} + return volume, true +} + +func checkPlatformVolumes(expected, actual GroupResources, runtime RuntimeDescription) []framework.Check { + var checks []framework.Check + for _, d := range runtime.Directories { + if d.Secret == nil && d.Listener == nil { + continue + } + check := framework.Check{Subject: "platform.directory[" + d.Name + "]", State: framework.Consistent, + Reason: "platform source and consumer mounts preserved"} + before := findPodVolume(expected.StatefulSet.Spec.Template.Spec, d.Name) + after := findPodVolume(actual.StatefulSet.Spec.Template.Spec, d.Name) + if !reflect.DeepEqual(before, after) { + check.State = framework.Conflict + check.Reason = "podOverrides changed a declared platform volume" + } + for _, p := range append([]Process{runtime.Main}, runtime.Initializers...) { + container := findContainer(actual.StatefulSet.Spec.Template, p.Name) + if container == nil { + for i := range actual.StatefulSet.Spec.Template.Spec.InitContainers { + c := &actual.StatefulSet.Spec.Template.Spec.InitContainers[i] + if c.Name == p.Name { + container = c + } + } + } + for _, a := range p.Access { + if a.Directory != d.Name { + continue + } + m := findMount(container, a.MountPath) + if m == nil || m.Name != d.Name || !m.ReadOnly || m.SubPath != "" || m.SubPathExpr != "" || + platformMountMasked(container, a.MountPath) { + check.State = framework.Conflict + check.Reason = "podOverrides displaced a platform consumer mount" + } + } + } + checks = append(checks, check) + } + return checks +} + +func validatePlatformSource(d Directory) error { + if d.Secret != nil { + return validateSecretSource(d.Name, d.Secret) + } + if l := d.Listener; l != nil { + if (l.Class == "") == (l.Name == "") { + return fmt.Errorf("listener directory requires exactly one class or name") + } + for _, name := range []string{l.Class, l.Name} { + if name != "" && len(validation.IsDNS1123Subdomain(name)) != 0 { + return fmt.Errorf("invalid listener reference") + } + } + } + return nil +} + +func validateSecretSource(directory string, s *framework.SecretVolume) error { + + if (s.SecretName == "") == (s.SecretClass == "") { + return fmt.Errorf("secret directory %s requires exactly one Secret or SecretClass", directory) + } + for _, name := range []string{s.SecretName, s.SecretClass} { + if name != "" && len(validation.IsDNS1123Subdomain(name)) != 0 { + return fmt.Errorf("invalid secret reference") + } + } + if s.SecretName != "" && (s.Format != "" || len(s.Scope) > 0 || len(s.KerberosServiceNames) > 0) { + return fmt.Errorf("native Secret cannot specify CSI options") + } + if s.Format != "" && s.Format != "tls-pem" && s.Format != "tls-p12" && s.Format != "kerberos" { + return fmt.Errorf("unsupported SecretClass format") + } + for _, scope := range s.Scope { + if scope == framework.SecretScopePod || scope == framework.SecretScopeNode { + continue + } + key, value, ok := strings.Cut(scope, "=") + if !ok || (key != "service" && key != "listener-volume") || len(validation.IsDNS1123Subdomain(value)) != 0 { + return fmt.Errorf("invalid SecretClass scope") + } + } + for _, name := range s.KerberosServiceNames { + if len(validation.IsDNS1123Label(name)) != 0 { + return fmt.Errorf("invalid Kerberos service name") + } + } + + return nil +} + +func platformMountMasked(container *corev1.Container, root string) bool { + if container == nil { + return true + } + for _, mount := range container.VolumeMounts { + if strings.HasPrefix(mount.MountPath, strings.TrimSuffix(root, "/")+"/") { + return true + } + } + return false +} + +// DeclaredPlatformClaims reconstructs only typed platform ephemeral sources. +// Controllers use these declarations to distinguish platform-owned temporary +// claims from arbitrary Pod override storage during later retirement. +func DeclaredPlatformClaims(runtime *framework.RuntimeDescription) []corev1.Volume { + var out []corev1.Volume + if runtime == nil { + return out + } + for _, d := range runtime.Directories { + if volume, ok := platformVolume(d); ok && volume.Ephemeral != nil { + out = append(out, volume) + } + } + return out +} diff --git a/internal/framework/pipeline/platform_volumes_test.go b/internal/framework/pipeline/platform_volumes_test.go new file mode 100644 index 00000000..03239787 --- /dev/null +++ b/internal/framework/pipeline/platform_volumes_test.go @@ -0,0 +1,61 @@ +package pipeline + +import ( + "encoding/json" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" +) + +func TestPlatformDirectoryAssemblyAndConsumerProtection(t *testing.T) { + runtime := runtimeFixture() + runtime.Files = nil + runtime.LogOutputs = nil + runtime.Directories = append(runtime.Directories, + Directory{Name: "credentials", Secret: &framework.SecretVolume{SecretClass: "database", Scope: []string{"pod"}}}, + Directory{Name: "external", Listener: &framework.ListenerVolume{Class: "public"}}) + runtime.Main.Access = append(runtime.Main.Access, + DirectoryAccess{Directory: "credentials", MountPath: "/credentials", ReadOnly: true}, + DirectoryAccess{Directory: "external", MountPath: "/listener", ReadOnly: true}) + if err := ValidateRuntime(runtime); err != nil { + t.Fatal(err) + } + group := GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "demo", Namespace: "test"}, Role: "workers", + Name: "default", Replicas: 1} + expected, err := assembleGroup(group, CommonConfig{}, ResolvedImage{PullPolicy: corev1.PullIfNotPresent}, + runtime, runtime.Main, nil, nil, AssemblyOptions{}) + if err != nil { + t.Fatal(err) + } + pod := expected.StatefulSet.Spec.Template.Spec + secret := findPodVolume(pod, "credentials") + listener := findPodVolume(pod, "external") + if secret.Ephemeral == nil || *secret.Ephemeral.VolumeClaimTemplate.Spec.StorageClassName != "secrets.kubedoop.dev" { + t.Fatal("SecretClass was not declared to its CSI provisioner") + } + if secret.Ephemeral.VolumeClaimTemplate.Annotations["secrets.kubedoop.dev/scope"] != "pod" || + listener.Ephemeral.VolumeClaimTemplate.Annotations["listeners.kubedoop.dev/class"] != "public" { + t.Fatal("platform declarations lost their native provisioner contract") + } + for _, check := range CheckAssembly(expected, expected, runtime, nil) { + if check.State == Conflict { + t.Fatalf("valid platform declaration rejected: %+v", check) + } + } + actual := cloneGroupResources(expected) + // The final podOverrides layer keeps its priority, but a declared credential + // consumer cannot be silently redirected to unrelated files. + patch := json.RawMessage(`{"spec":{"containers":[{"name":"trino", + "volumeMounts":[{"mountPath":"/credentials/key","name":"logs"}]}]}}`) + if err := patchPod(&actual.StatefulSet.Spec.Template, patch); err != nil { + t.Fatal(err) + } + conflict := false + for _, check := range CheckAssembly(expected, actual, runtime, nil) { + conflict = conflict || check.State == Conflict + } + if !conflict { + t.Fatal("overlapping override hid the declared credential consumer") + } +} diff --git a/internal/framework/pipeline/prepare.go b/internal/framework/pipeline/prepare.go new file mode 100644 index 00000000..2c065d2c --- /dev/null +++ b/internal/framework/pipeline/prepare.go @@ -0,0 +1,100 @@ +package pipeline + +import ( + "fmt" + "reflect" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +const factMissingResultReason = "MissingResult" + +func PrepareInputs[C, S, F any](definition framework.ProductDefinition[C, S, F], source SourceSnapshot[F]) ( + PreparedInputs[C, S, F], error, +) { + var out PreparedInputs[C, S, F] + if err := ValidateDefinition(definition); err != nil { + return out, err + } + if err := checkProfile(reflect.TypeFor[F](), make(map[reflect.Type]bool)); err != nil { + return out, fmt.Errorf("shared facts: %w", err) + } + // Preserve omitted raw config layers: JSON roundtrip would turn nil into literal null. + snapshot := CloneInput(source) + clusterConfig, err := ResolveClusterConfig(definition.ClusterConfigDefaults, snapshot.ClusterConfig) + if err != nil { + return out, err + } + platform, _, err := splitPlatformConfig(snapshot.ClusterConfig) + if err != nil { + return out, err + } + image, err := ResolveImage(definition.Name, definition.ImageDefaults, snapshot.Image) + if err != nil { + return out, err + } + if _, err := SourceRoleIdentities(snapshot); err != nil { + return out, err + } + topology, err := resolveTopology(definition, snapshot) + if err != nil { + return out, err + } + return PreparedInputs[C, S, F]{Platform: platform, Source: snapshot, ClusterConfig: clusterConfig, + Image: image, Topology: topology}, nil +} + +func normalizeFacts[C, F any](topology []framework.ResolvedGroup[C], facts map[GroupKey]framework.FactResult[F]) ( + map[GroupKey]framework.FactResult[F], error, +) { + if facts == nil { + return nil, nil + } + known := make(map[GroupKey]bool, len(topology)) + out := make(map[GroupKey]framework.FactResult[F], len(topology)) + for _, group := range topology { + key := GroupKey{Role: group.Group.Role, Name: group.Group.Name} + known[key] = true + if group.Config == nil { + continue + } + result, ok := facts[key] + if !ok { + result.Diagnostic = FactDiagnostic{State: FactsInvalid, Reason: factMissingResultReason, + Message: "facts resolver did not provide a result for this group"} + } else { + switch result.Diagnostic.State { + case FactsResolved: + if result.Value == nil { + result = invalidFactResult(result) + } + case FactsPending, FactsInvalid, FactsReadError: + if result.Value != nil { + result = invalidFactResult(result) + } + default: + result = invalidFactResult(result) + } + } + cloned, err := copyJSON(result) + if err != nil { + // A supported type can still contain a value outside the JSON data + // profile (for example NaN). That invalidates only its consumer. + cloned = framework.FactResult[F]{Diagnostic: FactDiagnostic{State: FactsInvalid, Reason: "InvalidFactValue", + Message: "resolved facts cannot be represented by the supported data profile", + Observed: CloneInput(result.Diagnostic.Observed)}} + } + out[key] = cloned + } + for key := range facts { + if !known[key] { + return nil, fmt.Errorf("facts result references unknown group %s/%s", key.Role, key.Name) + } + } + return out, nil +} + +func invalidFactResult[F any](in framework.FactResult[F]) framework.FactResult[F] { + return framework.FactResult[F]{Diagnostic: FactDiagnostic{State: FactsInvalid, Reason: "InvalidResult", + Message: "facts result must have a known state and a value only when resolved", Observed: in.Diagnostic.Observed}} +} diff --git a/internal/framework/pipeline/resource_build.go b/internal/framework/pipeline/resource_build.go new file mode 100644 index 00000000..61576204 --- /dev/null +++ b/internal/framework/pipeline/resource_build.go @@ -0,0 +1,331 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "path" + "strings" + "time" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/validation" + kubernetesjson "sigs.k8s.io/json" +) + +const ( + labelInstance = "app.kubernetes.io/instance" + labelComponent = "app.kubernetes.io/component" + kindConfigMap = "ConfigMap" + kindService = "Service" + kindStatefulSet = "StatefulSet" + materializerPodNameEnv = "POD_NAME" + materializerPodNameFieldPath = "metadata.name" +) + +func buildGroup(identity GroupIdentity, common CommonConfig, image ResolvedImage, runtime RuntimeDescription, + source GroupSource, options AssemblyOptions, final func(FinalView) []Check, +) (*GroupResources, []File, []Check, error) { + composed, vector, err := composeVector(runtime, common.Logging.EnableVectorAgent, options) + if err != nil { + return nil, nil, nil, err + } + if err := ValidateRuntime(composed); err != nil { + return nil, nil, nil, err + } + fileLayers := make([]map[string]FileOverride, 0, 2) + for _, layer := range []*Overrides{source.RoleOverrides, source.Overrides} { + if layer != nil { + fileLayers = append(fileLayers, layer.ConfigOverrides) + } + } + files := cloneDeclaredFiles(composed.Files) + if composed.ConfigDirectory != "" || hasFileOverrides(fileLayers) { + files, err = ApplyFileOverrides(composed.Files, composed.ConfigDirectory, fileLayers...) + } + if err != nil { + return nil, nil, nil, err + } + process := CloneRuntime(composed).Main + if err := applyProcessOverrides(&process, source.RoleOverrides, source.Overrides); err != nil { + return nil, files, nil, err + } + expected, err := assembleGroup(identity, common, image, composed, process, vector, files, options) + if err != nil { + return nil, files, nil, err + } + actual := cloneGroupResources(expected) + for index, layer := range []*Overrides{source.RoleOverrides, source.Overrides} { + if layer == nil || len(layer.PodOverrides) == 0 { + continue + } + if err := patchPod(&actual.StatefulSet.Spec.Template, layer.PodOverrides); err != nil { + return nil, files, nil, fmt.Errorf("podOverrides layer %d: %w", index, err) + } + } + checks := CheckAssembly(expected, actual, composed, files) + if final != nil { + view := FinalView{Generated: CloneRuntime(composed), Files: cloneDeclaredFiles(files), + FilePreparationKnown: filePreparationKnown(expected, actual, files), + LogCollectionKnown: collectorKnown(expected, actual, composed, files), + Pod: *actual.StatefulSet.Spec.Template.DeepCopy(), Services: []corev1.Service{ + *actual.Service.DeepCopy(), *actual.HeadlessService.DeepCopy()}} + checks = append(checks, final(view)...) + } + conflicts := []error{} + for _, check := range checks { + if check.State == Conflict { + conflicts = append(conflicts, fmt.Errorf("%s: %s", check.Subject, check.Reason)) + } + } + if err := errors.Join(conflicts...); err != nil { + return nil, files, checks, err + } + return &actual, files, checks, nil +} + +func hasFileOverrides(layers []map[string]FileOverride) bool { + for _, layer := range layers { + if len(layer) > 0 { + return true + } + } + return false +} + +func assembleGroup(identity GroupIdentity, common CommonConfig, image ResolvedImage, runtime RuntimeDescription, + process Process, vector *corev1.Container, files []File, options AssemblyOptions, +) (GroupResources, error) { + var out GroupResources + name := identity.ServiceName() + headless := name + "-headless" + if len(validation.IsDNS1035Label(headless)) != 0 { + return out, fmt.Errorf("headless Service name %q is too long", headless) + } + selector := map[string]string{labelInstance: identity.ClusterIdentity.Name, + labelComponent: identity.Role, "role-group": identity.Name} + labels := resourceLabels(identity.ClusterIdentity, selector) + metadata := func(value string) metav1.ObjectMeta { + return metav1.ObjectMeta{Name: value, Namespace: identity.Namespace, + Labels: CloneInput(labels)} + } + plan, err := PrepareMaterialization(files) + if err != nil { + return out, err + } + encoded, err := EncodeMaterializationPlan(plan) + if err != nil { + return out, err + } + if len(encoded) > 1024*1024 { + return out, fmt.Errorf("materialization plan exceeds ConfigMap data capacity") + } + out.ConfigMap = corev1.ConfigMap{ObjectMeta: metadata(name), + Data: map[string]string{materializationPlanFile: string(encoded)}} + main := corev1.Container{Name: process.Name, Image: process.Image, Command: process.Command, Args: process.Args, + Env: process.Env, SecurityContext: process.Identity, ImagePullPolicy: image.PullPolicy, + Lifecycle: process.Lifecycle.DeepCopy(), StartupProbe: process.StartupProbe.DeepCopy(), + ReadinessProbe: process.ReadinessProbe.DeepCopy(), LivenessProbe: process.LivenessProbe.DeepCopy(), + Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceCPU: common.Resources.CPU.Min.DeepCopy(), + corev1.ResourceMemory: common.Resources.Memory.Limit.DeepCopy()}, + Limits: corev1.ResourceList{corev1.ResourceCPU: common.Resources.CPU.Max.DeepCopy(), + corev1.ResourceMemory: common.Resources.Memory.Limit.DeepCopy()}}} + for _, access := range process.Access { + main.VolumeMounts = append(main.VolumeMounts, corev1.VolumeMount{ + Name: access.Directory, MountPath: access.MountPath, ReadOnly: access.ReadOnly}) + } + pod := corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: CloneInput(labels)}, Spec: corev1.PodSpec{ + Containers: []corev1.Container{main}, + SecurityContext: &corev1.PodSecurityContext{FSGroup: CloneInput(runtime.SharedGroup)}, + Affinity: common.Affinity.DeepCopy()}} + if image.PullSecretName != "" { + pod.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: image.PullSecretName}} + } + graceSeconds := int64(common.GracefulShutdownTimeout.Duration / time.Second) + pod.Spec.TerminationGracePeriodSeconds = &graceSeconds + for _, directory := range runtime.Directories { + if directory.Name == materializationPlanVolume { + return out, fmt.Errorf("directory %q conflicts with plan volume", directory.Name) + } + if volume, ok := platformVolume(directory); ok { + pod.Spec.Volumes = append(pod.Spec.Volumes, volume) + continue + } + if directory.Data && common.Resources.Storage.Type == framework.StoragePersistent { + out.RetainedData = &RetainedDataSlot{Name: directory.Name, RetainedData: RetainedData{ + StorageClassName: common.Resources.Storage.StorageClassName, + Capacity: common.Resources.Storage.Capacity.DeepCopy()}} + continue + } + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{Name: directory.Name, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}) + } + if err := validateStorage(common.Resources.Storage); err != nil { + return out, err + } + if common.Resources.Storage.Type == framework.StoragePersistent && out.RetainedData == nil { + return out, fmt.Errorf("config.resources.storage: persistent storage requires a declared Data directory") + } + if len(files) > 0 { + if strings.TrimSpace(options.MaterializerImage) == "" { + return out, fmt.Errorf("materializer image is required") + } + if process.Name == materializerContainerName { + return out, fmt.Errorf("main container conflicts with materializer name") + } + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{Name: materializationPlanVolume, + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}}}}) + init := corev1.Container{Name: materializerContainerName, Image: options.MaterializerImage, + Command: []string{"/materialize"}, + Args: []string{"--plan=" + materializationPlanPath, "--root=" + materializationRoot}, + SecurityContext: options.HelperIdentity.DeepCopy(), Env: []corev1.EnvVar{{Name: materializerPodNameEnv, + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: materializerPodNameFieldPath}}}}, + VolumeMounts: []corev1.VolumeMount{{Name: materializationPlanVolume, MountPath: "/plan", ReadOnly: true}}} + directories := map[string]bool{} + for _, file := range files { + directories[file.Directory] = true + } + for _, directory := range sortedKeys(directories) { + init.VolumeMounts = append(init.VolumeMounts, corev1.VolumeMount{ + Name: directory, MountPath: path.Join(materializationRoot, directory)}) + } + pod.Spec.InitContainers = []corev1.Container{init} + } + for _, initializer := range runtime.Initializers { + container := corev1.Container{Name: initializer.Name, Image: initializer.Image, + Command: initializer.Command, Args: initializer.Args, Env: initializer.Env, + SecurityContext: initializer.Identity.DeepCopy(), ImagePullPolicy: image.PullPolicy, + Resources: *main.Resources.DeepCopy()} + for _, access := range initializer.Access { + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: access.Directory, MountPath: access.MountPath, ReadOnly: access.ReadOnly}) + } + pod.Spec.InitContainers = append(pod.Spec.InitContainers, container) + } + if vector != nil { + pod.Spec.Containers = append(pod.Spec.Containers, *vector.DeepCopy()) + } + out.Service = corev1.Service{ObjectMeta: metadata(name), Spec: corev1.ServiceSpec{Selector: CloneInput(selector)}} + for _, endpoint := range runtime.Endpoints { + pod.Spec.Containers[0].Ports = append(pod.Spec.Containers[0].Ports, corev1.ContainerPort{ + Name: endpoint.Name, ContainerPort: endpoint.Port, Protocol: corev1.ProtocolTCP}) + out.Service.Spec.Ports = append(out.Service.Spec.Ports, corev1.ServicePort{ + Name: endpoint.Name, Port: endpoint.Port, TargetPort: intstr.FromString(endpoint.Name), + Protocol: corev1.ProtocolTCP}) + } + out.HeadlessService = *out.Service.DeepCopy() + out.HeadlessService.ObjectMeta = metadata(headless) + out.HeadlessService.Spec.ClusterIP = corev1.ClusterIPNone + out.StatefulSet = appsv1.StatefulSet{ObjectMeta: metadata(name), Spec: appsv1.StatefulSetSpec{ + Replicas: &identity.Replicas, ServiceName: headless, + Selector: &metav1.LabelSelector{MatchLabels: CloneInput(selector)}, Template: pod}} + out.Coordination = CloneInput(runtime.Coordination) + if out.Coordination != nil { + out.StatefulSet.Spec.PodManagementPolicy = appsv1.OrderedReadyPodManagement + out.StatefulSet.Spec.UpdateStrategy.Type = appsv1.RollingUpdateStatefulSetStrategyType + } + if out.RetainedData != nil { + out.StatefulSet.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{retainedClaimTemplate(*out.RetainedData)} + out.StatefulSet.Spec.PersistentVolumeClaimRetentionPolicy = &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + } + } + return out, nil +} + +// resourceLabels propagates CR metadata under resource-specific declarations. +// Selector identity is supplied by the group assembler, not copied from the CR; +// ordinary label changes must never alter an immutable StatefulSet selector. +func resourceLabels(cluster ClusterIdentity, declared map[string]string) map[string]string { + labels := make(map[string]string, len(cluster.Labels)+len(declared)+1) + maps.Copy(labels, cluster.Labels) + maps.Copy(labels, declared) + labels[labelInstance] = cluster.Name + return labels +} + +func cloneGroupResources(in GroupResources) GroupResources { + out := in + out.ConfigMap = *in.ConfigMap.DeepCopy() + out.StatefulSet = *in.StatefulSet.DeepCopy() + out.Service = *in.Service.DeepCopy() + out.HeadlessService = *in.HeadlessService.DeepCopy() + out.RetainedData = CloneInput(in.RetainedData) + out.Coordination = CloneInput(in.Coordination) + return out +} + +func patchPod(pod *corev1.PodTemplateSpec, patch json.RawMessage) error { + var object map[string]json.RawMessage + if err := json.Unmarshal(patch, &object); err != nil || object == nil { + return fmt.Errorf("patch must be an object") + } + before, err := json.Marshal(pod) + if err != nil { + return err + } + after, err := strategicpatch.StrategicMergePatch(before, patch, corev1.PodTemplateSpec{}) + if err != nil { + return err + } + var next corev1.PodTemplateSpec + strict, err := kubernetesjson.UnmarshalStrict(after, &next) + if err != nil { + return err + } + if err := errors.Join(strict...); err != nil { + return fmt.Errorf("unsupported PodTemplate fields: %w", err) + } + *pod = next + return nil +} + +func checkResourceInventory[C, S, F any](plan ResourcePlan[C, S, F]) error { + seen := map[string]bool{} + add := func(kind, namespace, name string) error { + key := kind + "/" + namespace + "/" + name + if seen[key] { + return fmt.Errorf("duplicate resource producer %s", key) + } + seen[key] = true + return nil + } + for _, group := range plan.Groups { + if group.Resources != nil { + r := group.Resources + for _, item := range []struct { + kind string + object metav1.Object + }{ + {kindConfigMap, &r.ConfigMap}, {kindService, &r.Service}, {kindService, &r.HeadlessService}, + {kindStatefulSet, &r.StatefulSet}, + } { + if err := add(item.kind, item.object.GetNamespace(), item.object.GetName()); err != nil { + return err + } + } + } + } + for _, cm := range plan.ClusterOutput.ConfigMaps { + if err := add(kindConfigMap, cm.Namespace, cm.Name); err != nil { + return err + } + } + return nil +} + +func filePreparationKnown(expected, actual GroupResources, files []File) bool { + _, known := checkMaterializer(expected.StatefulSet.Spec.Template.Spec, actual.StatefulSet.Spec.Template.Spec, + len(files) > 0) + return known +} diff --git a/internal/framework/pipeline/resource_build_test.go b/internal/framework/pipeline/resource_build_test.go new file mode 100644 index 00000000..37620a6e --- /dev/null +++ b/internal/framework/pipeline/resource_build_test.go @@ -0,0 +1,151 @@ +package pipeline + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/utils/ptr" +) + +func assemblyRuntimeFixture() RuntimeDescription { + return RuntimeDescription{ + ConfigDirectory: "config", + Main: Process{Name: "trino", Image: "example.invalid/trino:fixture", Command: []string{"launcher"}, + Args: []string{"run"}, Env: []corev1.EnvVar{{Name: "SETTING", Value: "default"}}, + Access: []DirectoryAccess{ + {Directory: "config", MountPath: "/etc/trino", ReadOnly: true}, + {Directory: "logs", MountPath: "/var/log/trino"}, + }}, + Directories: []Directory{{Name: "config"}, {Name: "logs"}}, + Files: []File{{Directory: "config", Path: "node.properties", Content: KeyValues{ + Codec: PropertiesCodec{}, Values: map[string]PropertyValue{ + "node.environment": Literal("test"), "node.id": PodNameBinding{}, + }, + }}}, + Endpoints: []Endpoint{{Name: "http", Port: 8080}}, + LogOutputs: []LogOutput{{Container: "trino", Directory: "logs", RelativePath: "server.json"}}, + } +} + +func assemblyCommon(enabled bool) CommonConfig { + return CommonConfig{Resources: Resources{ + CPU: CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("1")}, + Memory: Memory{Limit: resource.MustParse("1Gi")}, + }, Logging: Logging{EnableVectorAgent: enabled}} +} + +func assemblyGroupIdentity() GroupIdentity { + return GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "sample", Namespace: "test"}, + Role: "workers", Name: "default", Replicas: 2} +} + +func assemblyBuildOptions() AssemblyOptions { + return AssemblyOptions{MaterializerImage: "example.invalid/materializer:test", + VectorImage: "example.invalid/vector:test"} +} + +func TestBuildGroupOverrideChannelsAndIsolation(t *testing.T) { + runtime := assemblyRuntimeFixture() + before := CloneRuntime(runtime) + roleArgs, groupArgs := []string{"role-cli"}, []string{} + source := GroupSource{RoleOverrides: &Overrides{ + EnvOverrides: map[string]string{"SETTING": "role-env"}, CLIOverrides: &roleArgs, + ConfigOverrides: map[string]FileOverride{"node.properties": { + Properties: &PropertyOverride{Set: ptr.To(map[string]string{"node.id": "explicit-id"})}, + }}, + PodOverrides: json.RawMessage(`{"spec":{"containers":[{"name":"trino","args":["role-pod"], + "env":[{"name":"SETTING","value":"role-pod"}]}]}}`), + }, Overrides: &Overrides{ + EnvOverrides: map[string]string{"SETTING": "group-env"}, CLIOverrides: &groupArgs, + ConfigOverrides: map[string]FileOverride{"node.properties": { + Properties: &PropertyOverride{Set: ptr.To(map[string]string{"node.environment": "group"})}, + }}, + }} + inputBefore := CloneInput(source) + image := ResolvedImage{PullPolicy: corev1.PullNever, PullSecretName: "registry"} + resources, files, checks, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(false), image, + runtime, source, assemblyBuildOptions(), nil) + if err != nil { + t.Fatalf("build failed: %v, checks=%+v", err, checks) + } + main := resources.StatefulSet.Spec.Template.Spec.Containers[0] + if !slices.Equal(main.Command, runtime.Main.Command) || !slices.Equal(main.Args, []string{"role-pod"}) || + main.Env[0].Value != "role-pod" || main.ImagePullPolicy != corev1.PullNever || + resources.StatefulSet.Spec.Template.Spec.ImagePullSecrets[0].Name != "registry" { + t.Fatalf("role Pod patch did not win over group env/CLI: %+v", main) + } + if !reflect.DeepEqual(runtime, before) || !reflect.DeepEqual(source, inputBefore) { + t.Fatal("assembly changed product declarations or override input") + } + file := findFile(files, "config", "node.properties") + if id, known := literalProperty(file, "node.id"); !known || id != "explicit-id" { + t.Fatal("file override failed to cancel the Pod name binding") + } + plan, err := DecodeMaterializationPlan([]byte(resources.ConfigMap.Data[materializationPlanFile])) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + if err := Materialize(directory, plan, "actual-pod-name"); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(directory, "config", "node.properties")) + if err != nil || !strings.Contains(string(content), "node.id=explicit-id") || + !strings.Contains(string(content), "node.environment=group") || strings.Contains(string(content), "actual-pod-name") { + t.Fatalf("materialized bytes lost layered override semantics: %s, %v", content, err) + } + source.Overrides.PodOverrides = json.RawMessage(`{"spec":{"containers":[{"name":"trino","args":["group-pod"]}]}}`) + resources, _, _, err = buildGroup(assemblyGroupIdentity(), assemblyCommon(false), image, + runtime, source, assemblyBuildOptions(), nil) + if err != nil || !slices.Equal(resources.StatefulSet.Spec.Template.Spec.Containers[0].Args, []string{"group-pod"}) { + t.Fatalf("group Pod patch did not run last: %v", err) + } +} + +func TestBuildGroupFinalViewIsIsolated(t *testing.T) { + runtime := assemblyRuntimeFixture() + var observed FinalView + resources, files, _, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(true), ResolvedImage{}, runtime, + GroupSource{}, assemblyBuildOptions(), func(view FinalView) []Check { + observed = view + view.Generated.Main.Command[0] = "mutated" + view.Pod.Spec.Containers[0].Name = "mutated" + view.Files[0].Content.(KeyValues).Values["node.id"] = Literal("mutated") + view.Services[0].Spec.Ports[0].Port = 9 + return []Check{{Subject: "product-observation", State: Unknown, Reason: "test premise"}} + }) + if err != nil { + t.Fatal(err) + } + if !observed.FilePreparationKnown || !observed.LogCollectionKnown { + t.Fatalf("unmodified selected helpers should remain known: %+v", observed) + } + if runtime.Main.Command[0] != "launcher" || resources.StatefulSet.Spec.Template.Spec.Containers[0].Name != "trino" || + resources.Service.Spec.Ports[0].Port != 8080 { + t.Fatal("final validator mutated the generated resources or original runtime") + } + if _, ok := files[0].Content.(KeyValues).Values["node.id"].(PodNameBinding); !ok { + t.Fatal("final validator mutated the materialized file description") + } +} + +func TestBuildGroupRejectsIndependentCollectorConflict(t *testing.T) { + source := GroupSource{Overrides: &Overrides{ + ConfigOverrides: map[string]FileOverride{vectorConfigFile: {Remove: ptr.To(true)}}, + PodOverrides: json.RawMessage(`{"spec":{"containers":[{"name":"trino","command":["custom"]}]}}`), + }} + resources, _, checks, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(true), ResolvedImage{}, + assemblyRuntimeFixture(), source, assemblyBuildOptions(), nil) + if err == nil || resources != nil { + t.Fatal("a changed main premise hid the retained Vector's missing config") + } + requireAssemblyCheck(t, checks, "assembly.main.execution", Unknown) + requireAssemblyCheck(t, checks, "vector.config", Conflict) +} diff --git a/internal/framework/pipeline/resource_checks.go b/internal/framework/pipeline/resource_checks.go new file mode 100644 index 00000000..174ccb61 --- /dev/null +++ b/internal/framework/pipeline/resource_checks.go @@ -0,0 +1,392 @@ +package pipeline + +import ( + "fmt" + "path" + "reflect" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// CheckAssembly reports independent structural and modeled-consumer relations. +// It does not repair the Pod or replace Kubernetes admission/product validation. +// A changed execution premise makes that consumer unknown, not every relation. +func CheckAssembly(expected, actual GroupResources, generated RuntimeDescription, files []File) []Check { + pod, baseline := actual.StatefulSet.Spec.Template, expected.StatefulSet.Spec.Template + checks := checkPodStructure(pod.Spec, actual.StatefulSet.Spec.VolumeClaimTemplates...) + checks = append(checks, checkRetainedData(expected, actual, generated)...) + checks = append(checks, checkPlatformVolumes(expected, actual, generated)...) + checks = append(checks, checkLifecycle(expected, actual, generated)...) + checks = append(checks, checkResourceSelectors(actual)...) + main := findContainer(pod, generated.Main.Name) + mainKnown := processPremise(generated.Main, main) && main != nil && main.Image == generated.Main.Image + if main == nil { + checks = append(checks, Check{Subject: "assembly.main", State: Conflict, + Reason: "declared main container is missing"}) + } else if !mainKnown { + checks = append(checks, Check{Subject: "assembly.main.execution", State: Unknown, + Reason: "main image, command, args, environment or working directory changed"}) + } else { + checks = append(checks, Check{Subject: "assembly.main.execution", State: Consistent, + Reason: "declared main execution premise is retained"}) + } + helperCheck, helperKnown := checkMaterializer(baseline.Spec, pod.Spec, len(files) > 0) + checks = append(checks, helperCheck...) + checks = append(checks, checkPreparedAccess(pod.Spec, generated, files, mainKnown, helperKnown)...) + checks = append(checks, checkVectorConsumer(baseline, pod, generated, files, mainKnown, helperKnown)...) + return checks +} + +func checkPodStructure(pod corev1.PodSpec, claims ...corev1.PersistentVolumeClaim) []Check { + checks := []Check{} + volumes := map[string]bool{} + for _, claim := range claims { + if claim.Name == "" || volumes[claim.Name] { + checks = append(checks, Check{Subject: "statefulset.volumeClaimTemplates", State: Conflict, + Reason: "empty or duplicate claim template name"}) + } + volumes[claim.Name] = true + } + for _, volume := range pod.Volumes { + if volume.Name == "" || volumes[volume.Name] { + checks = append(checks, Check{Subject: "pod.volumes", State: Conflict, + Reason: fmt.Sprintf("empty or duplicate volume name %q", volume.Name)}) + } + volumes[volume.Name] = true + } + containers := map[string]bool{} + for _, container := range append(slices.Clone(pod.InitContainers), pod.Containers...) { + if container.Name == "" || containers[container.Name] { + checks = append(checks, Check{Subject: "pod.containers", State: Conflict, + Reason: fmt.Sprintf("empty or duplicate container name %q", container.Name)}) + } + containers[container.Name] = true + mounts := map[string]bool{} + for _, mount := range container.VolumeMounts { + subject := fmt.Sprintf("pod.container[%s].mount[%s]", container.Name, mount.MountPath) + if mounts[mount.MountPath] { + checks = append(checks, Check{Subject: subject, State: Conflict, Reason: "duplicate mount path"}) + } + mounts[mount.MountPath] = true + if !volumes[mount.Name] { + checks = append(checks, Check{Subject: subject, State: Conflict, + Reason: fmt.Sprintf("volume %q is missing", mount.Name)}) + } + } + } + return checks +} + +func checkResourceSelectors(resources GroupResources) []Check { + checks := []Check{} + podLabels := labels.Set(resources.StatefulSet.Spec.Template.Labels) + selector, err := metav1.LabelSelectorAsSelector(resources.StatefulSet.Spec.Selector) + if err != nil || selector.Empty() || !selector.Matches(podLabels) { + checks = append(checks, Check{Subject: "statefulset.selector", State: Conflict, + Reason: "selector does not identify the final Pod template"}) + } else { + checks = append(checks, Check{Subject: "statefulset.selector", State: Consistent, + Reason: "selector matches the final Pod template labels"}) + } + if resources.StatefulSet.Spec.ServiceName != resources.HeadlessService.Name { + checks = append(checks, Check{Subject: "statefulset.serviceName", State: Conflict, + Reason: "governing headless Service name does not match"}) + } + for _, service := range []corev1.Service{resources.Service, resources.HeadlessService} { + subject := fmt.Sprintf("service[%s].selector", service.Name) + selector := labels.SelectorFromSet(service.Spec.Selector) + state, reason := Consistent, "Service selector matches the final Pod template labels" + if selector.Empty() || !selector.Matches(podLabels) { + state, reason = Conflict, "Service selector does not identify the final Pod template" + } + checks = append(checks, Check{Subject: subject, State: state, Reason: reason}) + checks = append(checks, checkServicePorts(service, resources.StatefulSet.Spec.Template.Spec)...) + } + return checks +} + +func portProtocol(protocol corev1.Protocol) corev1.Protocol { + if protocol == "" { + return corev1.ProtocolTCP + } + return protocol +} + +func checkServicePorts(service corev1.Service, pod corev1.PodSpec) []Check { + checks := []Check{} + for _, port := range service.Spec.Ports { + if port.TargetPort.Type != intstr.String { + continue + } + matches := 0 + for _, container := range pod.Containers { + for _, target := range container.Ports { + if target.Name == port.TargetPort.StrVal && portProtocol(target.Protocol) == portProtocol(port.Protocol) { + matches++ + } + } + } + state, reason := Consistent, "named target resolves to one final container port of the same protocol" + if matches != 1 || port.TargetPort.StrVal == "" { + state = Conflict + reason = fmt.Sprintf("named target %q resolves to %d matching ports", port.TargetPort.StrVal, matches) + } + checks = append(checks, Check{Subject: fmt.Sprintf("service[%s].port[%s]", service.Name, port.Name), State: state, + Reason: reason}) + } + return checks +} + +func findInitContainer(pod corev1.PodSpec, name string) *corev1.Container { + for i := range pod.InitContainers { + if pod.InitContainers[i].Name == name { + return &pod.InitContainers[i] + } + } + return nil +} + +func findMount(container *corev1.Container, mountPath string) *corev1.VolumeMount { + if container != nil { + for i := range container.VolumeMounts { + if container.VolumeMounts[i].MountPath == mountPath { + return &container.VolumeMounts[i] + } + } + } + return nil +} + +func findPodVolume(pod corev1.PodSpec, name string) *corev1.Volume { + for i := range pod.Volumes { + if pod.Volumes[i].Name == name { + return &pod.Volumes[i] + } + } + return nil +} + +// A more specific mount can replace a file without changing its directory's +// original mount. Its contents cannot be inferred from the generated files. +func shadowsFile(container *corev1.Container, directoryPath, relativePath string) bool { + if container == nil { + return false + } + target := path.Join(directoryPath, relativePath) + for _, mount := range container.VolumeMounts { + if strings.HasPrefix(mount.MountPath, directoryPath+"/") && + (target == mount.MountPath || strings.HasPrefix(target, mount.MountPath+"/")) { + return true + } + } + return false +} + +func sameContainerExecution(expected, actual *corev1.Container) bool { + if expected == nil || actual == nil || expected.Image != actual.Image { + return false + } + return processPremise(Process{ + Image: expected.Image, Command: expected.Command, Args: expected.Args, Env: expected.Env, + }, actual) +} + +// A mount includes its backing source. Keeping a mount name while pointing it +// at another ConfigMap does not establish the original consumer's input. +func sameMount(expectedPod, actualPod corev1.PodSpec, expected, actual *corev1.VolumeMount) bool { + return expected != nil && actual != nil && reflect.DeepEqual(expected, actual) && + reflect.DeepEqual(findPodVolume(expectedPod, expected.Name), findPodVolume(actualPod, actual.Name)) +} + +func checkMaterializer(expected, actual corev1.PodSpec, hasFiles bool) ([]Check, bool) { + before := findInitContainer(expected, materializerContainerName) + after := findInitContainer(actual, materializerContainerName) + if before == nil && !hasFiles { + for _, container := range actual.InitContainers { + for _, mount := range container.VolumeMounts { + if !mount.ReadOnly { + return []Check{{Subject: "assembly.materialization", State: Unknown, + Reason: "an unmodeled writable init process may create files absent from the plan"}}, + false + } + } + } + return nil, true + } + check := Check{Subject: "assembly.materialization", State: Unknown, + Reason: "the modeled file preparation path is missing or changed"} + if !sameContainerExecution(before, after) { + return []Check{check}, false + } + for i := range before.VolumeMounts { + mount := &before.VolumeMounts[i] + if !sameMount(expected, actual, mount, findMount(after, mount.MountPath)) { + check.Reason = fmt.Sprintf("materializer mount %q or its backing volume changed", mount.MountPath) + return []Check{check}, false + } + } + if len(before.VolumeMounts) != len(after.VolumeMounts) { + check.Reason = "materializer acquired unmodeled mounts" + return []Check{check}, false + } + if writer := otherFileWriter(before, actual); writer != "" { + check.Reason = fmt.Sprintf("container %q has writable access to materialized files", writer) + return []Check{check}, false + } + check.State, check.Reason = Consistent, "materializer execution and required mounts retain their declared structure" + return []Check{check}, true +} + +func otherFileWriter(materializer *corev1.Container, pod corev1.PodSpec) string { + outputs := map[string]bool{} + for _, mount := range materializer.VolumeMounts { + if mount.Name != materializationPlanVolume { + outputs[mount.Name] = true + } + } + for _, container := range append(slices.Clone(pod.InitContainers), pod.Containers...) { + if container.Name == materializer.Name { + continue + } + for _, mount := range container.VolumeMounts { + if outputs[mount.Name] && !mount.ReadOnly { + return container.Name + } + } + } + return "" +} + +func checkPreparedAccess( + pod corev1.PodSpec, generated RuntimeDescription, files []File, mainKnown, helperKnown bool, +) []Check { + checks := []Check{} + main, helper := findContainer(corev1.PodTemplateSpec{Spec: pod}, generated.Main.Name), + findInitContainer(pod, materializerContainerName) + for _, access := range generated.Main.Access { + if !slices.ContainsFunc(files, func(file File) bool { return file.Directory == access.Directory }) { + continue + } + check := Check{Subject: "assembly.files[" + access.Directory + "]", State: Unknown, + Reason: "main execution or file preparation is not known"} + if mainKnown && helperKnown { + shadowed := slices.ContainsFunc(files, func(file File) bool { + return file.Directory == access.Directory && shadowsFile(main, access.MountPath, file.Path) + }) + if shadowed { + check.Reason = "a more specific mount shadows a generated file" + } else { + consumer := findMount(main, access.MountPath) + writer := findMount(helper, path.Join(materializationRoot, access.Directory)) + check.State, check.Reason = volumeSharing(consumer, writer) + } + } + checks = append(checks, check) + } + return checks +} + +func volumeSharing(consumer, producer *corev1.VolumeMount) (CheckState, string) { + if consumer == nil || producer == nil { + return Conflict, "a required producer or consumer directory mount is missing" + } + if consumer.SubPathExpr != "" || producer.SubPathExpr != "" { + return Unknown, "dynamic subPathExpr prevents proving a shared directory" + } + if consumer.Name != producer.Name || consumer.SubPath != producer.SubPath { + return Conflict, "producer and consumer refer to different volumes or subpaths" + } + if producer.ReadOnly { + return Conflict, "the declared producer directory is mounted read-only" + } + return Consistent, "producer and consumer share one mounted volume and subpath" +} + +func checkVectorConsumer( + expected, actual corev1.PodTemplateSpec, generated RuntimeDescription, files []File, mainKnown, helperKnown bool, +) []Check { + before, after := findContainer(expected, vectorContainerName), findContainer(actual, vectorContainerName) + if before == nil || generated.Main.Name == vectorContainerName { + return nil // No platform collector was declared by this assembly. + } + check := Check{Subject: "vector.config", State: Unknown, Reason: "collector execution or configuration mount changed"} + if !helperKnown { + check.Reason = "file preparation is not known; planned content cannot establish the collector's actual input" + return []Check{check} + } + if !sameContainerExecution(before, after) || !sameMount(expected.Spec, actual.Spec, + findMount(before, vectorConfigPath), findMount(after, vectorConfigPath)) || + shadowsFile(after, vectorConfigPath, vectorConfigFile) { + return []Check{check} + } + file := findFile(files, generated.ConfigDirectory, vectorConfigFile) + if file == nil { + check.State, check.Reason = Conflict, "the retained collector command requires its removed configuration file" + return []Check{check} + } + original := findFile(generated.Files, generated.ConfigDirectory, vectorConfigFile) + if original == nil || !reflect.DeepEqual(original.Content, file.Content) { + check.Reason = "collector configuration content changed; its sources have not been parsed" + return []Check{check} + } + check.State, check.Reason = Consistent, "collector execution, config mount and generated config content are retained" + checks := []Check{check} + for _, output := range generated.LogOutputs { + checks = append(checks, checkLogSharing(before, after, actual, generated, output, mainKnown)) + } + return checks +} + +func checkLogSharing( + before, collector *corev1.Container, pod corev1.PodTemplateSpec, + generated RuntimeDescription, output LogOutput, mainKnown bool, +) Check { + check := Check{Subject: "vector.source[" + output.Directory + "/" + output.RelativePath + "]", State: Unknown, + Reason: "producer execution or its original directory mapping is not known"} + + var consumer *corev1.VolumeMount + for _, mount := range before.VolumeMounts { + if mount.Name == output.Directory { + if shadowsFile(collector, mount.MountPath, output.RelativePath) { + check.Reason = "a more specific collector mount shadows the declared log source" + return check + } + consumer = findMount(collector, mount.MountPath) + if consumer == nil { + check.State, check.Reason = Conflict, "retained collector config reads a directory mount that was removed" + return check + } + } + } + if !mainKnown { + return check + } + producerPaths := 0 + for _, access := range generated.Main.Access { + if access.Directory == output.Directory { + producerPaths++ + } + } + if producerPaths != 1 { + check.Reason = "multiple producer access paths prevent locating the native log writer" + return check + } + for _, access := range generated.Main.Access { + if access.Directory == output.Directory { + producerContainer := findContainer(pod, output.Container) + if shadowsFile(producerContainer, access.MountPath, output.RelativePath) { + check.Reason = "a more specific producer mount shadows the declared log source" + return check + } + producer := findMount(producerContainer, access.MountPath) + check.State, check.Reason = volumeSharing(consumer, producer) + return check + } + } + return check +} diff --git a/internal/framework/pipeline/resource_checks_test.go b/internal/framework/pipeline/resource_checks_test.go new file mode 100644 index 00000000..2cf81c6c --- /dev/null +++ b/internal/framework/pipeline/resource_checks_test.go @@ -0,0 +1,235 @@ +package pipeline + +import ( + "slices" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func assemblyCheckFixture(t *testing.T) (GroupResources, RuntimeDescription) { + t.Helper() + runtime := assemblyRuntimeFixture() + runtime.ConfigDirectory = "config" + runtime.Main.Command, runtime.Main.Args = []string{"launcher"}, []string{"run"} + file := runtime.Files[0].Content.(KeyValues) + file.Codec = PropertiesCodec{} + runtime.Files[0].Content = file + options := AssemblyOptions{ + MaterializerImage: "example.invalid/materializer:test", VectorImage: "example.invalid/vector:test", + } + generated, vector, err := composeVector(runtime, true, options) + if err != nil { + t.Fatal(err) + } + common := CommonConfig{Resources: Resources{ + CPU: CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("1")}, + Memory: Memory{Limit: resource.MustParse("1Gi")}, + }} + resources, err := assembleGroup(GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "sample", Namespace: "test"}, + Role: "worker", Name: "default", Replicas: 1}, common, ResolvedImage{PullPolicy: corev1.PullIfNotPresent}, + generated, generated.Main, vector, generated.Files, options) + if err != nil { + t.Fatal(err) + } + return resources, generated +} + +func requireAssemblyCheck(t *testing.T, checks []Check, subject string, state CheckState) { + t.Helper() + for _, check := range checks { + if check.Subject == subject && check.State == state { + return + } + } + t.Fatalf("expected %s=%s, got %#v", subject, state, checks) +} + +func TestAssemblyChecksKeepIndependentConsumerAndStructureFailures(t *testing.T) { + expected, generated := assemblyCheckFixture(t) + for _, check := range CheckAssembly(expected, expected, generated, generated.Files) { + if check.State != Consistent { + t.Fatalf("fixture should retain all modeled relations: %#v", check) + } + } + actual := cloneGroupResources(expected) + pod := &actual.StatefulSet.Spec.Template + pod.Spec.Containers[0].Command = []string{"custom-launcher"} + pod.Spec.Containers[0].Ports[0].Name = "renamed" + pod.Labels = map[string]string{"unrelated": "label"} + files := slices.DeleteFunc(cloneDeclaredFiles(generated.Files), func(file File) bool { + return file.Path == vectorConfigFile + }) + checks := CheckAssembly(expected, actual, generated, files) + requireAssemblyCheck(t, checks, "assembly.main.execution", Unknown) + requireAssemblyCheck(t, checks, "vector.config", Conflict) + requireAssemblyCheck(t, checks, "statefulset.selector", Conflict) + requireAssemblyCheck(t, checks, "service["+expected.Service.Name+"].selector", Conflict) + requireAssemblyCheck(t, checks, "service["+expected.Service.Name+"].port[http]", Conflict) + // A missing main is a declaration conflict, even with other custom containers. + pod.Spec.Containers = slices.DeleteFunc(pod.Spec.Containers, func(container corev1.Container) bool { + return container.Name == generated.Main.Name + }) + requireAssemblyCheck(t, CheckAssembly(expected, actual, generated, files), "assembly.main", Conflict) +} + +func TestAssemblyChecksDoNotClaimChangedPreparationIsKnown(t *testing.T) { + cases := []struct { + name string + change func(*corev1.PodSpec) + }{ + {"removed", func(pod *corev1.PodSpec) { pod.InitContainers = nil }}, + {"command", func(pod *corev1.PodSpec) { pod.InitContainers[0].Command = []string{"custom"} }}, + {"args", func(pod *corev1.PodSpec) { pod.InitContainers[0].Args = []string{} }}, + {"environment", func(pod *corev1.PodSpec) { + pod.InitContainers[0].Env = []corev1.EnvVar{{Name: "POD_NAME", Value: "x"}} + }}, + {"image", func(pod *corev1.PodSpec) { pod.InitContainers[0].Image = "example.invalid/different:test" }}, + {"plan-mount", func(pod *corev1.PodSpec) { pod.InitContainers[0].VolumeMounts[0].MountPath = "/alternate-plan" }}, + {"plan-source", func(pod *corev1.PodSpec) { + findPodVolume(*pod, materializationPlanVolume).ConfigMap.Name = "other-config-map" + }}, + {"extra-mount", func(pod *corev1.PodSpec) { + pod.InitContainers[0].VolumeMounts = append(pod.InitContainers[0].VolumeMounts, + corev1.VolumeMount{Name: "config", MountPath: "/other"}) + }}, + {"second-init-writer", func(pod *corev1.PodSpec) { + pod.InitContainers = append(pod.InitContainers, corev1.Container{ + Name: "rewrite-config", Image: "example.invalid/init", + VolumeMounts: []corev1.VolumeMount{{Name: "config", MountPath: "/write"}}}) + }}, + {"second-running-writer", func(pod *corev1.PodSpec) { + pod.Containers = append(pod.Containers, corev1.Container{Name: "rewrite-config", Image: "example.invalid/helper", + VolumeMounts: []corev1.VolumeMount{{Name: "config", MountPath: "/write"}}}) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + tc.change(&actual.StatefulSet.Spec.Template.Spec) + checks := CheckAssembly(expected, actual, generated, generated.Files) + requireAssemblyCheck(t, checks, "assembly.materialization", Unknown) + requireAssemblyCheck(t, checks, "assembly.files[config]", Unknown) + files := slices.DeleteFunc(cloneDeclaredFiles(generated.Files), func(file File) bool { + return file.Path == vectorConfigFile + }) + requireAssemblyCheck(t, CheckAssembly(expected, actual, generated, files), "vector.config", Unknown) + }) + } + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + actual.StatefulSet.Spec.Template.Spec.InitContainers = append(actual.StatefulSet.Spec.Template.Spec.InitContainers, + corev1.Container{Name: "read-config", Image: "example.invalid/init", + VolumeMounts: []corev1.VolumeMount{{Name: "config", MountPath: "/read", ReadOnly: true}}}) + checks := CheckAssembly(expected, actual, generated, generated.Files) + requireAssemblyCheck(t, checks, "assembly.materialization", Consistent) +} + +func TestAssemblyChecksFollowFinalMountsWithoutRepair(t *testing.T) { + t.Run("collector-config-file-shadowed", func(t *testing.T) { + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + collector := findContainer(actual.StatefulSet.Spec.Template, vectorContainerName) + collector.VolumeMounts = append(collector.VolumeMounts, corev1.VolumeMount{ + Name: "logs", MountPath: "/etc/vector/vector.yaml", SubPath: "custom.yaml", ReadOnly: true, + }) + files := slices.DeleteFunc(cloneDeclaredFiles(generated.Files), func(file File) bool { + return file.Path == vectorConfigFile + }) + requireAssemblyCheck(t, CheckAssembly(expected, actual, generated, files), "vector.config", Unknown) + }) + t.Run("collector-config-content-unknown", func(t *testing.T) { + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + actual.StatefulSet.Spec.Template.Spec.Containers[0].Command = []string{"custom"} + files := cloneDeclaredFiles(generated.Files) + findFile(files, generated.ConfigDirectory, vectorConfigFile).Content = Text("arbitrary vector config") + checks := CheckAssembly(expected, actual, generated, files) + requireAssemblyCheck(t, checks, "vector.config", Unknown) + for _, check := range checks { + if check.State == Conflict { + t.Fatalf("unknown content/execution must not become invalid: %#v", check) + } + } + }) + cases := []struct { + name, subject string + state CheckState + change func(*corev1.PodTemplateSpec) + }{ + {"collector-different-volume", "vector.source[logs/server.json]", Conflict, func(pod *corev1.PodTemplateSpec) { + findMount(findContainer(*pod, vectorContainerName), "/logs/logs").Name = "spare" + }}, + {"producer-unknown-different-volume", "vector.source[logs/server.json]", Unknown, func(pod *corev1.PodTemplateSpec) { + pod.Spec.Containers[0].Command = []string{"custom"} + findMount(findContainer(*pod, vectorContainerName), "/logs/logs").Name = "spare" + }}, + {"collector-moved-path-independent-of-main", "vector.source[logs/server.json]", Conflict, + func(pod *corev1.PodTemplateSpec) { + pod.Spec.Containers[0].Command = []string{"custom"} + findMount(findContainer(*pod, vectorContainerName), "/logs/logs").MountPath = "/elsewhere" + }}, + {"shared-volume-rebound-coherently", "vector.source[logs/server.json]", Consistent, + func(pod *corev1.PodTemplateSpec) { + findMount(findContainer(*pod, vectorContainerName), "/logs/logs").Name = "spare" + findMount(&pod.Spec.Containers[0], "/var/log/trino").Name = "spare" + }}, + {"producer-read-only", "vector.source[logs/server.json]", Conflict, func(pod *corev1.PodTemplateSpec) { + findMount(&pod.Spec.Containers[0], "/var/log/trino").ReadOnly = true + }}, + {"different-subpath", "vector.source[logs/server.json]", Conflict, func(pod *corev1.PodTemplateSpec) { + findMount(&pod.Spec.Containers[0], "/var/log/trino").SubPath = "different" + }}, + {"dynamic-subpath", "vector.source[logs/server.json]", Unknown, func(pod *corev1.PodTemplateSpec) { + findMount(&pod.Spec.Containers[0], "/var/log/trino").SubPathExpr = "$(SOME_PATH)" + }}, + {"config-no-longer-prepared", "assembly.files[config]", Conflict, func(pod *corev1.PodTemplateSpec) { + findMount(&pod.Spec.Containers[0], "/etc/trino").Name = "spare" + }}, + {"config-file-shadowed", "assembly.files[config]", Unknown, func(pod *corev1.PodTemplateSpec) { + pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{Name: "spare", MountPath: "/etc/trino/node.properties", SubPath: "node.properties"}) + }}, + {"collector-file-shadowed", "vector.source[logs/server.json]", Unknown, func(pod *corev1.PodTemplateSpec) { + collector := findContainer(*pod, vectorContainerName) + collector.VolumeMounts = append(collector.VolumeMounts, corev1.VolumeMount{ + Name: "spare", MountPath: "/logs/logs/server.json", SubPath: "other.log", ReadOnly: true, + }) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + actual.StatefulSet.Spec.Template.Spec.Volumes = append(actual.StatefulSet.Spec.Template.Spec.Volumes, + corev1.Volume{Name: "spare", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}) + tc.change(&actual.StatefulSet.Spec.Template) + checks := CheckAssembly(expected, actual, generated, generated.Files) + requireAssemblyCheck(t, checks, tc.subject, tc.state) + if findPodVolume(actual.StatefulSet.Spec.Template.Spec, "spare") == nil { + t.Fatal("checks must not repair the final Pod") + } + }) + } + // Mounting one volume twice at different paths remains legal; only a + // repeated path, missing backing volume or duplicate names are conflicts. + pod := corev1.PodSpec{Volumes: []corev1.Volume{{Name: "data"}}, Containers: []corev1.Container{{Name: "app", + VolumeMounts: []corev1.VolumeMount{{Name: "data", MountPath: "/one"}, {Name: "data", MountPath: "/two"}}, + }}} + if checks := checkPodStructure(pod); len(checks) != 0 { + t.Fatalf("one volume may be mounted at multiple paths: %#v", checks) + } + pod.Containers[0].VolumeMounts[1].MountPath = "/one" + pod.Containers[0].VolumeMounts[1].Name = "absent" + pod.InitContainers = []corev1.Container{{Name: "app"}} + pod.Volumes = append(pod.Volumes, pod.Volumes[0]) + checks := checkPodStructure(pod) + for _, fragment := range []string{"duplicate volume", "duplicate container", "duplicate mount", "is missing"} { + if !slices.ContainsFunc(checks, func(check Check) bool { return strings.Contains(check.Reason, fragment) }) { + t.Fatalf("structure failure %q not found: %#v", fragment, checks) + } + } +} diff --git a/internal/framework/pipeline/retained_data.go b/internal/framework/pipeline/retained_data.go new file mode 100644 index 00000000..c5b66940 --- /dev/null +++ b/internal/framework/pipeline/retained_data.go @@ -0,0 +1,92 @@ +package pipeline + +import ( + "fmt" + "reflect" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func validateRetainedData(r RuntimeDescription) error { + slot := "" + for _, directory := range r.Directories { + if !directory.Data { + continue + } + if slot != "" { + return fmt.Errorf("one data directory is supported") + } + slot = directory.Name + if r.ConfigDirectory == slot { + return fmt.Errorf("data directory cannot be the configuration directory") + } + for _, file := range r.Files { + if file.Directory == slot { + return fmt.Errorf("generated files cannot write into the data directory") + } + } + for _, log := range r.LogOutputs { + if log.Directory == slot { + return fmt.Errorf("log outputs require a separate ephemeral directory") + } + } + writable := false + for _, access := range r.Main.Access { + writable = writable || (access.Directory == slot && !access.ReadOnly) + } + if !writable { + return fmt.Errorf("data directory requires writable access by the main process") + } + } + return nil +} + +func retainedClaimTemplate(slot RetainedDataSlot) corev1.PersistentVolumeClaim { + mode, class := corev1.PersistentVolumeFilesystem, slot.StorageClassName + return corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: slot.Name}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: &mode, StorageClassName: &class, + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: slot.Capacity.DeepCopy(), + }}, + }} +} + +// These checks describe the final mount relation. They never rewrite overrides +// or infer that the program actually uses the mounted data. +func checkRetainedData(expected, actual GroupResources, runtime RuntimeDescription) []Check { + slot := expected.RetainedData + if slot == nil { + return nil + } + check := Check{Subject: "assembly.retainedData[" + slot.Name + "]", State: Consistent, + Reason: "declared data slot and writable mounts are retained"} + if !reflect.DeepEqual(expected.StatefulSet.Spec.VolumeClaimTemplates, actual.StatefulSet.Spec.VolumeClaimTemplates) || + !reflect.DeepEqual(expected.StatefulSet.Spec.PersistentVolumeClaimRetentionPolicy, + actual.StatefulSet.Spec.PersistentVolumeClaimRetentionPolicy) || + !reflect.DeepEqual(slot, actual.RetainedData) { + check.State, check.Reason = Conflict, "retained declaration or claim policy changed during assembly" + return []Check{check} + } + main := findContainer(actual.StatefulSet.Spec.Template, runtime.Main.Name) + for _, access := range runtime.Main.Access { + if access.Directory != slot.Name { + continue + } + mount := findMount(main, access.MountPath) + if mount == nil || mount.Name != slot.Name || mount.ReadOnly != access.ReadOnly || + mount.SubPath != "" || mount.SubPathExpr != "" { + check.State, check.Reason = Conflict, "podOverrides displaced or changed a declared retained data mount" + break + } + for _, other := range main.VolumeMounts { + if strings.HasPrefix(other.MountPath, strings.TrimSuffix(access.MountPath, "/")+"/") { + check.State, check.Reason = Conflict, "podOverrides added a mount inside the retained data directory" + } + } + } + return []Check{check} +} diff --git a/internal/framework/pipeline/retained_data_test.go b/internal/framework/pipeline/retained_data_test.go new file mode 100644 index 00000000..c0fd40fe --- /dev/null +++ b/internal/framework/pipeline/retained_data_test.go @@ -0,0 +1,137 @@ +package pipeline + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func retainedRuntime() RuntimeDescription { + r := runtimeFixture() + r.Directories = append(r.Directories, Directory{Name: "data", Data: true}) + r.Main.Access = append(r.Main.Access, DirectoryAccess{Directory: "data", MountPath: "/data"}) + return r +} + +func TestRetainedRuntimeRejectsOverlappingResponsibilities(t *testing.T) { + tests := []struct { + name, want string + change func(*RuntimeDescription) + }{ + {"two-slots", "one data", func(r *RuntimeDescription) { + r.Directories = append(r.Directories, Directory{Name: "extra", Data: true}) + }}, + {"config-root", "configuration directory", func(r *RuntimeDescription) { r.ConfigDirectory = "data" }}, + {"generated-data", "generated files", func(r *RuntimeDescription) { + r.Files = append(r.Files, File{Directory: "data", Path: "marker", Content: Text("overwrite")}) + }}, + {"logs", "separate ephemeral", func(r *RuntimeDescription) { r.LogOutputs[0].Directory = "data" }}, + {"read-only", "writable access", func(r *RuntimeDescription) { r.Main.Access[2].ReadOnly = true }}, + } + if err := ValidateRuntime(retainedRuntime()); err != nil { + t.Fatal(err) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := retainedRuntime() + test.change(&r) + if err := ValidateRuntime(r); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("want %q, got %v", test.want, err) + } + }) + } +} + +func retainedAssembly(t *testing.T) (RuntimeDescription, GroupResources) { + t.Helper() + r := retainedRuntime() + r.Files = nil + r.LogOutputs = nil + identity := GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "store", Namespace: "test"}, + Role: "workers", Name: "default", Replicas: 1} + common := CommonConfig{Resources: Resources{Storage: framework.Storage{Type: framework.StoragePersistent, + StorageClassName: "retained-test", Capacity: resource.MustParse("1Gi")}}} + assembled, err := assembleGroup(identity, common, ResolvedImage{PullPolicy: corev1.PullIfNotPresent}, + r, r.Main, nil, nil, AssemblyOptions{}) + if err != nil { + t.Fatal(err) + } + return r, assembled +} + +func TestRetainedAssemblyCarriesExplicitClaimAndIsolatesMutableData(t *testing.T) { + r, assembled := retainedAssembly(t) + sts := &assembled.StatefulSet + if len(sts.Spec.VolumeClaimTemplates) != 1 || assembled.RetainedData == nil { + t.Fatalf("explicit declaration/claim missing: %+v", assembled) + } + claim := sts.Spec.VolumeClaimTemplates[0] + if claim.Name != "data" || *claim.Spec.StorageClassName != "retained-test" || + claim.Spec.Resources.Requests.Storage().Cmp(resource.MustParse("1Gi")) != 0 || + *claim.Spec.VolumeMode != corev1.PersistentVolumeFilesystem || len(claim.Spec.AccessModes) != 1 || + claim.Spec.AccessModes[0] != corev1.ReadWriteOnce || len(claim.OwnerReferences) != 0 { + t.Fatalf("incorrect storage contract: %+v", claim) + } + policy := sts.Spec.PersistentVolumeClaimRetentionPolicy + if policy == nil || policy.WhenDeleted != appsv1.RetainPersistentVolumeClaimRetentionPolicyType || + policy.WhenScaled != appsv1.RetainPersistentVolumeClaimRetentionPolicyType { + t.Fatal("both retain directions must be explicit") + } + if findPodVolume(sts.Spec.Template.Spec, "data") != nil { + t.Fatal("claim must not be shadowed by an emptyDir or direct PVC volume") + } + for _, check := range CheckAssembly(assembled, assembled, r, nil) { + if check.State == Conflict { + t.Fatalf("valid retained volume incorrectly rejected: %+v", check) + } + } + clone := CloneRuntime(r) + clone.Directories[2].Data = false + copy := cloneGroupResources(assembled) + copy.RetainedData.Capacity.Add(resource.MustParse("2Gi")) + if !r.Directories[2].Data || assembled.RetainedData.Capacity.String() != "1Gi" { + t.Fatal("clones leaked mutable retained storage declarations") + } +} + +func TestPodOverridesCannotDisplaceRetainedMounts(t *testing.T) { + patches := []string{ + `{"spec":{"volumes":[{"name":"data","emptyDir":{}}]}}`, + `{"spec":{"containers":[{"name":"trino","volumeMounts":[{"mountPath":"/data","$patch":"delete"}]}]}}`, + `{"spec":{"containers":[{"name":"trino","volumeMounts":[{"mountPath":"/data","name":"logs"}]}]}}`, + `{"spec":{"containers":[{"name":"trino","volumeMounts":[{"mountPath":"/data","readOnly":true}]}]}}`, + `{"spec":{"containers":[{"name":"trino","volumeMounts":[{"mountPath":"/data","subPath":"other"}]}]}}`, + `{"spec":{"containers":[{"name":"trino","volumeMounts":[{"mountPath":"/data/marker","name":"logs"}]}]}}`, + } + for _, patch := range patches { + r, expected := retainedAssembly(t) + actual := cloneGroupResources(expected) + if err := patchPod(&actual.StatefulSet.Spec.Template, json.RawMessage(patch)); err != nil { + t.Fatal(err) + } + found := false + for _, check := range CheckAssembly(expected, actual, r, nil) { + found = found || check.State == Conflict + } + if !found { + t.Fatalf("broken durable mount accepted: %s", patch) + } + } + r, expected := retainedAssembly(t) + actual := cloneGroupResources(expected) + if err := patchPod(&actual.StatefulSet.Spec.Template, json.RawMessage( + `{"spec":{"containers":[{"name":"trino","env":[{"name":"USER_ENV","value":"final"}]}]}}`)); err != nil { + t.Fatal(err) + } + for _, check := range CheckAssembly(expected, actual, r, nil) { + if check.State == Conflict { + t.Fatalf("unrelated final override must remain usable: %+v", check) + } + } +} diff --git a/internal/framework/pipeline/role_config.go b/internal/framework/pipeline/role_config.go new file mode 100644 index 00000000..57ffdffc --- /dev/null +++ b/internal/framework/pipeline/role_config.go @@ -0,0 +1,116 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "reflect" + + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + kubernetesjson "sigs.k8s.io/json" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +// BuildRoleResources consumes all desired replicas, including groups that cannot +// currently build or resolve facts. An integer minAvailable supports a role whose +// Pods are managed by several StatefulSets; no controller-scale inference is used. +func BuildRoleResources[C, S, F any]( + definition framework.ProductDefinition[C, S, F], source SourceSnapshot[F], +) ([]BuiltRole, error) { + identities, err := SourceRoleIdentities(source) + if err != nil { + return nil, err + } + layers := make(map[string]json.RawMessage, len(source.Roles)) + for _, role := range source.Roles { + layers[role.Name] = role.Config + } + totals := make(map[string]int64, len(identities)) + for _, group := range source.Groups { + if totals[group.Role] > math.MaxInt64-int64(group.Replicas) { + return nil, fmt.Errorf("desired replica count for role %q exceeds the supported range", group.Role) + } + totals[group.Role] += int64(group.Replicas) + } + built := make([]BuiltRole, 0, len(identities)) + for _, identity := range identities { + role := BuiltRole{Role: identity} + declaration, exists := definition.Roles[identity.Name] + if !exists { + role.Error = "role is not declared by the product" + } else { + config, err := resolveRoleConfig(declaration.RoleConfig, layers[identity.Name]) + if err != nil { + role.Error = err.Error() + } else { + role.Config = &config + role.PodDisruptionBudget, err = buildRolePDB(identity, config, totals[identity.Name]) + if err != nil { + role.Error = err.Error() + } + } + } + built = append(built, role) + } + return built, nil +} + +func resolveRoleConfig(defaults RoleConfig, layer json.RawMessage) (RoleConfig, error) { + resolved := defaults + if len(layer) != 0 { + if err := validateJSON(layer, reflect.TypeFor[RoleConfig](), "role.roleConfig"); err != nil { + return RoleConfig{}, err + } + strict, decodeErr := kubernetesjson.UnmarshalStrict(layer, &RoleConfig{}, kubernetesjson.DisallowDuplicateFields) + if err := errors.Join(decodeErr, errors.Join(strict...)); err != nil { + return RoleConfig{}, fmt.Errorf("role.roleConfig: %w", err) + } + data, err := json.Marshal(defaults) + if err != nil { + return RoleConfig{}, err + } + var base, patch map[string]json.RawMessage + if err := json.Unmarshal(data, &base); err != nil { + return RoleConfig{}, err + } + if err := json.Unmarshal(layer, &patch); err != nil { + return RoleConfig{}, err + } + merged, err := json.Marshal(mergeObjects(base, patch)) + if err != nil { + return RoleConfig{}, err + } + if err := json.Unmarshal(merged, &resolved); err != nil { + return RoleConfig{}, err + } + } + if resolved.PodDisruptionBudget.MaxUnavailable < 0 { + return RoleConfig{}, fmt.Errorf("role.roleConfig.podDisruptionBudget.maxUnavailable must be nonnegative") + } + return resolved, nil +} + +func buildRolePDB(role RoleIdentity, config RoleConfig, replicas int64) (*policyv1.PodDisruptionBudget, error) { + if !config.PodDisruptionBudget.Enabled { + return nil, nil + } + minimum := max(int64(0), replicas-int64(config.PodDisruptionBudget.MaxUnavailable)) + if minimum > math.MaxInt32 { + return nil, fmt.Errorf("role %q minimum available replica count exceeds int32", role.Name) + } + selector := map[string]string{ + labelInstance: role.ClusterIdentity.Name, + labelComponent: role.Name, + } + minAvailable := intstr.FromInt32(int32(minimum)) + return &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: role.PodDisruptionBudgetName(), Namespace: role.Namespace, + Labels: resourceLabels(role.ClusterIdentity, selector)}, + Spec: policyv1.PodDisruptionBudgetSpec{MinAvailable: &minAvailable, + Selector: &metav1.LabelSelector{MatchLabels: CloneInput(selector)}}, + }, nil +} diff --git a/internal/framework/pipeline/role_config_test.go b/internal/framework/pipeline/role_config_test.go new file mode 100644 index 00000000..2df52756 --- /dev/null +++ b/internal/framework/pipeline/role_config_test.go @@ -0,0 +1,327 @@ +package pipeline + +import ( + "encoding/json" + "math" + "reflect" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func roleConfigDefinition() framework.ProductDefinition[struct{}, struct{}, struct{}] { + return framework.ProductDefinition[struct{}, struct{}, struct{}]{Name: "role-fixture", + Roles: map[string]framework.RoleDefinition[struct{}]{ + "coordinators": {RoleConfig: RoleConfig{PodDisruptionBudget: PodDisruptionBudgetConfig{ + Enabled: true, MaxUnavailable: 0}}}, + "workers": {RoleConfig: RoleConfig{PodDisruptionBudget: PodDisruptionBudgetConfig{ + Enabled: true, MaxUnavailable: 1}}}, + }, + } +} + +func roleConfigSource() SourceSnapshot[struct{}] { + return SourceSnapshot[struct{}]{ + Cluster: ClusterIdentity{Name: "roles", Namespace: "fixture", Labels: map[string]string{ + "team": "data", "app.kubernetes.io/instance": "forged", "app.kubernetes.io/component": "forged", + "role-group": "not-a-selector", + }}, + Roles: []RoleSource{{Name: "workers"}, {Name: "coordinators"}}, + Groups: []GroupSource{ + {Role: "workers", Name: "waiting", Replicas: 2}, + {Role: "workers", Name: "invalid", Replicas: 3, Config: json.RawMessage(`{"port":"invalid"}`)}, + {Role: "coordinators", Name: "default", Replicas: 1}, + }, + } +} + +func roleConfigBuilt(t *testing.T, roles []BuiltRole, name string) BuiltRole { + t.Helper() + for _, role := range roles { + if role.Role.Name == name { + return role + } + } + t.Fatalf("missing role %q", name) + return BuiltRole{} +} + +func TestRoleConfigBuildUsesAllDeclaredReplicasWithoutProductCallbacks(t *testing.T) { + definition, source := roleConfigDefinition(), roleConfigSource() + definition.ValidateInput = func(framework.EffectiveInput[struct{}, struct{}, struct{}]) error { + t.Fatal("management resources called product input validation") + return nil + } + definition.GenerateGroup = func(framework.EffectiveInput[struct{}, struct{}, struct{}]) (RuntimeDescription, error) { + t.Fatal("management resources called workload generation") + return RuntimeDescription{}, nil + } + definition.GenerateCluster = func(framework.ClusterOutputInput[struct{}, struct{}]) (ClusterOutput, error) { + t.Fatal("management resources called cluster generation") + return ClusterOutput{}, nil + } + definition.ValidateFinal = func(FinalView) []Check { + t.Fatal("management resources called workload final validation") + return nil + } + roles, err := BuildRoleResources(definition, source) + if err != nil || len(roles) != 2 || roles[0].Role.Name != "coordinators" || roles[1].Role.Name != "workers" { + t.Fatalf("role inventory was lost or unordered: %+v, %v", roles, err) + } + for name, wantMinimum := range map[string]int32{"coordinators": 1, "workers": 4} { + role := roleConfigBuilt(t, roles, name) + if role.Error != "" || role.Config == nil || role.PodDisruptionBudget == nil { + t.Fatalf("group input failure blocked role management: %+v", role) + } + pdb := role.PodDisruptionBudget + if pdb.Spec.MinAvailable == nil || pdb.Spec.MinAvailable.Type != intstr.Int || + pdb.Spec.MinAvailable.IntVal != wantMinimum || pdb.Spec.MaxUnavailable != nil { + t.Fatalf("PDB inferred controller scale instead of all desired replicas: %+v", pdb.Spec) + } + wantSelector := map[string]string{"app.kubernetes.io/instance": "roles", "app.kubernetes.io/component": name} + if pdb.Spec.Selector == nil || !reflect.DeepEqual(pdb.Spec.Selector.MatchLabels, wantSelector) || + len(pdb.Spec.Selector.MatchExpressions) != 0 { + t.Fatalf("role PDB must select all groups of exactly this role: %+v", pdb.Spec.Selector) + } + if pdb.Name != "roles-"+name+"-pdb" || pdb.Namespace != source.Cluster.Namespace || + pdb.Labels["team"] != "data" || pdb.Labels["app.kubernetes.io/instance"] != "roles" || + pdb.Labels["app.kubernetes.io/component"] != name { + t.Fatalf("wrong PDB identity or propagated labels: %+v", pdb.ObjectMeta) + } + } +} + +func TestRoleConfigPresenceDefaultsAndExplicitZero(t *testing.T) { + for _, item := range []struct { + name, layer string + enabled bool + unavailable int32 + }{ + {"omitted", "", true, 1}, + {"empty object", `{}`, true, 1}, + {"empty pdb", `{"podDisruptionBudget":{}}`, true, 1}, + {"disable only", `{"podDisruptionBudget":{"enabled":false}}`, false, 1}, + {"explicit zero", `{"podDisruptionBudget":{"maxUnavailable":0}}`, true, 0}, + {"both fields", `{"podDisruptionBudget":{"enabled":false,"maxUnavailable":0}}`, false, 0}, + {"above desired", `{"podDisruptionBudget":{"maxUnavailable":9}}`, true, 9}, + } { + t.Run(item.name, func(t *testing.T) { + source := roleConfigSource() + source.Roles[0].Config = json.RawMessage(item.layer) + roles, err := BuildRoleResources(roleConfigDefinition(), source) + if err != nil { + t.Fatal(err) + } + role := roleConfigBuilt(t, roles, "workers") + if role.Error != "" || role.Config == nil || + role.Config.PodDisruptionBudget != (PodDisruptionBudgetConfig{ + Enabled: item.enabled, MaxUnavailable: item.unavailable}) { + t.Fatalf("field presence did not override only the supplied field: %+v", role) + } + if (role.PodDisruptionBudget != nil) != item.enabled { + t.Fatalf("explicit enabled did not control resource emission: %+v", role) + } + if item.enabled && role.PodDisruptionBudget.Spec.MinAvailable.IntVal != max(0, 5-item.unavailable) { + t.Fatal("minimum was not clamped at zero") + } + }) + } + falseValue, zero := false, int32(0) + input := RoleConfigInput{PodDisruptionBudget: &PodDisruptionBudgetInput{ + Enabled: &falseValue, MaxUnavailable: &zero}} + data, err := json.Marshal(input) + if err != nil || string(data) != `{"podDisruptionBudget":{"enabled":false,"maxUnavailable":0}}` { + t.Fatalf("typed presence input dropped explicit false/zero: %s, %v", data, err) + } + empty, err := json.Marshal(RoleConfigInput{}) + if err != nil || string(empty) != `{}` { + t.Fatalf("omitted management input gained explicit values: %s, %v", empty, err) + } +} + +func TestRoleConfigZeroGroupsAreDifferentFromAbsentRole(t *testing.T) { + source := roleConfigSource() + source.Groups = nil + roles, err := BuildRoleResources(roleConfigDefinition(), source) + if err != nil || len(roles) != 2 { + t.Fatalf("zero-group roles disappeared: %+v, %v", roles, err) + } + for _, role := range roles { + if role.Error != "" || role.PodDisruptionBudget == nil || role.PodDisruptionBudget.Spec.MinAvailable.IntVal != 0 { + t.Fatalf("enabled empty role did not produce minAvailable=0: %+v", role) + } + } + source.Roles = nil + roles, err = BuildRoleResources(roleConfigDefinition(), source) + if err != nil || len(roles) != 0 { + t.Fatalf("product declarations invented absent CR roles: %+v, %v", roles, err) + } + source.Roles = []RoleSource{{Name: "workers"}} + definition := roleConfigDefinition() + definition.Roles["workers"] = framework.RoleDefinition[struct{}]{} + roles, err = BuildRoleResources(definition, source) + if err != nil || len(roles) != 1 || roles[0].Error != "" || roles[0].Config == nil || + roles[0].PodDisruptionBudget != nil || roles[0].Config.PodDisruptionBudget.Enabled { + t.Fatalf("zero-value product policy must disable the PDB: %+v, %v", roles, err) + } +} + +func TestRoleConfigRejectsInvalidManagementOnlyForItsRole(t *testing.T) { + for _, layer := range []string{ + `null`, `[]`, `"text"`, `{`, `{"unknown":true}`, `{"podDisruptionBudget":null}`, + `{"podDisruptionBudget":{"unknown":1}}`, `{"podDisruptionBudget":{"enabled":null}}`, + `{"podDisruptionBudget":{"enabled":"true"}}`, `{"podDisruptionBudget":{"maxUnavailable":null}}`, + `{"podDisruptionBudget":{"maxUnavailable":-1}}`, + `{"podDisruptionBudget":{"enabled":false,"maxUnavailable":-1}}`, + `{"podDisruptionBudget":{"maxUnavailable":2147483648}}`, + `{"podDisruptionBudget":{"maxUnavailable":0.5}}`, + } { + t.Run(layer, func(t *testing.T) { + source := roleConfigSource() + source.Roles[0].Config = json.RawMessage(layer) + roles, err := BuildRoleResources(roleConfigDefinition(), source) + if err != nil { + t.Fatalf("invalid management config invalidated the whole identity inventory: %v", err) + } + bad, good := roleConfigBuilt(t, roles, "workers"), roleConfigBuilt(t, roles, "coordinators") + if bad.Error == "" || bad.Config != nil || bad.PodDisruptionBudget != nil || + good.Error != "" || good.PodDisruptionBudget == nil { + t.Fatalf("management validation did not isolate the role: %+v", roles) + } + }) + } + definition := roleConfigDefinition() + definition.Roles["workers"] = framework.RoleDefinition[struct{}]{RoleConfig: RoleConfig{ + PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: false, MaxUnavailable: -1}}} + roles, err := BuildRoleResources(definition, roleConfigSource()) + if err != nil || roleConfigBuilt(t, roles, "workers").Error == "" { + t.Fatalf("disabled invalid defaults escaped validation: %+v, %v", roles, err) + } + delete(definition.Roles, "workers") + roles, err = BuildRoleResources(definition, roleConfigSource()) + if err != nil || roleConfigBuilt(t, roles, "workers").Error == "" || + roleConfigBuilt(t, roles, "coordinators").PodDisruptionBudget == nil { + t.Fatalf("undeclared product role lost its identity or blocked another role: %+v, %v", roles, err) + } +} + +func TestRoleConfigDuplicateFieldsDoNotBecomeDisabledBudgets(t *testing.T) { + for _, layer := range []string{ + `{"podDisruptionBudget":{"enabled":true,"enabled":false}}`, + `{"podDisruptionBudget":{"enabled":true},"podDisruptionBudget":{"enabled":false}}`, + } { + t.Run(layer, func(t *testing.T) { + source := roleConfigSource() + source.Roles[0].Config = json.RawMessage(layer) + roles, err := BuildRoleResources(roleConfigDefinition(), source) + if err != nil { + t.Fatalf("duplicate management field blocked the complete source: %v", err) + } + bad, good := roleConfigBuilt(t, roles, "workers"), roleConfigBuilt(t, roles, "coordinators") + if !strings.Contains(bad.Error, "duplicate field") || bad.Config != nil || bad.PodDisruptionBudget != nil { + t.Fatalf("duplicate field was treated as valid budget configuration: %+v", bad) + } + if good.Error != "" || good.Config == nil || good.PodDisruptionBudget == nil || + good.PodDisruptionBudget.Spec.MinAvailable.IntVal != 1 { + t.Fatalf("duplicate management field blocked the other role: %+v", good) + } + }) + } +} + +func TestRoleConfigRejectsUnreliableIdentityInventories(t *testing.T) { + for _, item := range []struct { + name string + edit func(*SourceSnapshot[struct{}]) + }{ + {"cluster name", func(s *SourceSnapshot[struct{}]) { s.Cluster.Name = "" }}, + {"namespace", func(s *SourceSnapshot[struct{}]) { s.Cluster.Namespace = "INVALID" }}, + {"empty role", func(s *SourceSnapshot[struct{}]) { s.Roles[0].Name = "" }}, + {"invalid role", func(s *SourceSnapshot[struct{}]) { s.Roles[0].Name = "workers.invalid" }}, + {"duplicate role", func(s *SourceSnapshot[struct{}]) { s.Roles = append(s.Roles, s.Roles[0]) }}, + {"missing roles", func(s *SourceSnapshot[struct{}]) { s.Roles = nil }}, + {"unlisted group role", func(s *SourceSnapshot[struct{}]) { s.Groups[0].Role = "unlisted" }}, + {"duplicate group", func(s *SourceSnapshot[struct{}]) { s.Groups = append(s.Groups, s.Groups[0]) }}, + {"invalid group name", func(s *SourceSnapshot[struct{}]) { s.Groups[0].Name = "INVALID" }}, + {"negative replicas", func(s *SourceSnapshot[struct{}]) { s.Groups[0].Replicas = -1 }}, + } { + t.Run(item.name, func(t *testing.T) { + source := roleConfigSource() + item.edit(&source) + if identities, err := SourceRoleIdentities(source); err == nil || identities != nil { + t.Fatalf("unsafe or partial inventory accepted: %+v, %v", identities, err) + } + if roles, err := BuildRoleResources(roleConfigDefinition(), source); err == nil || roles != nil { + t.Fatalf("unsafe inventory reached resource generation: %+v, %v", roles, err) + } + }) + } +} + +func TestRoleConfigReplicaArithmeticDoesNotOverflow(t *testing.T) { + source := roleConfigSource() + source.Groups[0].Replicas, source.Groups[1].Replicas = math.MaxInt32, math.MaxInt32 + definition := roleConfigDefinition() + roles, err := BuildRoleResources(definition, source) + if err != nil { + t.Fatal(err) + } + bad, good := roleConfigBuilt(t, roles, "workers"), roleConfigBuilt(t, roles, "coordinators") + if bad.Error == "" || bad.PodDisruptionBudget != nil || good.PodDisruptionBudget == nil { + t.Fatalf("overflow produced a permissive PDB or blocked another role: %+v", roles) + } + definition.Roles["workers"] = framework.RoleDefinition[struct{}]{RoleConfig: RoleConfig{ + PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: math.MaxInt32}}} + roles, err = BuildRoleResources(definition, source) + if err != nil { + t.Fatal(err) + } + worker := roleConfigBuilt(t, roles, "workers") + if worker.Error != "" || worker.PodDisruptionBudget == nil || + worker.PodDisruptionBudget.Spec.MinAvailable.IntVal != math.MaxInt32 { + t.Fatalf("valid int32 minimum rejected because the intermediate sum exceeds int32: %+v", worker) + } +} + +func TestRoleConfigResourcesOwnDefaultsAndSourceSnapshots(t *testing.T) { + definition, source := roleConfigDefinition(), roleConfigSource() + before, err := json.Marshal(source) + if err != nil { + t.Fatal(err) + } + roles, err := BuildRoleResources(definition, source) + if err != nil { + t.Fatal(err) + } + worker := roleConfigBuilt(t, roles, "workers") + worker.Config.PodDisruptionBudget.Enabled = false + worker.Role.Labels["team"] = "role mutation" + worker.PodDisruptionBudget.Labels["team"] = "metadata mutation" + worker.PodDisruptionBudget.Spec.Selector.MatchLabels["app.kubernetes.io/instance"] = "selector mutation" + after, err := json.Marshal(source) + if err != nil || string(before) != string(after) || + !definition.Roles["workers"].RoleConfig.PodDisruptionBudget.Enabled { + t.Fatalf("built role resources mutated source/defaults: %s, %v", after, err) + } + coordinator := roleConfigBuilt(t, roles, "coordinators") + if coordinator.Role.Labels["team"] != "data" || coordinator.PodDisruptionBudget.Labels["team"] != "data" || + coordinator.PodDisruptionBudget.Spec.Selector.MatchLabels["app.kubernetes.io/instance"] != "roles" { + t.Fatal("one role's resources alias another role") + } + again, err := BuildRoleResources(definition, source) + if err != nil || !roleConfigBuilt(t, again, "workers").Config.PodDisruptionBudget.Enabled { + t.Fatalf("a previous result altered later builds: %+v, %v", again, err) + } + // PDB names use the subdomain resource-name limit, not a Service label limit. + source.Cluster.Name = strings.Repeat("c", 63) + source.Roles = []RoleSource{{Name: strings.Repeat("r", 63)}} + source.Groups = nil + definition.Roles[source.Roles[0].Name] = framework.RoleDefinition[struct{}]{RoleConfig: RoleConfig{ + PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: true}}} + roles, err = BuildRoleResources(definition, source) + if err != nil || len(roles) != 1 || roles[0].Error != "" || roles[0].PodDisruptionBudget == nil { + t.Fatalf("valid PDB subdomain name was rejected: %+v, %v", roles, err) + } +} diff --git a/internal/framework/pipeline/runtime_validation.go b/internal/framework/pipeline/runtime_validation.go new file mode 100644 index 00000000..27173d73 --- /dev/null +++ b/internal/framework/pipeline/runtime_validation.go @@ -0,0 +1,163 @@ +package pipeline + +import ( + "fmt" + "maps" + "path" + "reflect" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +func relativeFile(p string) bool { + return p != "" && p != "." && p != ".." && !path.IsAbs(p) && + path.Clean(p) == p && !strings.HasPrefix(p, "../") && !strings.ContainsRune(p, '\x00') +} + +func nilCodec(codec PropertyCodec) bool { + if codec == nil { + return true + } + v := reflect.ValueOf(codec) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func validateContent(content FileContent) error { + switch c := content.(type) { + case KeyValues: + if nilCodec(c.Codec) { + return fmt.Errorf("key values require a codec") + } + for _, key := range slices.Sorted(maps.Keys(c.Values)) { + switch c.Values[key].(type) { + case Literal, PodNameBinding: + default: + return fmt.Errorf("key %q has an unsupported or nil value", key) + } + } + case Lines: + for _, line := range c { + if strings.ContainsAny(line, "\r\n") { + return fmt.Errorf("lines cannot contain CR or LF") + } + } + case Text: + default: + return fmt.Errorf("unsupported or nil file content; use a content value") + } + return nil +} + +func validateProcess(p Process, sharedGroup *int64) error { + if len(validation.IsDNS1123Label(p.Name)) != 0 || strings.TrimSpace(p.Image) == "" { + return fmt.Errorf("main process requires a valid container name and image") + } + if len(p.Command) > 0 && p.Command[0] == "" { + return fmt.Errorf("main process command cannot start with an empty executable") + } + if sharedGroup != nil && *sharedGroup < 0 { + return fmt.Errorf("shared group cannot be negative") + } + if id := p.Identity; id != nil { + if (id.RunAsUser != nil && *id.RunAsUser < 0) || (id.RunAsGroup != nil && *id.RunAsGroup < 0) { + return fmt.Errorf("process UID and GID cannot be negative") + } + if id.RunAsNonRoot != nil && *id.RunAsNonRoot && id.RunAsUser != nil && *id.RunAsUser == 0 { + return fmt.Errorf("process declares UID 0 with runAsNonRoot") + } + } + return nil +} + +func validateEndpoints(endpoints []Endpoint) error { + ports := map[string]bool{} + for _, endpoint := range endpoints { + if len(validation.IsValidPortName(endpoint.Name)) != 0 || ports[endpoint.Name] || + endpoint.Port < 1 || endpoint.Port > 65535 { + return fmt.Errorf("invalid or duplicate endpoint %q", endpoint.Name) + } + ports[endpoint.Name] = true + } + return nil +} + +// ValidateRuntime checks the declaration itself, before user overrides. Success +// proves neither actual filesystem permissions nor product startup or health. +func ValidateRuntime(r RuntimeDescription) error { + if err := validateRuntimeDomains(r); err != nil { + return err + } + dirs, mounts, writable := map[string]bool{}, map[string]bool{}, map[string]bool{} + for _, dir := range r.Directories { + if len(validation.IsDNS1123Label(dir.Name)) != 0 || dirs[dir.Name] { + return fmt.Errorf("invalid or duplicate directory %q", dir.Name) + } + dirs[dir.Name] = true + } + if r.ConfigDirectory != "" && !dirs[r.ConfigDirectory] { + return fmt.Errorf("config override directory %q is not declared", r.ConfigDirectory) + } + for _, access := range r.Main.Access { + p := access.MountPath + if !dirs[access.Directory] || !path.IsAbs(p) || path.Clean(p) != p || strings.ContainsRune(p, '\x00') { + return fmt.Errorf("invalid mount %q or unknown directory %q", p, access.Directory) + } + if mounts[p] { + return fmt.Errorf("duplicate mount path %q", p) + } + mounts[p] = true + writable[access.Directory] = writable[access.Directory] || !access.ReadOnly + } + files := make([]string, 0, len(r.Files)) + for _, file := range r.Files { + if !dirs[file.Directory] || !relativeFile(file.Path) { + return fmt.Errorf("invalid file path %q or unknown directory %q", file.Path, file.Directory) + } + name := file.Directory + "/" + file.Path + for _, other := range files { + if name == other || strings.HasPrefix(name, other+"/") || strings.HasPrefix(other, name+"/") { + return fmt.Errorf("file paths %q and %q collide", name, other) + } + } + if err := validateContent(file.Content); err != nil { + return fmt.Errorf("file %q: %w", name, err) + } + files = append(files, name) + } + if err := validateEndpoints(r.Endpoints); err != nil { + return err + } + logs := map[string]bool{} + for _, log := range r.LogOutputs { + name := log.Directory + "/" + log.RelativePath + if log.Container != r.Main.Name || !dirs[log.Directory] || !relativeFile(log.RelativePath) || + !writable[log.Directory] || logs[name] { + return fmt.Errorf("invalid log output %q: producer, writable directory, path or uniqueness", name) + } + logs[name] = true + } + return nil +} + +func validateRuntimeDomains(r RuntimeDescription) error { + if err := validateProcess(r.Main, r.SharedGroup); err != nil { + return err + } + if err := validateLifecycle(r); err != nil { + return err + } + if err := validatePlatformVolumes(r); err != nil { + return err + } + if err := validateRetainedData(r); err != nil { + return err + } + return nil +} diff --git a/internal/framework/pipeline/runtime_validation_test.go b/internal/framework/pipeline/runtime_validation_test.go new file mode 100644 index 00000000..4aec9a40 --- /dev/null +++ b/internal/framework/pipeline/runtime_validation_test.go @@ -0,0 +1,114 @@ +package pipeline + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +// A sentinel codec: declaration validation must never invoke serialization. +type unusedCodec struct{} + +func (*unusedCodec) Encode(map[string]string) (string, error) { + panic("ValidateRuntime must not execute a codec") +} + +func runtimeFixture() RuntimeDescription { + return RuntimeDescription{ + Main: Process{Name: "trino", Image: "example.invalid/trino:fixture", Access: []DirectoryAccess{ + {Directory: "config", MountPath: "/etc/trino", ReadOnly: true}, + {Directory: "logs", MountPath: "/var/log/trino"}, + }}, + Directories: []Directory{{Name: "config"}, {Name: "logs"}}, + Files: []File{{Directory: "config", Path: "node.properties", Content: KeyValues{ + Codec: &unusedCodec{}, Values: map[string]PropertyValue{ + "node.environment": Literal("test"), "node.id": PodNameBinding{}, + }, + }}}, + Endpoints: []Endpoint{{Name: "http", Port: 8080}}, + LogOutputs: []LogOutput{{Container: "trino", Directory: "logs", RelativePath: "server.json"}}, + } +} + +func TestRuntimeDeclarationSeparatesBindingsAndCollection(t *testing.T) { + r := runtimeFixture() + r.LogOutputs = nil + if err := ValidateRuntime(r); err != nil { + t.Fatal(err) + } + values := r.Files[0].Content.(KeyValues).Values + if _, ok := values["node.id"].(PodNameBinding); !ok { + t.Fatal("binding must remain expressible without a collector") + } + values["node.id"] = Literal("explicit-id") + if err := ValidateRuntime(r); err != nil { + t.Fatal(err) + } + if _, stillBound := values["node.id"].(PodNameBinding); stillBound { + t.Fatal("one property cannot remain bound after it is replaced with a literal") + } +} + +func TestRuntimeRejectsBrokenReferencesAndContent(t *testing.T) { + cases := []struct { + name, want string + change func(*RuntimeDescription) + }{ + {"unknown-directory", "unknown directory", func(r *RuntimeDescription) { r.Files[0].Directory = "absent" }}, + {"escape", "invalid file path", func(r *RuntimeDescription) { r.Files[0].Path = "../secret" }}, + {"prefix-collision", "collide", func(r *RuntimeDescription) { + r.Files = append(r.Files, File{Directory: "config", Path: "node.properties/nested", Content: Text("")}) + }}, + {"duplicate-mount", "duplicate mount", func(r *RuntimeDescription) { + r.Main.Access = append(r.Main.Access, r.Main.Access[0]) + }}, + {"read-only-log", "invalid log output", func(r *RuntimeDescription) { r.Main.Access[1].ReadOnly = true }}, + {"bad-port", "endpoint", func(r *RuntimeDescription) { r.Endpoints[0].Port = 65536 }}, + {"nil-content", "nil file content", func(r *RuntimeDescription) { r.Files[0].Content = nil }}, + {"nil-codec", "require a codec", func(r *RuntimeDescription) { + r.Files[0].Content = KeyValues{Codec: (*unusedCodec)(nil)} + }}, + {"nil-property", "nil value", func(r *RuntimeDescription) { + r.Files[0].Content.(KeyValues).Values["node.id"] = nil + }}, + {"multiline", "CR or LF", func(r *RuntimeDescription) { r.Files[0].Content = Lines{"one\ntwo"} }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := runtimeFixture() + tc.change(&r) + if err := ValidateRuntime(r); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("wanted %q, got %v", tc.want, err) + } + }) + } +} + +func TestRuntimeIdentityPresenceAndEmptyFiles(t *testing.T) { + r := runtimeFixture() + r.Files = []File{ + {Directory: "config", Path: "empty.properties", Content: KeyValues{Codec: &unusedCodec{}}}, + {Directory: "config", Path: "empty.lines", Content: Lines{}}, + {Directory: "config", Path: "empty.text", Content: Text("")}, + } + zero, negative, group, nonRoot := int64(0), int64(-1), int64(1001), true + r.SharedGroup = &group + r.Main.Identity = &corev1.SecurityContext{RunAsUser: &zero} + if err := ValidateRuntime(r); err != nil { + t.Fatalf("explicit zero and empty files are values: %v", err) + } + r.Main.Identity.RunAsNonRoot = &nonRoot + if err := ValidateRuntime(r); err == nil { + t.Fatal("explicit root contradicts runAsNonRoot") + } + r.Main.Identity = &corev1.SecurityContext{RunAsGroup: &negative} + if err := ValidateRuntime(r); err == nil { + t.Fatal("negative GID must fail") + } + r.Main.Identity = nil + r.SharedGroup = &negative + if err := ValidateRuntime(r); err == nil { + t.Fatal("negative shared group must fail") + } +} diff --git a/internal/framework/pipeline/s3_domain.go b/internal/framework/pipeline/s3_domain.go new file mode 100644 index 00000000..8e782d10 --- /dev/null +++ b/internal/framework/pipeline/s3_domain.go @@ -0,0 +1,148 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +var s3ConnectionType = reflect.TypeFor[framework.S3Connection]() + +// S3 is a named business domain with its own inheritance contract. Traversal +// finds that exact type inside product structs/maps; products do not register +// union handlers or attach merge-policy tags to fields. +func foldS3Domains(base, layer map[string]json.RawMessage, fields map[string]reflect.Type) error { + for _, key := range sortedKeys(layer) { + typ := fields[key] + if typ == s3ConnectionType { + merged, err := foldS3Connection(base[key], layer[key]) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } + base[key] = merged + delete(layer, key) + continue + } + if typ == nil || (typ.Kind() != reflect.Struct && typ.Kind() != reflect.Map) || + typ == quantityType || typ == durationType || typ == affinityType { + continue + } + var inherited, patch map[string]json.RawMessage + if err := json.Unmarshal(layer[key], &patch); err != nil { + return err + } + if err := json.Unmarshal(base[key], &inherited); err != nil && len(base[key]) != 0 { + return err + } + if inherited == nil { + inherited = map[string]json.RawMessage{} + } + children := map[string]reflect.Type{} + if typ.Kind() == reflect.Struct { + children, _ = jsonFields(typ) + } else { + for name := range patch { + children[name] = typ.Elem() + } + } + if err := foldS3Domains(inherited, patch, children); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + var err error + base[key], err = json.Marshal(inherited) + if err != nil { + return err + } + layer[key], err = json.Marshal(patch) + if err != nil { + return err + } + } + return nil +} + +func foldS3Connection(base, layer json.RawMessage) (json.RawMessage, error) { + var prior, next map[string]json.RawMessage + if len(base) != 0 { + if err := json.Unmarshal(base, &prior); err != nil { + return nil, err + } + } + if err := json.Unmarshal(layer, &next); err != nil { + return nil, err + } + if raw, present := next["type"]; present { + var before, after framework.S3ConnectionType + _ = json.Unmarshal(prior["type"], &before) + if err := json.Unmarshal(raw, &after); err != nil { + return nil, err + } + if before == "" { + before = framework.S3Disabled + } + if before != after { + prior = nil + } + } + if prior == nil { + prior = map[string]json.RawMessage{} + } + return json.Marshal(mergeObjects(prior, next)) +} + +func validateS3Layer(data json.RawMessage, scope string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("%s: S3 connection must be an object", scope) + } + if raw, present := fields["type"]; present { + var kind framework.S3ConnectionType + if err := json.Unmarshal(raw, &kind); err != nil || + (kind != framework.S3Disabled && kind != framework.S3Inline && kind != framework.S3Reference) { + return fmt.Errorf("%s.type: must be disabled, inline or reference", scope) + } + _, hasInline := fields["inline"] + _, hasReference := fields["reference"] + if (kind == framework.S3Disabled && (hasInline || hasReference)) || + (kind == framework.S3Inline && hasReference) || (kind == framework.S3Reference && hasInline) { + return fmt.Errorf("%s: fields belong to another S3 connection branch", scope) + } + } + return nil +} + +func validateS3Domains(value reflect.Value) error { + if value.Type() == s3ConnectionType { + return value.Interface().(framework.S3Connection).Validate() + } + switch value.Kind() { + case reflect.Struct: + if value.Type() == quantityType || value.Type() == durationType || value.Type() == affinityType { + return nil + } + for index := 0; index < value.NumField(); index++ { + if err := validateS3Domains(value.Field(index)); err != nil { + return fmt.Errorf("%s: %w", value.Type().Field(index).Name, err) + } + } + case reflect.Map: + keys := make(map[string]reflect.Value, value.Len()) + for _, key := range value.MapKeys() { + keys[key.String()] = key + } + for _, name := range sortedKeys(keys) { + if err := validateS3Domains(value.MapIndex(keys[name])); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + case reflect.Slice: + for index := 0; index < value.Len(); index++ { + if err := validateS3Domains(value.Index(index)); err != nil { + return fmt.Errorf("[%d]: %w", index, err) + } + } + } + return nil +} diff --git a/internal/framework/pipeline/s3_domain_test.go b/internal/framework/pipeline/s3_domain_test.go new file mode 100644 index 00000000..b4875471 --- /dev/null +++ b/internal/framework/pipeline/s3_domain_test.go @@ -0,0 +1,49 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +type s3DomainConfig struct { + Hive struct { + S3 framework.S3Connection `json:"s3"` + } `json:"hive"` +} + +func TestS3DomainInheritanceAndBranchChange(t *testing.T) { + defaults := framework.Config[s3DomainConfig]{Common: assemblyCommon(false)} + defaults.Product.Hive.S3 = framework.S3Connection{Type: framework.S3Inline, Inline: framework.S3Endpoint{ + Host: "default.storage.svc", Region: "us-east-1", Credentials: framework.S3Credentials{SecretName: "credentials"}}} + role := json.RawMessage(`{"hive":{"s3":{"inline":{"host":"role.storage.svc","pathStyle":true}}}}`) + inherited, err := ResolveConfig(defaults, role, json.RawMessage(`{"hive":{"s3":{"inline":{"port":9000}}}}`)) + if err != nil { + t.Fatal(err) + } + connection := inherited.Product.Hive.S3 + if connection.Type != framework.S3Inline || connection.Inline.Host != "role.storage.svc" || + connection.Inline.Port != 9000 || !connection.Inline.PathStyle || + connection.Inline.Credentials.SecretName != "credentials" { + t.Fatalf("S3 field inheritance failed: %+v", connection) + } + referenceLayer := json.RawMessage(`{"hive":{"s3":{"type":"reference","reference":"shared-s3"}}}`) + referenced, err := ResolveConfig(defaults, role, referenceLayer) + if err != nil { + t.Fatal(err) + } + connection = referenced.Product.Hive.S3 + if connection.Type != framework.S3Reference || connection.Reference != "shared-s3" || + !reflect.DeepEqual(connection.Inline, framework.S3Endpoint{}) { + t.Fatalf("branch change retained the old endpoint/credentials: %+v", connection) + } + disabled, err := ResolveConfig(defaults, role, json.RawMessage(`{"hive":{"s3":{"type":"disabled"}}}`)) + if err != nil || disabled.Product.Hive.S3.Type != framework.S3Disabled { + t.Fatalf("explicit disable did not clear the inherited connection: %+v %v", disabled, err) + } + if defaults.Product.Hive.S3.Inline.Host != "default.storage.svc" { + t.Fatal("folding mutated product defaults") + } +} diff --git a/internal/framework/pipeline/shared_inventory_test.go b/internal/framework/pipeline/shared_inventory_test.go new file mode 100644 index 00000000..094bee70 --- /dev/null +++ b/internal/framework/pipeline/shared_inventory_test.go @@ -0,0 +1,77 @@ +package pipeline + +import ( + "encoding/json" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestSharedConfigMapsRespectUnavailableGroupSlots(t *testing.T) { + for _, unavailable := range []string{"invalid-config", "pending-facts"} { + t.Run(unavailable, func(t *testing.T) { + definition := TrinoDefinition() + source := SourceSnapshot[TrinoFacts]{ + Cluster: ClusterIdentity{Name: "reserved", Namespace: "fixture"}, + Roles: []RoleSource{{Name: trinoCoordinatorRole}, {Name: trinoWorkerRole}}, + Groups: []GroupSource{ + {Role: trinoCoordinatorRole, Name: "default", Replicas: 1}, + {Role: trinoWorkerRole, Name: "blocked", Replicas: 1}, + }, + } + if unavailable == "invalid-config" { + source.Groups[1].Config = json.RawMessage(`{"httpPort":"invalid"}`) + } + reservedName := "reserved-workers-blocked" + definition.GenerateCluster = func(in framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]) ( + ClusterOutput, error, + ) { + return ClusterOutput{State: ClusterOutputReady, ConfigMaps: []corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{Name: reservedName, Namespace: in.Cluster.Namespace}, + Data: map[string]string{"shared": "must not occupy the unavailable group slot"}, + }}}, nil + } + prepared, err := PrepareInputs(definition, source) + if err != nil { + t.Fatal(err) + } + var facts map[GroupKey]framework.FactResult[TrinoFacts] + if unavailable == "pending-facts" { + facts = map[GroupKey]framework.FactResult[TrinoFacts]{ + {Role: trinoCoordinatorRole, Name: "default"}: { + Value: &TrinoFacts{}, Diagnostic: FactDiagnostic{State: FactsResolved}, + }, + {Role: trinoWorkerRole, Name: "blocked"}: { + Diagnostic: FactDiagnostic{State: FactsPending, Reason: "CatalogMissing"}, + }, + } + } + plan, err := BuildPreparedResources(definition, prepared, facts, assemblyBuildOptions()) + if err != nil { + t.Fatalf("shared conflict must preserve independent group plans: %v", err) + } + if !strings.Contains(plan.ClusterError, "reserved ConfigMap slot") || + !strings.Contains(plan.ClusterError, reservedName) || len(plan.ClusterOutput.ConfigMaps) != 0 || + plan.ClusterOutput.State != "" { + t.Fatalf("shared output occupied a desired group slot: output=%+v error=%q", + plan.ClusterOutput, plan.ClusterError) + } + if len(plan.Groups) != 2 || plan.Groups[0].Resources == nil || plan.Groups[0].Outcome.Error != "" || + plan.Groups[1].Resources != nil || len(plan.Roles) != 2 { + t.Fatalf("shared conflict changed independent group/role results: %+v", plan) + } + blocked := plan.Groups[1].Outcome + if unavailable == "invalid-config" && blocked.Error == "" { + t.Fatal("test did not exercise a failed configuration") + } + if unavailable == "pending-facts" && (blocked.Error != "" || blocked.Facts == nil || + blocked.Facts.State != FactsPending) { + t.Fatalf("test lost the pending facts result: %+v", blocked) + } + }) + } +} diff --git a/internal/framework/pipeline/source.go b/internal/framework/pipeline/source.go new file mode 100644 index 00000000..91905abd --- /dev/null +++ b/internal/framework/pipeline/source.go @@ -0,0 +1,61 @@ +package pipeline + +import ( + "fmt" + "reflect" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// SourceFromProjection combines raw generated input with deployment facts and +// independently read execution intent. Only this internal boundary folds replica +// presence. It never invokes product callbacks or loses groups on config errors. +func SourceFromProjection[F any]( + projection input.Projection, facts F, operation ClusterOperation, +) (SourceSnapshot[F], error) { + if err := input.ValidateProductType(reflect.TypeFor[F]()); err != nil { + return SourceSnapshot[F]{}, fmt.Errorf("shared facts: %w", err) + } + raw := CloneInput(projection) + source := SourceSnapshot[F]{Cluster: raw.Cluster, Image: raw.Image, + ClusterConfig: raw.ClusterConfig, Shared: CloneInput(facts), Operation: operation} + for _, role := range raw.Roles { + source.Roles = append(source.Roles, RoleSource{Name: role.Name, Config: role.RoleConfig}) + replicas := int32(1) + if role.Replicas != nil { + replicas = *role.Replicas + } + if replicas < 0 { + return source, fmt.Errorf("%s.replicas must not be negative", role.Name) + } + for _, group := range role.Groups { + count := replicas + if group.Replicas != nil { + count = *group.Replicas + } + if count < 0 { + return source, fmt.Errorf("%s/%s.replicas must not be negative", role.Name, group.Name) + } + source.Groups = append(source.Groups, GroupSource{Role: role.Name, Name: group.Name, Replicas: count, + RoleConfigLayer: CloneInput(role.Config), Config: group.Config, + RoleOverrides: CloneInput(role.Overrides), Overrides: group.Overrides}) + } + } + if _, err := SourceRoleIdentities(source); err != nil { + return SourceSnapshot[F]{}, err + } + return source, nil +} + +// Build is a pure complete build from raw input and resolved base facts. Runtime +// controllers prepare inputs first and supply per-group fact outcomes separately. +func Build[C, S, F any](definition framework.ProductDefinition[C, S, F], projection input.Projection, + facts F, options AssemblyOptions, +) (ResourcePlan[C, S, F], error) { + source, err := SourceFromProjection(projection, facts, ClusterOperation{}) + if err != nil { + return ResourcePlan[C, S, F]{}, err + } + return BuildResources(definition, source, options) +} diff --git a/internal/framework/pipeline/storage.go b/internal/framework/pipeline/storage.go new file mode 100644 index 00000000..213da0b2 --- /dev/null +++ b/internal/framework/pipeline/storage.go @@ -0,0 +1,106 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + + "github.com/zncdatadev/operator-go/pkg/framework" + "k8s.io/apimachinery/pkg/util/validation" +) + +// Storage has a fixed discriminator rule. It is deliberately outside the +// generic object merger: changing type starts a fresh branch; keeping or +// omitting type inherits fields within the current branch. +func foldStorageLayer(base, layer map[string]json.RawMessage) error { + var resources, inherited map[string]json.RawMessage + if err := json.Unmarshal(layer["resources"], &resources); err != nil { + if len(layer["resources"]) == 0 { + return nil + } + return err + } + patch, present := resources["storage"] + if !present { + return nil + } + if err := json.Unmarshal(base["resources"], &inherited); err != nil { + return err + } + var next, previous map[string]json.RawMessage + if err := json.Unmarshal(patch, &next); err != nil { + return err + } + if err := json.Unmarshal(inherited["storage"], &previous); err != nil { + return err + } + if discriminator, present := next["type"]; present { + var oldType, newType framework.StorageType + if err := json.Unmarshal(discriminator, &newType); err != nil { + return err + } + if err := json.Unmarshal(previous["type"], &oldType); err != nil { + return err + } + if oldType == "" { + oldType = framework.StorageEphemeral + } + if oldType != newType { + previous = make(map[string]json.RawMessage) + } + } + merged, err := json.Marshal(mergeObjects(previous, next)) + if err != nil { + return err + } + inherited["storage"] = merged + base["resources"], err = json.Marshal(inherited) + if err != nil { + return err + } + delete(resources, "storage") + layer["resources"], err = json.Marshal(resources) + return err +} + +// Syntax and contradictory fields are checked for every layer, before another +// layer could hide them. Partial persistent inputs remain valid for inheritance. +func validateStorageLayer(data json.RawMessage, path string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("%s: storage must be an object", path) + } + if raw, present := fields["type"]; present { + var kind framework.StorageType + if err := json.Unmarshal(raw, &kind); err != nil || + (kind != framework.StorageEphemeral && kind != framework.StoragePersistent) { + return fmt.Errorf("%s.type: must be ephemeral or persistent", path) + } + if kind == framework.StorageEphemeral { + if _, present := fields["storageClassName"]; present { + return fmt.Errorf("%s: ephemeral storage cannot specify storageClassName", path) + } + if _, present := fields["capacity"]; present { + return fmt.Errorf("%s: ephemeral storage cannot specify capacity", path) + } + } + } + return nil +} + +func validateStorage(storage framework.Storage) error { + switch storage.Type { + case "", framework.StorageEphemeral: + if storage.StorageClassName != "" || !storage.Capacity.IsZero() { + return fmt.Errorf("config.resources.storage: ephemeral storage cannot have storageClassName or capacity") + } + case framework.StoragePersistent: + if storage.StorageClassName == "" || len(validation.IsDNS1123Subdomain(storage.StorageClassName)) != 0 || + storage.Capacity.Sign() <= 0 { + return fmt.Errorf("config.resources.storage: persistent storage requires " + + "an explicit storage class and positive capacity") + } + default: + return fmt.Errorf("config.resources.storage.type: must be ephemeral or persistent") + } + return nil +} diff --git a/internal/framework/pipeline/storage_test.go b/internal/framework/pipeline/storage_test.go new file mode 100644 index 00000000..d7c90e20 --- /dev/null +++ b/internal/framework/pipeline/storage_test.go @@ -0,0 +1,87 @@ +package pipeline + +import ( + "encoding/json" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestStorageInheritanceAndBranchSwitch(t *testing.T) { + persistent := `{"resources":{"storage":{"type":"persistent","storageClassName":"retained","capacity":"1Gi"}}}` + got, err := ResolveConfig(testDefaults(), json.RawMessage(persistent), + json.RawMessage(`{"resources":{"storage":{"capacity":"2Gi"}}}`)) + if err != nil { + t.Fatal(err) + } + storage := got.Common.Resources.Storage + if storage.Type != framework.StoragePersistent || storage.StorageClassName != "retained" || + storage.Capacity.Cmp(resource.MustParse("2Gi")) != 0 || + got.Common.Resources.CPU.Min.String() != "500m" { + t.Fatalf("storage inheritance lost branch fields or sibling resources: %+v", got.Common.Resources) + } + got, err = ResolveConfig(testDefaults(), json.RawMessage(persistent), + json.RawMessage(`{"resources":{"storage":{"type":"ephemeral"}}}`)) + if err != nil { + t.Fatal(err) + } + storage = got.Common.Resources.Storage + if storage.Type != framework.StorageEphemeral || storage.StorageClassName != "" || !storage.Capacity.IsZero() { + t.Fatalf("old branch survived switch: %+v", storage) + } + // Effective-value validation permits a higher layer to correct capacity. + got, err = ResolveConfig(testDefaults(), + json.RawMessage(`{"resources":{"storage":{"type":"persistent","storageClassName":"retained","capacity":"0"}}}`), + json.RawMessage(`{"resources":{"storage":{"capacity":"1Gi"}}}`)) + if err != nil || + got.Common.Resources.Storage.Capacity.Cmp(resource.MustParse("1Gi")) != 0 { + t.Fatalf("final capacity was not validated after inheritance: %v", err) + } +} + +func TestStorageLayerAndEffectiveValidation(t *testing.T) { + for _, raw := range []string{ + `{"type":"ephemeral","capacity":"1Gi"}`, `{"type":"persistent"}`, + `{"type":""}`, `{"storageClassName":"retained"}`, + `{"type":"persistent","storageClassName":"retained","capacity":"0"}`, + } { + if _, err := ResolveConfig(testDefaults(), nil, json.RawMessage(`{"resources":{"storage":`+raw+`}}`)); err == nil { + t.Fatalf("invalid storage accepted: %s", raw) + } + } + defaults := testDefaults() + defaults.Common.Resources.Storage = framework.Storage{Type: framework.StoragePersistent, + StorageClassName: "retained", Capacity: resource.MustParse("1Gi")} + got, err := ResolveConfig(defaults, nil, json.RawMessage(`{"resources":{"storage":{}}}`)) + if err != nil || + got.Common.Resources.Storage.Type != framework.StoragePersistent { + t.Fatalf("empty object did not inherit defaults: %v", err) + } +} + +func TestStorageRequiresRuntimeConsumer(t *testing.T) { + r := runtimeFixture() + r.Files, r.LogOutputs = nil, nil + common := testDefaults().Common + common.Resources.Storage = framework.Storage{Type: framework.StoragePersistent, + StorageClassName: "retained", Capacity: resource.MustParse("1Gi")} + identity := GroupIdentity{ClusterIdentity: ClusterIdentity{Name: "store", Namespace: "test"}, + Role: "workers", Name: "default", Replicas: 1} + if _, err := assembleGroup(identity, common, ResolvedImage{}, + r, r.Main, nil, nil, AssemblyOptions{}); err == nil { + t.Fatal("persistent configuration without a Data directory was silently ignored") + } + r = retainedRuntime() + r.Files, r.LogOutputs = nil, nil + common.Resources.Storage = framework.Storage{Type: framework.StorageEphemeral} + out, err := assembleGroup(identity, common, ResolvedImage{}, r, r.Main, nil, nil, AssemblyOptions{}) + if err != nil { + t.Fatal(err) + } + volume := findPodVolume(out.StatefulSet.Spec.Template.Spec, "data") + if out.RetainedData != nil || len(out.StatefulSet.Spec.VolumeClaimTemplates) != 0 || + volume == nil || volume.EmptyDir == nil { + t.Fatal("ephemeral Data directory must produce only emptyDir") + } +} diff --git a/internal/framework/pipeline/testinput/crd.yaml b/internal/framework/pipeline/testinput/crd.yaml new file mode 100644 index 00000000..dab00f0a --- /dev/null +++ b/internal/framework/pipeline/testinput/crd.yaml @@ -0,0 +1,4900 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: trinoclusters.pipeline.tests.kubedoop.dev +spec: + group: pipeline.tests.kubedoop.dev + names: + kind: TrinoCluster + listKind: TrinoClusterList + plural: trinoclusters + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + nullable: true + properties: + clusterConfig: + nullable: true + properties: + authentication: + items: + nullable: true + properties: + authenticationClass: + nullable: true + type: string + oidc: + nullable: true + properties: + clientCredentialsSecret: + nullable: true + type: string + extraScopes: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.clientCredentialsSecret),has(self.extraScopes)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.authenticationClass),has(self.oidc)].filter(v,v).size() + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + nodeEnvironment: + nullable: true + type: string + reconciliationPaused: + nullable: true + type: boolean + stopped: + nullable: true + type: boolean + vectorAgentConfigMap: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.authentication),has(self.nodeEnvironment),has(self.reconciliationPaused),has(self.stopped),has(self.vectorAgentConfigMap)].filter(v,v).size() + coordinators: + nullable: true + properties: + cliOverrides: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + config: + nullable: true + properties: + affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string + gracefulShutdownTimeout: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + logging: + nullable: true + properties: + containers: + additionalProperties: + nullable: true + properties: + console: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + file: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + loggers: + additionalProperties: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + enableVectorAgent: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() + resources: + nullable: true + properties: + cpu: + nullable: true + properties: + max: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + min: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() + memory: + nullable: true + properties: + limit: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() + storage: + nullable: true + properties: + capacity: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.httpPort),has(self.logging),has(self.resources)].filter(v,v).size() + configOverrides: + additionalProperties: + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with set + or remove + rule: '!has(self.replace) || (!has(self.set) && !has(self.remove))' + - message: a property cannot be both set and removed in + one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text or + remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + envOverrides: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + podOverrides: + nullable: true + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer + roleConfig: + nullable: true + properties: + podDisruptionBudget: + nullable: true + properties: + enabled: + nullable: true + type: boolean + maxUnavailable: + format: int32 + maximum: 2147483647 + minimum: 0 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.enabled),has(self.maxUnavailable)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podDisruptionBudget)].filter(v,v).size() + roleGroups: + additionalProperties: + nullable: true + properties: + cliOverrides: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + config: + nullable: true + properties: + affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string + gracefulShutdownTimeout: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + logging: + nullable: true + properties: + containers: + additionalProperties: + nullable: true + properties: + console: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + file: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + loggers: + additionalProperties: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + enableVectorAgent: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() + resources: + nullable: true + properties: + cpu: + nullable: true + properties: + max: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + min: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() + memory: + nullable: true + properties: + limit: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() + storage: + nullable: true + properties: + capacity: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.httpPort),has(self.logging),has(self.resources)].filter(v,v).size() + configOverrides: + additionalProperties: + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with + set or remove + rule: '!has(self.replace) || (!has(self.set) && + !has(self.remove))' + - message: a property cannot be both set and removed + in one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text + or remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + envOverrides: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + podOverrides: + nullable: true + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas),has(self.roleConfig),has(self.roleGroups)].filter(v,v).size() + image: + nullable: true + properties: + custom: + nullable: true + type: string + kubedoopVersion: + nullable: true + type: string + productVersion: + nullable: true + type: string + pullPolicy: + enum: + - Always + - IfNotPresent + - Never + nullable: true + type: string + pullSecretName: + nullable: true + type: string + repo: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.custom),has(self.kubedoopVersion),has(self.productVersion),has(self.pullPolicy),has(self.pullSecretName),has(self.repo)].filter(v,v).size() + workers: + nullable: true + properties: + cliOverrides: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + config: + nullable: true + properties: + affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string + gracefulShutdownTimeout: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + logging: + nullable: true + properties: + containers: + additionalProperties: + nullable: true + properties: + console: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + file: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + loggers: + additionalProperties: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + enableVectorAgent: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() + resources: + nullable: true + properties: + cpu: + nullable: true + properties: + max: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + min: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() + memory: + nullable: true + properties: + limit: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() + storage: + nullable: true + properties: + capacity: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.httpPort),has(self.logging),has(self.resources)].filter(v,v).size() + configOverrides: + additionalProperties: + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with set + or remove + rule: '!has(self.replace) || (!has(self.set) && !has(self.remove))' + - message: a property cannot be both set and removed in + one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text or + remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + envOverrides: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + podOverrides: + nullable: true + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer + roleConfig: + nullable: true + properties: + podDisruptionBudget: + nullable: true + properties: + enabled: + nullable: true + type: boolean + maxUnavailable: + format: int32 + maximum: 2147483647 + minimum: 0 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.enabled),has(self.maxUnavailable)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podDisruptionBudget)].filter(v,v).size() + roleGroups: + additionalProperties: + nullable: true + properties: + cliOverrides: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + config: + nullable: true + properties: + affinity: + nullable: true + properties: + nodeAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + preference: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preference),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + nullable: true + properties: + nodeSelectorTerms: + items: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchFields: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchFields)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeSelectorTerms)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + podAntiAffinity: + nullable: true + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + podAffinityTerm: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are + not allowed + rule: self.all(k, dyn(self[k]) + != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are + not allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + weight: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.podAffinityTerm),has(self.weight)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + requiredDuringSchedulingIgnoredDuringExecution: + items: + nullable: true + properties: + labelSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + matchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + mismatchLabelKeys: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + namespaceSelector: + nullable: true + properties: + matchExpressions: + items: + nullable: true + properties: + key: + nullable: true + type: string + operator: + nullable: true + type: string + values: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements + are not allowed + rule: self.all(v, dyn(v) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields + are not allowed + rule: size(dyn(self)) == [has(self.key),has(self.operator),has(self.values)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are + not allowed + rule: self.all(v, dyn(v) != null) + matchLabels: + additionalProperties: + nullable: true + type: string + maxProperties: 16 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not + allowed + rule: self.all(k, dyn(self[k]) != + null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.matchExpressions),has(self.matchLabels)].filter(v,v).size() + namespaces: + items: + nullable: true + type: string + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not + allowed + rule: self.all(v, dyn(v) != null) + topologyKey: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.labelSelector),has(self.matchLabelKeys),has(self.mismatchLabelKeys),has(self.namespaceSelector),has(self.namespaces),has(self.topologyKey)].filter(v,v).size() + maxItems: 16 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.preferredDuringSchedulingIgnoredDuringExecution),has(self.requiredDuringSchedulingIgnoredDuringExecution)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.nodeAffinity),has(self.podAffinity),has(self.podAntiAffinity)].filter(v,v).size() + catalogConfigMapName: + nullable: true + type: string + gracefulShutdownTimeout: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a valid duration string + rule: duration(self) == duration(self) + httpPort: + format: int32 + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: integer + logging: + nullable: true + properties: + containers: + additionalProperties: + nullable: true + properties: + console: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + file: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + loggers: + additionalProperties: + nullable: true + properties: + level: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not + allowed + rule: size(dyn(self)) == [has(self.level)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.console),has(self.file),has(self.loggers)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + enableVectorAgent: + nullable: true + type: boolean + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.containers),has(self.enableVectorAgent)].filter(v,v).size() + resources: + nullable: true + properties: + cpu: + nullable: true + properties: + max: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + min: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.max),has(self.min)].filter(v,v).size() + memory: + nullable: true + properties: + limit: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.limit)].filter(v,v).size() + storage: + nullable: true + properties: + capacity: + maxLength: 128 + nullable: true + type: string + x-kubernetes-validations: + - message: must be a Kubernetes quantity string + rule: isQuantity(self) + storageClassName: + nullable: true + type: string + type: + enum: + - ephemeral + - persistent + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.capacity),has(self.storageClassName),has(self.type)].filter(v,v).size() + - message: ephemeral storage cannot specify storageClassName + or capacity + rule: '!has(self.type) || self.type != ''ephemeral'' + || (!has(self.storageClassName) && !has(self.capacity))' + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cpu),has(self.memory),has(self.storage)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.affinity),has(self.catalogConfigMapName),has(self.gracefulShutdownTimeout),has(self.httpPort),has(self.logging),has(self.resources)].filter(v,v).size() + configOverrides: + additionalProperties: + nullable: true + properties: + lines: + items: + nullable: true + pattern: ^[^\r\n]*$ + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + properties: + nullable: true + properties: + remove: + items: + nullable: true + type: string + maxItems: 32 + nullable: true + type: array + x-kubernetes-validations: + - message: null array elements are not allowed + rule: self.all(v, dyn(v) != null) + replace: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + set: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.remove),has(self.replace),has(self.set)].filter(v,v).size() + - message: properties.replace cannot be combined with + set or remove + rule: '!has(self.replace) || (!has(self.set) && + !has(self.remove))' + - message: a property cannot be both set and removed + in one layer + rule: '!has(self.set) || !has(self.remove) || self.remove.all(k, + !(k in self.set))' + remove: + enum: + - true + nullable: true + type: boolean + text: + nullable: true + type: string + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.lines),has(self.properties),has(self.remove),has(self.text)].filter(v,v).size() + - message: select exactly one of properties, lines, text + or remove + rule: '[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() + == 1' + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + envOverrides: + additionalProperties: + nullable: true + type: string + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + podOverrides: + nullable: true + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + format: int32 + minimum: 0 + nullable: true + type: integer + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas)].filter(v,v).size() + maxProperties: 32 + nullable: true + type: object + x-kubernetes-validations: + - message: null map values are not allowed + rule: self.all(k, dyn(self[k]) != null) + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.cliOverrides),has(self.config),has(self.configOverrides),has(self.envOverrides),has(self.podOverrides),has(self.replicas),has(self.roleConfig),has(self.roleGroups)].filter(v,v).size() + type: object + x-kubernetes-validations: + - message: explicit null fields are not allowed + rule: size(dyn(self)) == [has(self.clusterConfig),has(self.coordinators),has(self.image),has(self.workers)].filter(v,v).size() + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + maxLength: 32768 + type: string + observedGeneration: + format: int64 + minimum: 0 + type: integer + reason: + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + 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: + - type + - status + - reason + - message + - lastTransitionTime + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + groups: + items: + properties: + applied: + type: boolean + checks: + items: + properties: + reason: + type: string + state: + enum: + - consistent + - conflict + - unknown + type: string + subject: + type: string + required: + - subject + - state + type: object + type: array + desiredReplicas: + format: int32 + minimum: 0 + type: integer + executionReplicas: + format: int32 + minimum: 0 + type: integer + facts: + properties: + message: + type: string + observed: + items: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + resourceVersion: + type: string + uid: + type: string + required: + - apiVersion + - kind + - namespace + - name + type: object + type: array + reason: + type: string + state: + enum: + - resolved + - pending + - invalid + - readError + type: string + required: + - state + type: object + message: + type: string + name: + minLength: 1 + type: string + platform: + properties: + diagnostic: + properties: + message: + type: string + observed: + items: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + resourceVersion: + type: string + uid: + type: string + required: + - apiVersion + - kind + - namespace + - name + type: object + type: array + reason: + type: string + state: + enum: + - resolved + - pending + - invalid + - readError + type: string + required: + - state + type: object + listeners: + items: + properties: + address: + type: string + directory: + type: string + pod: + type: string + ports: + additionalProperties: + format: int32 + type: integer + type: object + required: + - pod + - directory + - address + - ports + type: object + type: array + phase: + type: string + required: + - phase + - diagnostic + type: object + readyReplicas: + format: int32 + minimum: 0 + type: integer + role: + minLength: 1 + type: string + required: + - role + - name + - desiredReplicas + - readyReplicas + - applied + type: object + type: array + x-kubernetes-list-map-keys: + - role + - name + x-kubernetes-list-type: map + observedGeneration: + format: int64 + minimum: 0 + type: integer + roles: + items: + properties: + applied: + type: boolean + message: + type: string + name: + minLength: 1 + type: string + required: + - name + - applied + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: explicit null spec is not allowed + rule: size(dyn(self)) == [has(self.spec),has(self.status),has(self.apiVersion),has(self.kind),has(self.metadata)].filter(v,v).size() + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/internal/framework/pipeline/testinput/zz_generated.input.go b/internal/framework/pipeline/testinput/zz_generated.input.go new file mode 100644 index 00000000..f0f63b52 --- /dev/null +++ b/internal/framework/pipeline/testinput/zz_generated.input.go @@ -0,0 +1,306 @@ +// Code generated by operator-go inputgen; DO NOT EDIT. +package testinput + +import ( + "encoding/json" + "fmt" + "sort" + + 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/runtime/schema" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const InputContractVersion = 1 + +var GroupVersion = schema.GroupVersion{Group: "pipeline.tests.kubedoop.dev", Version: "v1alpha1"} +var SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(GroupVersion, &TrinoCluster{}, &TrinoClusterList{}) + metav1.AddToGroupVersion(scheme, GroupVersion) + return nil +}) +var AddToScheme = SchemeBuilder.AddToScheme + +type TrinoCluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec SpecInput `json:"spec"` + Status framework.ReconcileStatus `json:"status,omitempty"` +} + +type TrinoClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TrinoCluster `json:"items"` +} + +type SpecInput struct { + Image *input.ImageInput `json:"image,omitempty"` + ClusterConfig *ClusterConfigInput `json:"clusterConfig,omitempty"` + Coordinators *RoleInput `json:"coordinators,omitempty"` + Workers *RoleInput `json:"workers,omitempty"` +} + +type RoleInput struct { + RoleConfig *input.RoleConfigInput `json:"roleConfig,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` + Config *ConfigInput `json:"config,omitempty"` + ConfigOverrides *map[string]input.FileOverride `json:"configOverrides,omitempty"` + EnvOverrides *map[string]string `json:"envOverrides,omitempty"` + CLIOverrides *[]string `json:"cliOverrides,omitempty"` + PodOverrides json.RawMessage `json:"podOverrides,omitempty"` + RoleGroups map[string]RoleGroupInput `json:"roleGroups,omitempty"` +} + +type RoleGroupInput struct { + Replicas *int32 `json:"replicas,omitempty"` + Config *ConfigInput `json:"config,omitempty"` + ConfigOverrides *map[string]input.FileOverride `json:"configOverrides,omitempty"` + EnvOverrides *map[string]string `json:"envOverrides,omitempty"` + CLIOverrides *[]string `json:"cliOverrides,omitempty"` + PodOverrides json.RawMessage `json:"podOverrides,omitempty"` +} + +type ConfigInputResourcesCPU struct { + Min *resource.Quantity `json:"min,omitempty"` + Max *resource.Quantity `json:"max,omitempty"` +} + +type ConfigInputResourcesMemory struct { + Limit *resource.Quantity `json:"limit,omitempty"` +} + +type ConfigInputResourcesStorage struct { + Type *string `json:"type,omitempty"` + StorageClassName *string `json:"storageClassName,omitempty"` + Capacity *resource.Quantity `json:"capacity,omitempty"` +} + +type ConfigInputResources struct { + CPU *ConfigInputResourcesCPU `json:"cpu,omitempty"` + Memory *ConfigInputResourcesMemory `json:"memory,omitempty"` + Storage *ConfigInputResourcesStorage `json:"storage,omitempty"` +} + +type ConfigInputLoggingContainersValueConsole struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValueFile struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValueLoggersValue struct { + Level *string `json:"level,omitempty"` +} + +type ConfigInputLoggingContainersValue struct { + Console *ConfigInputLoggingContainersValueConsole `json:"console,omitempty"` + File *ConfigInputLoggingContainersValueFile `json:"file,omitempty"` + Loggers *map[string]ConfigInputLoggingContainersValueLoggersValue `json:"loggers,omitempty"` +} + +type ConfigInputLogging struct { + EnableVectorAgent *bool `json:"enableVectorAgent,omitempty"` + Containers *map[string]ConfigInputLoggingContainersValue `json:"containers,omitempty"` +} + +type ConfigInput struct { + Resources *ConfigInputResources `json:"resources,omitempty"` + Logging *ConfigInputLogging `json:"logging,omitempty"` + Affinity *corev1.Affinity `json:"affinity,omitempty"` + GracefulShutdownTimeout *metav1.Duration `json:"gracefulShutdownTimeout,omitempty"` + HTTPPort *int32 `json:"httpPort,omitempty"` + CatalogConfigMapName *string `json:"catalogConfigMapName,omitempty"` +} + +type ClusterConfigInputAuthenticationItemOIDC struct { + ClientCredentialsSecret *string `json:"clientCredentialsSecret,omitempty"` + ExtraScopes *[]string `json:"extraScopes,omitempty"` +} + +type ClusterConfigInputAuthenticationItem struct { + AuthenticationClass *string `json:"authenticationClass,omitempty"` + OIDC *ClusterConfigInputAuthenticationItemOIDC `json:"oidc,omitempty"` +} + +type ClusterConfigInput struct { + Stopped *bool `json:"stopped,omitempty"` + ReconciliationPaused *bool `json:"reconciliationPaused,omitempty"` + VectorAgentConfigMap *string `json:"vectorAgentConfigMap,omitempty"` + Authentication *[]ClusterConfigInputAuthenticationItem `json:"authentication,omitempty"` + NodeEnvironment *string `json:"nodeEnvironment,omitempty"` +} + +func (in *TrinoCluster) DeepCopyInto(out *TrinoCluster) { + *out = *in + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = input.Clone(in.Spec) + in.Status.DeepCopyInto(&out.Status) +} +func (in *TrinoCluster) DeepCopy() *TrinoCluster { + if in == nil { + return nil + } + out := new(TrinoCluster) + in.DeepCopyInto(out) + return out +} +func (in *TrinoCluster) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + return in.DeepCopy() +} +func (in *TrinoClusterList) DeepCopyInto(out *TrinoClusterList) { + *out = *in + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]TrinoCluster, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} +func (in *TrinoClusterList) DeepCopy() *TrinoClusterList { + if in == nil { + return nil + } + out := new(TrinoClusterList) + in.DeepCopyInto(out) + return out +} +func (in *TrinoClusterList) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + return in.DeepCopy() +} + +// Decode is the strict local JSON entry point. A typed API-server GET is the +// other supported input path; ordinary json.Unmarshal alone loses null values. +func Decode(data []byte) (*TrinoCluster, error) { + var out TrinoCluster + if err := input.DecodeJSON(data, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Operation reads only fixed controls, before projection or product validation. +// It neither marshals product fields nor changes the declared replica inventory. +func Operation(in *TrinoCluster) framework.ClusterOperation { + var out framework.ClusterOperation + if in == nil || in.Spec.ClusterConfig == nil { + return out + } + controls := in.Spec.ClusterConfig + if controls.Stopped != nil { + out.Stopped = *controls.Stopped + } + if controls.ReconciliationPaused != nil { + out.ReconciliationPaused = *controls.ReconciliationPaused + } + return out +} + +// Project preserves raw layer presence and the complete declared role inventory. +// It neither folds replicas/config nor carries facts or runtime control state. +func Project(in *TrinoCluster) (input.Projection, error) { + var out input.Projection + if in == nil { + return out, fmt.Errorf("input CR is required") + } + out.Cluster = framework.ClusterIdentity{ + Name: in.Name, Namespace: in.Namespace, Labels: input.Clone(in.Labels), + } + image, err := input.ConfigJSON(in.Spec.Image) + if err != nil { + return out, fmt.Errorf("image: %w", err) + } + out.Image = image + clusterConfig, err := input.ClusterConfigJSON(in.Spec.ClusterConfig) + if err != nil { + return out, fmt.Errorf("clusterConfig: %w", err) + } + out.ClusterConfig = clusterConfig + roles := []struct { + name string + value *RoleInput + }{ + {"coordinators", in.Spec.Coordinators}, + {"workers", in.Spec.Workers}, + } + for _, entry := range roles { + if entry.value == nil { + continue + } + role := entry.value + management, err := input.ConfigJSON(role.RoleConfig) + if err != nil { + return out, fmt.Errorf("%s.roleConfig: %w", entry.name, err) + } + config, err := input.ConfigJSON(role.Config) + if err != nil { + return out, fmt.Errorf("%s.config: %w", entry.name, err) + } + projected := input.Role{Name: entry.name, Replicas: input.Clone(role.Replicas), + Config: config, RoleConfig: management, + Overrides: inputOverrides(role.ConfigOverrides, role.EnvOverrides, role.CLIOverrides, role.PodOverrides), + } + groups := make([]string, 0, len(role.RoleGroups)) + for name := range role.RoleGroups { + groups = append(groups, name) + } + sort.Strings(groups) + for _, name := range groups { + group := role.RoleGroups[name] + config, err := input.ConfigJSON(group.Config) + if err != nil { + return out, fmt.Errorf("%s/%s.config: %w", entry.name, name, err) + } + projected.Groups = append(projected.Groups, input.Group{ + Name: name, Replicas: input.Clone(group.Replicas), Config: config, + Overrides: inputOverrides( + group.ConfigOverrides, group.EnvOverrides, group.CLIOverrides, group.PodOverrides), + }) + } + out.Roles = append(out.Roles, projected) + } + return out, nil +} + +// Binding is the versioned generated-code bridge, not a product controller. +func Binding() input.Binding[*TrinoCluster] { + return input.Binding[*TrinoCluster]{ + Version: InputContractVersion, + Roles: []string{"coordinators", "workers"}, + AddToScheme: AddToScheme, + NewObject: func() *TrinoCluster { return &TrinoCluster{} }, + Operation: Operation, Project: Project, + Status: func(in *TrinoCluster) *framework.ReconcileStatus { return &in.Status }, + } +} + +func inputOverrides( + files *map[string]input.FileOverride, env *map[string]string, cli *[]string, pod json.RawMessage, +) *input.Overrides { + if files == nil && env == nil && cli == nil && len(pod) == 0 { + return nil + } + out := &input.Overrides{ + CLIOverrides: input.Clone(cli), PodOverrides: input.Clone(pod), + } + if files != nil { + out.ConfigOverrides = input.Clone(*files) + } + if env != nil { + out.EnvOverrides = input.Clone(*env) + } + return out +} diff --git a/internal/framework/pipeline/trino_final_fixture_test.go b/internal/framework/pipeline/trino_final_fixture_test.go new file mode 100644 index 00000000..6eeb7b67 --- /dev/null +++ b/internal/framework/pipeline/trino_final_fixture_test.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "path" + "strconv" + "strings" +) + +// validateTrinoFinal checks only modeled relations under the declared launch +// premise. Unknown content or execution is not rewritten or called consistent. +func validateTrinoFinal(view FinalView) []Check { + if !view.FilePreparationKnown { + return []Check{{Subject: trinoExecutionCheck, State: Unknown, Reason: "file preparation changed"}} + } + main := findContainer(view.Pod, view.Generated.Main.Name) + if !processPremise(view.Generated.Main, main) { + return []Check{{Subject: trinoExecutionCheck, State: Unknown, + Reason: "image, command, args, environment or working directory changed"}} + } + for _, access := range view.Generated.Main.Access { + if access.Directory != view.Generated.ConfigDirectory { + continue + } + found := false + for _, mount := range main.VolumeMounts { + if strings.HasPrefix(mount.MountPath, access.MountPath+"/") { + return []Check{{Subject: trinoExecutionCheck, State: Unknown, + Reason: "a nested mount may replace generated configuration"}} + } + if mount.Name == access.Directory && mount.MountPath == access.MountPath && + mount.SubPath == "" && mount.SubPathExpr == "" { + found = true + } + } + if !found { + return []Check{{Subject: trinoExecutionCheck, State: Unknown, Reason: "configuration mount changed"}} + } + } + checks := make([]Check, 0, 6) + for _, name := range []string{"config.properties", trinoNodeFile, trinoJVMFile, "log.properties"} { + if findFile(view.Files, view.Generated.ConfigDirectory, name) == nil { + checks = append(checks, Check{Subject: name, State: Conflict, Reason: "required by the declared Trino launcher"}) + } + } + file := findFile(view.Files, view.Generated.ConfigDirectory, "config.properties") + port, known := literalProperty(file, "http-server.http.port") + if known { + number, err := strconv.ParseInt(port, 10, 32) + if err != nil || number < 1 || number > 65535 { + checks = append(checks, Check{Subject: trinoHTTPCheck, State: Conflict, Reason: "invalid explicit HTTP port"}) + } else { + matches := false + for _, declared := range main.Ports { + if declared.Name == trinoHTTPEndpoint && declared.ContainerPort == int32(number) { + matches = true + } + } + state, reason := Consistent, "file port matches the final named container port" + if !matches { + state, reason = Conflict, "file port does not match the final named container port" + } + checks = append(checks, Check{Subject: trinoHTTPCheck, State: state, Reason: reason}) + } + } else { + checks = append(checks, Check{Subject: trinoHTTPCheck, State: Unknown, + Reason: "HTTP property is absent or not structured"}) + } + logPath, known := literalProperty(file, "log.path") + for _, output := range view.Generated.LogOutputs { + if !view.LogCollectionKnown { + continue + } + expected := "" + for _, access := range view.Generated.Main.Access { + if access.Directory == output.Directory { + expected = path.Join(access.MountPath, output.RelativePath) + } + } + state, reason := Unknown, "native log path is not known" + if known { + state, reason = Consistent, "native log path matches declared collection source" + if logPath != expected { + state, reason = Conflict, fmt.Sprintf("log.path %q differs from collected path %q", logPath, expected) + } + } + checks = append(checks, Check{Subject: "trino.logging", State: state, Reason: reason}) + } + return checks +} + +func literalProperty(file *File, key string) (string, bool) { + if file == nil { + return "", false + } + properties, ok := file.Content.(KeyValues) + if !ok { + return "", false + } + value, ok := properties.Values[key].(Literal) + return string(value), ok +} diff --git a/internal/framework/pipeline/trino_fixture_test.go b/internal/framework/pipeline/trino_fixture_test.go new file mode 100644 index 00000000..1af9dce9 --- /dev/null +++ b/internal/framework/pipeline/trino_fixture_test.go @@ -0,0 +1,241 @@ +package pipeline + +import ( + "fmt" + "path" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + trinoName = "trino" + trinoCoordinatorRole = "coordinators" + trinoWorkerRole = "workers" + trinoConfigDirectory = "config" + trinoLogDirectory = "log" + trinoServerLog = "server.json" + trinoRunArgument = "run" + trinoHTTPEndpoint = "http" + trinoInfoLevel = "INFO" + trinoDataDirectory = "data" + trinoJVMFile = "jvm.config" + trinoEnvironmentKey = "node.environment" + trinoHTTPCheck = "trino.http" + trinoExecutionCheck = "trino.execution" + trinoNodeFile = "node.properties" + trinoNodeIDKey = "node.id" +) + +var trinoEnvironmentPattern = regexp.MustCompile(`^[a-z0-9][_a-z0-9]*$`) + +// TrinoClusterConfig is cluster-wide product intent, outside role inheritance and facts. +type TrinoClusterConfig struct { + NodeEnvironment string `json:"nodeEnvironment"` +} + +type TrinoConfig struct { + HTTPPort int32 `json:"httpPort"` + // Empty means no external reference; nonempty resolves catalogs.json in the CR namespace. + CatalogConfigMapName string `json:"catalogConfigMapName"` +} + +// TrinoFacts is already-resolved group data. The generator performs no API reads. +type TrinoFacts struct { + Catalogs map[string]map[string]string `json:"catalogs"` +} + +// TrinoDefinition is a test-only adapter for the existing Trino validation slice. +// Runtime operator adoption remains U04; this fixture imports no prototype package. +func TrinoDefinition() framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts] { + defaults := framework.Config[TrinoConfig]{ + Common: CommonConfig{ + GracefulShutdownTimeout: metav1.Duration{Duration: 30 * time.Second}, + Resources: Resources{ + CPU: CPU{Min: resource.MustParse("500m"), Max: resource.MustParse("2")}, + Memory: Memory{Limit: resource.MustParse("1536Mi")}, + }, + Logging: Logging{EnableVectorAgent: true, Containers: map[string]ContainerLogging{ + trinoName: {Console: Logger{Level: trinoOffLevel}, File: Logger{Level: trinoTraceLevel}, + Loggers: map[string]Logger{trinoRootLogger: {Level: trinoInfoLevel}, trinoLoggerName: {Level: trinoInfoLevel}}}, + }}, + }, + Product: TrinoConfig{HTTPPort: 8080}, + } + return framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + ClusterConfigDefaults: TrinoClusterConfig{NodeEnvironment: "design_validation"}, + ImageDefaults: ImageConfig{Repo: "quay.io/zncdatadev", ProductVersion: "476", + KubedoopVersion: "0.0.0-dev", PullPolicy: corev1.PullIfNotPresent}, + Name: trinoName, Roles: map[string]framework.RoleDefinition[TrinoConfig]{ + trinoCoordinatorRole: {Config: defaults, + RoleConfig: RoleConfig{PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: 0}}}, + trinoWorkerRole: {Config: defaults, + RoleConfig: RoleConfig{PodDisruptionBudget: PodDisruptionBudgetConfig{Enabled: true, MaxUnavailable: 1}}}, + }, + ValidateInput: validateTrinoInput, GenerateGroup: generateTrino, GenerateCluster: trinoDiscovery, + ValidateFinal: validateTrinoFinal, + } +} + +func validateTrinoInput(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) error { + if !trinoEnvironmentPattern.MatchString(in.ClusterConfig.NodeEnvironment) { + return fmt.Errorf("clusterConfig.nodeEnvironment must match [a-z0-9][_a-z0-9]*") + } + if in.Config.Product.HTTPPort < 1 || in.Config.Product.HTTPPort > 65535 { + return fmt.Errorf("httpPort must be between 1 and 65535") + } + coordinators := 0 + for _, group := range in.Topology { + if group.Group.Role == trinoCoordinatorRole { + coordinators++ + if group.Group.Replicas != 1 { + return fmt.Errorf("this Trino example requires one coordinator replica") + } + } + } + if coordinators != 1 { + return fmt.Errorf("this Trino example requires one coordinator group") + } + if _, err := resolveTrinoLogging(in.Config.Common.Logging); err != nil { + return err + } + return ValidateTrinoCatalogs(in.Facts.Catalogs) +} + +// ValidateTrinoCatalogs is shared by external resolution and the pure generator. +// It validates the supported catalog shape, not plugin availability or credentials. +func ValidateTrinoCatalogs(catalogs map[string]map[string]string) error { + for _, name := range sortedKeys(catalogs) { + if !relativeFile(name) || strings.Contains(name, "/") || !utf8.ValidString(name) { + return fmt.Errorf("catalog name must be a single valid file component") + } + values := catalogs[name] + if values == nil || strings.TrimSpace(values["connector.name"]) == "" { + return fmt.Errorf("catalog requires a nonempty connector.name") + } + for key, value := range values { + if key == "" || !utf8.ValidString(key) || !utf8.ValidString(value) { + return fmt.Errorf("catalog property keys must be nonempty and properties must contain valid UTF-8") + } + } + } + return nil +} + +func trinoCoordinatorURI(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) (string, error) { + for _, group := range in.Topology { + if group.Group.Role != trinoCoordinatorRole { + continue + } + if group.Error != "" || group.Config == nil { + return "", fmt.Errorf("coordinator input unavailable: %s", group.Error) + } + return fmt.Sprintf("http://%s:%d", group.Group.ServiceDNS(), group.Config.Product.HTTPPort), nil + } + return "", fmt.Errorf("coordinator input unavailable: no coordinator group") +} + +func trinoFile(name string, values map[string]PropertyValue) File { + return File{Directory: trinoConfigDirectory, Path: name, + Content: KeyValues{Codec: PropertiesCodec{}, Values: values}} +} + +func generateTrino(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) ( + RuntimeDescription, error, +) { + uri, err := trinoCoordinatorURI(in) + if err != nil { + return RuntimeDescription{}, err + } + // This is the previous deployment fixture's 75% policy, not sizing guidance. + bytes, exact := in.Config.Common.Resources.Memory.Limit.AsInt64() + heapMi := (bytes / (1024 * 1024)) * 3 / 4 + if !exact || heapMi <= 0 { + return RuntimeDescription{}, fmt.Errorf("memory must yield a positive integral fixture heap") + } + uid, nonRoot := int64(1001), true // This fixture's execution identity, not a universal image default. + configAccess := DirectoryAccess{Directory: trinoConfigDirectory, MountPath: "/etc/trino", ReadOnly: true} + dataAccess := DirectoryAccess{Directory: trinoDataDirectory, MountPath: "/var/trino/data"} + logAccess := DirectoryAccess{Directory: trinoLogDirectory, MountPath: "/kubedoop/log/trino"} + logFile := trinoServerLog + logging, err := resolveTrinoLogging(in.Config.Common.Logging) + if err != nil { + return RuntimeDescription{}, err + } + configuration := map[string]PropertyValue{ + "coordinator": Literal(strconv.FormatBool(in.Group.Role == trinoCoordinatorRole)), + "http-server.http.port": Literal(strconv.Itoa(int(in.Config.Product.HTTPPort))), + "discovery.uri": Literal(uri), + "log.enable-console": Literal(strconv.FormatBool(logging.Console)), + } + if logging.File { + configuration["log.path"] = Literal(path.Join(logAccess.MountPath, logFile)) + configuration["log.format"] = Literal("JSON") + } + r := RuntimeDescription{ + ConfigDirectory: trinoConfigDirectory, + Main: Process{ + Name: trinoName, + Command: []string{"/kubedoop/trino-server/bin/launcher"}, + Args: []string{"--etc-dir=" + configAccess.MountPath, trinoRunArgument}, + Identity: &corev1.SecurityContext{ + RunAsUser: &uid, RunAsGroup: &uid, RunAsNonRoot: &nonRoot, + }, + Access: []DirectoryAccess{configAccess, dataAccess, logAccess}, + }, + Directories: []Directory{{Name: trinoConfigDirectory}, {Name: trinoDataDirectory}, {Name: trinoLogDirectory}}, + SharedGroup: &uid, + Endpoints: []Endpoint{{Name: trinoHTTPEndpoint, Port: in.Config.Product.HTTPPort}}, + Files: []File{ + trinoFile("config.properties", configuration), + {Directory: trinoConfigDirectory, Path: trinoJVMFile, Content: Lines{fmt.Sprintf("-Xmx%dm", heapMi)}}, + trinoFile(trinoNodeFile, map[string]PropertyValue{ + trinoNodeIDKey: PodNameBinding{}, trinoEnvironmentKey: Literal(in.ClusterConfig.NodeEnvironment), + "node.data-dir": Literal(dataAccess.MountPath), + }), + }, + } + if logging.File { + r.LogOutputs = []LogOutput{{Container: trinoName, Directory: logAccess.Directory, RelativePath: logFile}} + } + r.Files = append(r.Files, trinoFile("log.properties", logging.Levels)) + for _, name := range sortedKeys(in.Facts.Catalogs) { + if !relativeFile(name) || strings.Contains(name, "/") { + return RuntimeDescription{}, fmt.Errorf("catalog name must be a single file component: %q", name) + } + values := map[string]PropertyValue{} + for key, value := range in.Facts.Catalogs[name] { + values[key] = Literal(value) + } + r.Files = append(r.Files, trinoFile("catalog/"+name+".properties", values)) + } + return r, nil +} + +// Shared output is explicit about waiting versus withdrawing all resources. +func trinoDiscovery(in framework.ClusterOutputInput[TrinoClusterConfig, TrinoFacts]) (ClusterOutput, error) { + for _, group := range in.Groups { + if group.Group.Role != trinoCoordinatorRole || group.Error != "" { + continue + } + if group.Facts != nil && group.Facts.State != FactsResolved { + return ClusterOutput{State: ClusterOutputPending, Reason: "coordinator facts unavailable"}, nil + } + for _, endpoint := range group.GeneratedEndpoints { + if endpoint.Name == trinoHTTPEndpoint { + return ClusterOutput{State: ClusterOutputReady, ConfigMaps: []corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{Name: in.Cluster.Name + "-discovery", Namespace: in.Cluster.Namespace}, + Data: map[string]string{"TRINO_URI": fmt.Sprintf("http://%s:%d", group.Group.ServiceDNS(), endpoint.Port)}, + }}}, nil + } + } + } + return ClusterOutput{}, fmt.Errorf("coordinator generated HTTP endpoint unavailable") +} diff --git a/internal/framework/pipeline/trino_logging_fixture_test.go b/internal/framework/pipeline/trino_logging_fixture_test.go new file mode 100644 index 00000000..3666cf56 --- /dev/null +++ b/internal/framework/pipeline/trino_logging_fixture_test.go @@ -0,0 +1,91 @@ +package pipeline + +import ( + "fmt" + "strings" +) + +const ( + trinoRootLogger = "ROOT" + trinoOffLevel = "OFF" + trinoTraceLevel = "TRACE" + trinoDebugLevel = "DEBUG" + trinoErrorLevel = "ERROR" + trinoWarnLevel = "WARN" + trinoLoggerName = "io.trino" +) + +type trinoLoggingPlan struct { + Console, File bool + Levels map[string]PropertyValue +} + +// Airlift 336 routes both handlers through JUL logger levels. A single enabled +// sink, or two sinks with the same threshold, can be represented by clamping +// each explicit logger (including ROOT). Unequal enabled thresholds cannot. +// ROOT is the framework spelling; Airlift's native root property key is empty. +func resolveTrinoLogging(logging Logging) (trinoLoggingPlan, error) { + var plan trinoLoggingPlan + for _, container := range sortedKeys(logging.Containers) { + if container != trinoName { + return plan, fmt.Errorf("unsupported log producer %q", container) + } + } + config, exists := logging.Containers[trinoName] + if !exists { + return plan, fmt.Errorf("config.logging.containers.trino is required") + } + console, err := trinoLogRank(config.Console.Level) + if err != nil { + return plan, fmt.Errorf("config.logging.containers.trino.console.level: %w", err) + } + file, err := trinoLogRank(config.File.Level) + if err != nil { + return plan, fmt.Errorf("config.logging.containers.trino.file.level: %w", err) + } + plan.Console, plan.File = config.Console.Level != trinoOffLevel, config.File.Level != trinoOffLevel + if plan.Console && plan.File && console != file { + return plan, fmt.Errorf("trino cannot represent different active console.level and file.level thresholds; " + + "use equal thresholds or set one sink to OFF") + } + threshold := file + if plan.Console { + threshold = console + } + plan.Levels = make(map[string]PropertyValue, len(config.Loggers)) + for _, name := range sortedKeys(config.Loggers) { + if strings.TrimSpace(name) == "" { + return trinoLoggingPlan{}, fmt.Errorf("trino logger name is empty; use ROOT for the root logger") + } + level := config.Loggers[name].Level + rank, err := trinoLogRank(level) + if err != nil { + return trinoLoggingPlan{}, fmt.Errorf("unsupported Trino logger level for %q: %w", name, err) + } + if rank < threshold { + level = config.File.Level + if plan.Console { + level = config.Console.Level + } + } + key := name + if key == trinoRootLogger { + key = "" + } + plan.Levels[key] = Literal(level) + } + if _, exists := plan.Levels[""]; !exists { + return trinoLoggingPlan{}, fmt.Errorf("config.logging.containers.trino.loggers.ROOT is required") + } + return plan, nil +} + +func trinoLogRank(level string) (int, error) { + levels := []string{trinoTraceLevel, trinoDebugLevel, trinoInfoLevel, trinoWarnLevel, trinoErrorLevel, trinoOffLevel} + for rank, candidate := range levels { + if level == candidate { + return rank, nil + } + } + return 0, fmt.Errorf("unsupported level %q", level) +} diff --git a/internal/framework/pipeline/vector_assembly.go b/internal/framework/pipeline/vector_assembly.go new file mode 100644 index 00000000..783a4786 --- /dev/null +++ b/internal/framework/pipeline/vector_assembly.go @@ -0,0 +1,109 @@ +package pipeline + +import ( + "fmt" + "path" + "reflect" + "strings" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +const vectorTypeKey = "type" + +// composeVector consumes the framework logging gate. Products declare actual +// files; every declared output is collected when enabled. Platform files join +// declarations before file overrides. +func composeVector(in RuntimeDescription, enabled bool, options AssemblyOptions) ( + RuntimeDescription, *corev1.Container, error, +) { + r := CloneRuntime(in) + if !enabled { + return r, nil, nil + } + sources := map[string]any{} + inputs := []string{} + paths := map[string]string{} + for index, output := range r.LogOutputs { + mount := path.Join("/logs", output.Directory) + paths[output.Directory] = mount + name := fmt.Sprintf("source_%d", index) + inputs = append(inputs, name) + sources[name] = map[string]any{vectorTypeKey: "file", "include": []string{path.Join(mount, output.RelativePath)}, + "read_from": "beginning"} + } + if len(inputs) == 0 { + return r, nil, nil + } + if r.ConfigDirectory == "" { + return r, nil, fmt.Errorf("log collection requires an explicit config directory") + } + if strings.TrimSpace(options.VectorImage) == "" { + return r, nil, fmt.Errorf("vector image is required when collection is enabled") + } + if r.Main.Name == vectorContainerName { + return r, nil, fmt.Errorf("main container conflicts with Vector name") + } + const dataDirectory = "vector-data" + const dataPath = "/var/lib/vector" + sink := map[string]any{vectorTypeKey: "console", "inputs": inputs, "target": "stdout", + "encoding": map[string]any{"codec": "json"}} + if options.VectorDestination != nil { + if err := options.VectorDestination.Validate(); err != nil { + return r, nil, fmt.Errorf("vector destination: %w", err) + } + sink = map[string]any{vectorTypeKey: "vector", "inputs": inputs, "address": options.VectorDestination.Address} + } + config, err := yaml.Marshal(map[string]any{"data_dir": dataPath, "sources": sources, + "sinks": map[string]any{"collected": sink}}) + if err != nil { + return r, nil, err + } + r.Directories = append(r.Directories, Directory{Name: dataDirectory}) + r.Files = append(r.Files, File{Directory: r.ConfigDirectory, Path: vectorConfigFile, Content: Text(config)}) + vector := &corev1.Container{Name: vectorContainerName, Image: options.VectorImage, + Command: []string{vectorContainerName}, + Args: []string{"--config", path.Join(vectorConfigPath, vectorConfigFile)}, + SecurityContext: options.HelperIdentity.DeepCopy(), + VolumeMounts: []corev1.VolumeMount{{Name: r.ConfigDirectory, MountPath: vectorConfigPath, ReadOnly: true}, + {Name: dataDirectory, MountPath: dataPath}}} + if options.VectorDestination != nil { + // A changed discovery address must reach the running collector even + // without a separate platform restarter. The literal env value is a + // template trigger; generated YAML remains the only configuration source. + vector.Env = []corev1.EnvVar{{Name: "FRAMEWORK_VECTOR_DESTINATION", Value: options.VectorDestination.Address}} + } + for _, directory := range sortedKeys(paths) { + vector.VolumeMounts = append(vector.VolumeMounts, corev1.VolumeMount{ + Name: directory, MountPath: paths[directory], ReadOnly: true}) + } + return r, vector, nil +} + +// collectorKnown is a platform premise exposed to product final validation. +// A product needs the premise, not the helper container names or volume layout. +func collectorKnown(expected, actual GroupResources, generated RuntimeDescription, files []File) bool { + if !filePreparationKnown(expected, actual, files) { + return false + } + beforePod, afterPod := expected.StatefulSet.Spec.Template, actual.StatefulSet.Spec.Template + before, after := findContainer(beforePod, vectorContainerName), findContainer(afterPod, vectorContainerName) + // A user-added container is not evidence of a framework-selected collector. + if before == nil || generated.Main.Name == vectorContainerName { + return false + } + if !sameContainerExecution(before, after) || !reflect.DeepEqual(before.VolumeMounts, after.VolumeMounts) || + shadowsFile(after, vectorConfigPath, vectorConfigFile) { + return false + } + for index := range before.VolumeMounts { + mount := &before.VolumeMounts[index] + if !sameMount(beforePod.Spec, afterPod.Spec, mount, findMount(after, mount.MountPath)) { + return false + } + } + original := findFile(generated.Files, generated.ConfigDirectory, vectorConfigFile) + file := findFile(files, generated.ConfigDirectory, vectorConfigFile) + return original != nil && file != nil && reflect.DeepEqual(original.Content, file.Content) +} diff --git a/internal/framework/pipeline/vector_assembly_test.go b/internal/framework/pipeline/vector_assembly_test.go new file mode 100644 index 00000000..b440cea2 --- /dev/null +++ b/internal/framework/pipeline/vector_assembly_test.go @@ -0,0 +1,204 @@ +package pipeline + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestVectorCentralDestinationAndRefresh(t *testing.T) { + options := assemblyBuildOptions() + options.VectorDestination = &framework.VectorDestination{Address: "receiver-a.logs.svc:6000"} + first, files, _, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(true), ResolvedImage{}, + assemblyRuntimeFixture(), GroupSource{}, options, nil) + if err != nil { + t.Fatal(err) + } + var config struct { + Sinks map[string]struct { + Type, Address string + Inputs []string + } `json:"sinks"` + } + file := findFile(files, "config", vectorConfigFile) + if file == nil { + t.Fatal("no generated Vector configuration") + } + if err := yaml.Unmarshal([]byte(file.Content.(Text)), &config); err != nil { + t.Fatal(err) + } + sink := config.Sinks["collected"] + if sink.Type != "vector" || sink.Address != options.VectorDestination.Address || len(sink.Inputs) == 0 { + t.Fatalf("discovery destination was not consumed: %+v", sink) + } + options.VectorDestination.Address = "receiver-b.logs.svc:6000" + second, _, _, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(true), ResolvedImage{}, + assemblyRuntimeFixture(), GroupSource{}, options, nil) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(first.StatefulSet.Spec.Template, second.StatefulSet.Spec.Template) || + reflect.DeepEqual(first.ConfigMap.Data, second.ConfigMap.Data) { + t.Fatal("discovery refresh did not change both materialized configuration and workload template") + } + if findContainer(first.StatefulSet.Spec.Template, vectorContainerName).Env[0].Value != "receiver-a.logs.svc:6000" { + t.Fatal("a later resolved destination mutated a previously built plan") + } +} + +func TestVectorFrameworkGateAndActualOutputs(t *testing.T) { + for _, tc := range []struct { + name string + enabled, hasOutput bool + image string + wantCollector bool + wantError bool + }{ + {"disabled-with-output", false, true, "", false, false}, + {"disabled-without-output", false, false, "", false, false}, + {"enabled-without-output", true, false, "", false, false}, + {"enabled-missing-image", true, true, "", false, true}, + {"enabled-with-output", true, true, "example.invalid/vector:test", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + runtime := assemblyRuntimeFixture() + if !tc.hasOutput { + runtime.LogOutputs = nil // The native file sink is OFF; there is no actual file declaration. + runtime.ConfigDirectory, runtime.Files, runtime.Directories, runtime.Main.Access = "", nil, nil, nil + } + original := CloneRuntime(runtime) + options := assemblyBuildOptions() + options.VectorImage = tc.image + if !tc.hasOutput { + options.MaterializerImage = "" + } + var collectionKnown bool + resources, files, checks, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(tc.enabled), ResolvedImage{}, + runtime, GroupSource{}, options, func(view FinalView) []Check { + collectionKnown = view.LogCollectionKnown + return nil + }) + if (err != nil) != tc.wantError { + t.Fatalf("unexpected build result: %v", err) + } + if tc.wantError { + if resources != nil || !strings.Contains(err.Error(), "vector image") { + t.Fatalf("missing collector image should fail the group: %+v %v", resources, err) + } + return + } + collector := findContainer(resources.StatefulSet.Spec.Template, vectorContainerName) + config := findFile(files, runtime.ConfigDirectory, vectorConfigFile) + if (collector != nil) != tc.wantCollector || (config != nil) != tc.wantCollector || + collectionKnown != tc.wantCollector || !reflect.DeepEqual(runtime, original) { + t.Fatalf("framework selection or declaration isolation failed: collector=%v config=%v known=%v", + collector != nil, config != nil, collectionKnown) + } + if !tc.hasOutput && len(resources.StatefulSet.Spec.Template.Spec.InitContainers) != 0 { + t.Fatal("no files or outputs should not introduce a preparation process") + } + for _, check := range checks { + if !tc.wantCollector && strings.HasPrefix(check.Subject, "vector.") { + t.Fatalf("unselected collector acquired a proof: %+v", check) + } + } + }) + } +} + +func TestVectorCollectsEveryDeclaredFile(t *testing.T) { + runtime := assemblyRuntimeFixture() + runtime.LogOutputs = append(runtime.LogOutputs, + LogOutput{Container: "trino", Directory: "logs", RelativePath: "audit/events.json"}) + generated, collector, err := composeVector(runtime, true, assemblyBuildOptions()) + if err != nil { + t.Fatal(err) + } + config := findFile(generated.Files, generated.ConfigDirectory, vectorConfigFile) + var content struct { + Sources map[string]struct { + Include []string `json:"include"` + } `json:"sources"` + } + if err := yaml.Unmarshal([]byte(config.Content.(Text)), &content); err != nil { + t.Fatal(err) + } + if len(content.Sources) != 2 || + !slices.Equal(content.Sources["source_0"].Include, []string{"/logs/logs/server.json"}) || + !slices.Equal(content.Sources["source_1"].Include, []string{"/logs/logs/audit/events.json"}) { + t.Fatalf("framework did not collect every actual declaration: %+v", content) + } + if collector == nil || findMount(collector, "/logs/logs") == nil || len(collector.VolumeMounts) != 3 { + t.Fatal("outputs from the same directory should share its collector mount") + } + second, secondCollector, err := composeVector(runtime, true, assemblyBuildOptions()) + if err != nil || !reflect.DeepEqual(generated, second) || !reflect.DeepEqual(collector, secondCollector) { + t.Fatal("Vector composition is not deterministic") + } +} + +func TestVectorUnselectedCannotBeClaimedByUserContainer(t *testing.T) { + source := GroupSource{Overrides: &Overrides{PodOverrides: json.RawMessage(`{"spec":{"containers":[ + {"name":"vector","image":"example.invalid/custom:test","command":["custom"]}]}}`)}} + called := false + resources, _, _, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(false), ResolvedImage{}, + assemblyRuntimeFixture(), source, AssemblyOptions{MaterializerImage: "example.invalid/materializer:test"}, + func(view FinalView) []Check { + called = true + if view.LogCollectionKnown { + t.Fatal("a user-supplied container impersonated the unselected framework collector") + } + return nil + }) + if err != nil || !called || findContainer(resources.StatefulSet.Spec.Template, vectorContainerName) == nil { + t.Fatalf("custom Pod patch must survive without gaining a platform premise: %v", err) + } +} + +func TestVectorRemovedOrChangedCollectorIsUnknown(t *testing.T) { + for _, removed := range []bool{false, true} { + expected, generated := assemblyCheckFixture(t) + actual := cloneGroupResources(expected) + if removed { + actual.StatefulSet.Spec.Template.Spec.Containers = slices.DeleteFunc( + actual.StatefulSet.Spec.Template.Spec.Containers, func(container corev1.Container) bool { + return container.Name == vectorContainerName + }) + } else { + findContainer(actual.StatefulSet.Spec.Template, vectorContainerName).Command = []string{"custom"} + } + if collectorKnown(expected, actual, generated, generated.Files) { + t.Fatal("changed collector retained its modeled premise") + } + requireAssemblyCheck(t, CheckAssembly(expected, actual, generated, generated.Files), "vector.config", Unknown) + } +} + +func TestVectorUnselectedMainNameDoesNotSelectCollector(t *testing.T) { + runtime := assemblyRuntimeFixture() + runtime.Main.Name = vectorContainerName + runtime.LogOutputs[0].Container = vectorContainerName + called := false + resources, _, checks, err := buildGroup(assemblyGroupIdentity(), assemblyCommon(false), ResolvedImage{}, + runtime, GroupSource{}, assemblyBuildOptions(), func(view FinalView) []Check { + called = true + if view.LogCollectionKnown { + t.Fatal("the main process name cannot select the framework collector") + } + return nil + }) + if err != nil || resources == nil || !called { + t.Fatalf("unselected helper name should not prevent a valid main process: %v", err) + } + for _, check := range checks { + if strings.HasPrefix(check.Subject, "vector.") { + t.Fatalf("an unselected collector gained a relationship check: %+v", check) + } + } +} diff --git a/pkg/AGENTS.md b/pkg/AGENTS.md index 45d5011b..b3bcf6e8 100644 --- a/pkg/AGENTS.md +++ b/pkg/AGENTS.md @@ -16,6 +16,7 @@ Every directory under `pkg/`: | `common/` | Framework interfaces: `ClusterInterface` / `ClusterResource[T]`, the extension system and its per-CR-type `ExtensionRegistry[CR]`, `ServiceHealthCheck`, shared error types | | | `config/` | Config-file serialization (XML/Properties/YAML/Env/INI) and the layered override merge | [config/AGENTS.md](config/AGENTS.md) | | `constant/` | Kubedoop path, label, domain and restarter constants (`KubedoopMountDir`, `KubedoopSecretDir`, …) | | +| `framework/` | New framework domain contracts, raw input/schema/registration generator and public operator registration; execution stays SDK-internal | [framework/AGENTS.md](framework/AGENTS.md) | | `listener/` | Listener CSI volume registration and `ListenerProvisioner` | | | `productlogging/` | Product logging config generation (Log4j, Log4j2, Logback, Python) and `ContainerLogging` | | | `reconciler/` | `GenericReconciler` framework, handlers, cleaner, health, dependencies, apply semantics | [reconciler/AGENTS.md](reconciler/AGENTS.md) | @@ -29,6 +30,11 @@ Every directory under `pkg/`: ## Package Boundaries +The existing-SDK boundaries and working instructions below apply to the old +packages. New-framework work under `framework/` follows its own AGENTS and +[core specification](../docs/architecture.md#framework-design); it does not add hooks or merge +semantics to the old reconciler. + - **`pkg/common` holds interfaces, `pkg/reconciler` holds the loop.** A type that both the SDK and a product implement belongs in `common`; anything that talks to the API server on the reconcile path belongs in `reconciler`. diff --git a/pkg/framework/AGENTS.md b/pkg/framework/AGENTS.md new file mode 100644 index 00000000..b50638ae --- /dev/null +++ b/pkg/framework/AGENTS.md @@ -0,0 +1,128 @@ +# New framework contracts and generated input + +**Parent:** [../AGENTS.md](../AGENTS.md) + +Design intent: [core specification](../../docs/architecture.md#framework-design). +This subtree implements public contracts and generated registration. The pure resource pipeline lives +in [internal/framework/pipeline](../../internal/framework/pipeline/AGENTS.md); +the internal controller is reached only through framework/operator registration. + +## Existing packages + +| Package | Current responsibility | +| --- | --- | +| `framework` | ProductDefinition, EffectiveInput, RuntimeDescription and their domain values; facts/read interface, diagnostics/status, shared output states, PropertiesCodec | +| `framework/logging` | Pure Python stdlib dictConfig adapter consuming effective container logging; actual native-process tests | +| `framework/input` | Versioned generated Binding, raw Projection, presence input types, strict decoding, raw config projection and generated-data copying | +| `framework/inputgen` | Generate presence API/CRD and optional registration companions; Check all requested artifacts | +| `framework/dataops` | Independent retained data identity, approved data operation protocol and separate executor registration | +| `framework/operator` | Validate/freeze deployment registration and install the internal direct-client controller; no public mutable reconciler | + +The root package imports only standard-library and Kubernetes packages. It does +not import input, inputgen, old SDK execution packages or the discussion prototype. +The input package depends on the root contracts; inputgen uses both. No public +Source, Prepared, Plan, merge function or mutable Reconciler is provided here. + +## Product contracts + +`ProductDefinition[C,S,F]` uses concrete effective C/S/F data. `GenerateCluster` +returns `(ClusterOutput, error)`: Ready supplies a complete ConfigMap inventory, +including an empty withdrawal; Pending requires a reason and no partial output. +`ValidateClusterOutput` checks only this data/state contract. The +[internal controller](../../internal/framework/controller/AGENTS.md) validates +resource ownership and reconciles the complete shared output set, preserving old +outputs when generation is Pending or fails. + +`LogOutput` declares actual files and has no Collect field. The internal pipeline +consumes effective `Logging.EnableVectorAgent` and composes Vector only when enabled +and there are actual outputs. Helper images are explicit deployment inputs; the +[Trino reference executable](../../examples/trino-operator/README.md) uses generated +registration. Live-workload acceptance is recorded separately from API tests. +`FactsReader.Get` takes a +`types.NamespacedName` and `FactResource` (`metav1.Object` + `runtime.Object`), +without importing or exposing a writable controller-runtime client. + +`FileContent` and `PropertyValue` are closed value variants. `PropertyCodec` is a +pure encoder seam; the included PropertiesCodec writes deterministic UTF-8 data +and rejects invalid UTF-8. A custom Go codec is not a runtime helper capability. +Status uses its own DeepCopy methods, without an inverse dependency on input. + +`Resources.Storage` is the standard ephemeral/persistent data-storage domain. +`Directory.Data` binds one runtime data directory to that effective configuration; +class/capacity are not repeated in RuntimeDescription. Other directories default to ephemeral; +SecretVolume and ListenerVolume explicitly select read-only platform sources. +The internal physical retained-slot representation is not a product API. + +## Generated input contract + +`input.ContractVersion` is 1, independent of SDK release versions. Generated Go +source records `InputContractVersion`; Check validates it before byte comparison. +`input.CheckVersion` rejects incompatible versions. The companion checks its own version and operator registration validates the generated binding version. + +`input.Binding[CR]` contains Version, Roles, AddToScheme, NewObject, Operation, +Project and Status. Generated `Binding()` creates a new role slice on each call. +The constraint requires Kubernetes object interfaces, not a product-specific CR base. + +`Projection` contains Cluster identity/labels, raw Image, raw ClusterConfig containing +framework platform fields plus product fields, and all declared Roles. Each Role has raw Config, role-only RoleConfig, optional +Replicas, Overrides and Groups. Each Group holds its own raw layer and optional +replicas. Projection does not fold defaults/replicas, duplicate role layers into +groups, hold F, resolve config, or include an execution plan. Empty roles survive. +`Operation(cr)` reads fixed controls separately; Project strips operation fields from +ClusterConfig. The pipeline separates framework platform inputs from product S before folding. + +`DecodeJSON` validates exact field names, duplicate keys, non-null ordinary input +and native Quantity/Duration/Affinity shapes before assigning a fresh result. +Failure leaves the caller's destination unchanged. Pod RawMessage permits native +null/$patch but still rejects duplicate keys on local Decode. API pruning is a +separate behavior: normal typed GET follows API-server validation, not local Decode. + +Project is for inputs from strict Decode or typed API reads. It marshals config +presence and copies override channels; it is not a replacement for final override +or resource validation. Clone copies only the generated data profile, not arbitrary +Go program state. Kubernetes metadata and status use dedicated copy methods. + +## Generation and checks + +`inputgen.Generate[C,S](Names, roles)` returns GoSource, CRD and optional RegistrationSource. It checks exported +root types/`struct{}`, fixed product type profile, field/name collisions and roles. +The generated API exposes Decode, Operation, Project and Binding; it does not +import original product config types or a controller. Nonempty `Names.ImportPath` +emits a separate typed registration companion, leaving C/S fixed and F inferred. + +The CRD has no inherited defaults. Config collection limits remain 32 and native +Affinity collections 16; status lists are not constrained to those config limits. +Generate/Check do not write files; the caller owns its generator command and IO. + +`inputgen/integration_test.go` builds a temporary `example.com/framework-consumer` +module from testdata using this checkout via replace. It generates two CRDs, +compiles external product/binding/registration code and exercises an envtest API +and a real manager with dependency refresh through the generated entry point. This is not +a new product case or a new SDK module. An explicit invalid KUBEBUILDER_ASSETS +fails; API tests can skip only when no assets were supplied or found. Full root +`make test` supplies assets, so a local skip is not accepted as API verification. + +Run focused tests with `go test ./pkg/framework/...`; use the root `make lint` +and `make test GOTESTFLAGS='-p=2'` before committing. Changes to the generator must +also pass the external consumer test, not only source-string assertions. + +`ResolveVectorDestination` resolves the standard same-namespace ConfigMap ADDRESS +through FactsReader. Missing objects are Pending, invalid addresses Invalid, and +API read errors stay errors. Controller-owned observations carry UID/RV. The +internal controller selects this dependency only after a generated runtime has +actual log outputs and effective Vector selection is enabled. `AssemblyOptions` +carries the resolved address into native Vector sink generation. + +`Process` now declares native lifecycle/probes. `RuntimeDescription.Initializers` runs in order +after materialization, with explicit per-process directory access. `Coordination` supplies bounded +progress and shutdown priority; the controller persists observations and serializes scale-down. + +`ResolveAuthenticationClass` reads the exact cluster-scoped platform class and returns a +single typed provider branch with references only. Product adapters explicitly select +supported providers; this parse result never asserts login or external provider health. + +`ClusterConfig` owns authentication references and Vector destination references +alongside the separate operation controls and product S. EffectiveInput.Platform +and FactInput.Platform carry that common half. Directory.Secret/Listener represent +platform sources; GroupOutcome.Platform and GroupReconcileStatus.Platform carry +post-creation observations and Listener addresses. They do not expose credentials. diff --git a/pkg/framework/authentication.go b/pkg/framework/authentication.go new file mode 100644 index 00000000..ee1fc6b7 --- /dev/null +++ b/pkg/framework/authentication.go @@ -0,0 +1,127 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + kubernetesjson "sigs.k8s.io/json" +) + +// AuthenticationClassProvider is the platform's provider union. Resolving a class +// selects exactly one branch; products explicitly accept the providers they can +// consume. Credential references are returned, never credential bytes. +type AuthenticationClassProvider struct { + Static *StaticAuthentication `json:"static,omitempty"` + OIDC *OIDCAuthentication `json:"oidc,omitempty"` + TLS *TLSAuthentication `json:"tls,omitempty"` + LDAP *LDAPAuthentication `json:"ldap,omitempty"` + Kerberos *KerberosAuthentication `json:"kerberos,omitempty"` +} + +type NamedSecret struct { + Name string `json:"name"` +} +type StaticAuthentication struct { + UserCredentialsSecret *NamedSecret `json:"userCredentialsSecret"` +} +type TLSAuthentication struct { + ClientCertSecretClass string `json:"clientCertSecretClass,omitempty"` +} +type KerberosAuthentication struct { + KerberosStorageClass string `json:"kerberosStorageClass,omitempty"` +} +type ProviderTLS struct { + Verification *ProviderTLSVerification `json:"verification"` +} +type ProviderTLSVerification struct { + None *struct{} `json:"none,omitempty"` + Server *ProviderServerVerification `json:"server,omitempty"` +} +type ProviderServerVerification struct { + CACert *ProviderCA `json:"caCert"` +} +type ProviderCA struct { + SecretClass string `json:"secretClass,omitempty"` + WebPKI *struct{} `json:"webPki,omitempty"` +} +type OIDCAuthentication struct { + Hostname string `json:"hostname"` + Port int32 `json:"port,omitempty"` + PrincipalClaim string `json:"principalClaim"` + ProviderHint string `json:"providerHint"` + RootPath string `json:"rootPath,omitempty"` + Scopes []string `json:"scopes,omitempty"` + TLS *ProviderTLS `json:"tls,omitempty"` +} +type ProviderCredentials struct { + SecretClass string `json:"secretClass"` +} +type LDAPAuthentication struct { + BindCredentials *ProviderCredentials `json:"bindCredentials"` + Hostname string `json:"hostname"` + Port int32 `json:"port,omitempty"` + FieldNames map[string]string `json:"ldapFieldNames,omitempty"` + SearchBase string `json:"searchBase,omitempty"` + SearchFilter string `json:"searchFilter,omitempty"` + TLS *ProviderTLS `json:"tls,omitempty"` +} + +// ResolveAuthenticationClass performs one exact cluster-scoped read. The result +// is platform configuration, not an assertion that a provider or user is ready. +func ResolveAuthenticationClass(ctx context.Context, reader FactsReader, name string) (FactResult[AuthenticationClassProvider], error) { + if len(validation.IsDNS1123Subdomain(name)) != 0 { + return authenticationFailure(FactsInvalid, "InvalidAuthenticationReference"), nil + } + object := &unstructured.Unstructured{} + object.SetGroupVersionKind(schema.GroupVersionKind{Group: "authentication.kubedoop.dev", Version: "v1alpha1", Kind: "AuthenticationClass"}) + if err := reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil { + if apierrors.IsNotFound(err) { + return authenticationFailure(FactsPending, "AuthenticationClassMissing"), nil + } + return FactResult[AuthenticationClassProvider]{}, err + } + if !object.GetDeletionTimestamp().IsZero() { + return authenticationFailure(FactsPending, "AuthenticationClassDeleting"), nil + } + provider, found, err := unstructured.NestedMap(object.Object, "spec", "provider") + if err != nil || !found || len(provider) != 1 { + return authenticationFailure(FactsInvalid, "InvalidAuthenticationProvider"), nil + } + data, err := json.Marshal(provider) + if err != nil { + return FactResult[AuthenticationClassProvider]{}, fmt.Errorf("encode authentication provider: %w", err) + } + var value AuthenticationClassProvider + strict, err := kubernetesjson.UnmarshalStrict(data, &value) + if err != nil || len(strict) != 0 || !validAuthenticationProvider(value) { + return authenticationFailure(FactsInvalid, "InvalidAuthenticationProvider"), nil + } + return FactResult[AuthenticationClassProvider]{Value: &value, Diagnostic: FactDiagnostic{ + State: FactsResolved, Reason: "AuthenticationClassResolved", Message: "Provider configuration resolved; authentication success is not observed"}}, nil +} + +func authenticationFailure(state FactState, reason string) FactResult[AuthenticationClassProvider] { + return FactResult[AuthenticationClassProvider]{Diagnostic: FactDiagnostic{State: state, Reason: reason, + Message: "Referenced authentication class is unavailable or has an invalid provider declaration"}} +} + +func validAuthenticationProvider(value AuthenticationClassProvider) bool { + switch { + case value.Static != nil: + return value.Static.UserCredentialsSecret != nil && len(validation.IsDNS1123Subdomain(value.Static.UserCredentialsSecret.Name)) == 0 + case value.OIDC != nil: + return value.OIDC.Hostname != "" && value.OIDC.PrincipalClaim != "" && value.OIDC.Port >= 0 && value.OIDC.Port <= 65535 + case value.LDAP != nil: + return value.LDAP.Hostname != "" && value.LDAP.Port >= 0 && value.LDAP.Port <= 65535 + case value.TLS != nil, value.Kerberos != nil: + return true + default: + return false + } +} diff --git a/pkg/framework/authentication_test.go b/pkg/framework/authentication_test.go new file mode 100644 index 00000000..2b89fb54 --- /dev/null +++ b/pkg/framework/authentication_test.go @@ -0,0 +1,57 @@ +package framework + +import ( + "context" + "encoding/json" + "strings" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +type authenticationReader struct { + object *unstructured.Unstructured + key types.NamespacedName + err error +} + +func (r *authenticationReader) Get(_ context.Context, key types.NamespacedName, into FactResource) error { + r.key = key + if r.err != nil { + return r.err + } + into.(*unstructured.Unstructured).Object = r.object.DeepCopy().Object + return nil +} + +func TestAuthenticationClassExactReadAndProviderSelection(t *testing.T) { + object := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{"provider": map[string]any{ + "static": map[string]any{"userCredentialsSecret": map[string]any{"name": "users"}}}}}} + reader := &authenticationReader{object: object} + result, err := ResolveAuthenticationClass(t.Context(), reader, "password-users") + if err != nil || result.Diagnostic.State != FactsResolved || result.Value.Static.UserCredentialsSecret.Name != "users" || + reader.key != (types.NamespacedName{Name: "password-users"}) { + t.Fatalf("wrong provider or scope: %+v %v", result, err) + } + encoded, _ := json.Marshal(result.Value) + if strings.Contains(string(encoded), "password.db") { + t.Fatal("resolver read provider credentials") + } + provider, _, _ := unstructured.NestedMap(object.Object, "spec", "provider") + provider["tls"] = map[string]any{} + if err := unstructured.SetNestedMap(object.Object, provider, "spec", "provider"); err != nil { + t.Fatal(err) + } + result, err = ResolveAuthenticationClass(t.Context(), reader, "password-users") + if err != nil || result.Diagnostic.State != FactsInvalid || result.Value != nil { + t.Fatal("multiple providers accepted") + } + reader.err = apierrors.NewNotFound(schema.GroupResource{Resource: "authenticationclasses"}, "password-users") + result, err = ResolveAuthenticationClass(t.Context(), reader, "password-users") + if err != nil || result.Diagnostic.State != FactsPending || result.Value != nil { + t.Fatal("missing class did not wait") + } +} diff --git a/pkg/framework/config.go b/pkg/framework/config.go new file mode 100644 index 00000000..76a59764 --- /dev/null +++ b/pkg/framework/config.go @@ -0,0 +1,118 @@ +package framework + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Config is the complete effective configuration passed to product code. Common +// and Product share one flat config object in the CR; these fields are not wire nesting. +type Config[C any] struct { + Common CommonConfig + Product C +} + +type CommonConfig struct { + Resources Resources `json:"resources"` + Logging Logging `json:"logging"` + Affinity corev1.Affinity `json:"affinity"` + GracefulShutdownTimeout metav1.Duration `json:"gracefulShutdownTimeout"` +} + +type Resources struct { + CPU CPU `json:"cpu"` + Memory Memory `json:"memory"` + Storage Storage `json:"storage"` +} + +// Storage is the standard data-directory storage domain. An empty product +// default selects ephemeral storage. User input must name a supported type. +// A type change discards the inherited branch; it never migrates live data. +type Storage struct { + Type StorageType `json:"type"` + StorageClassName string `json:"storageClassName"` + Capacity resource.Quantity `json:"capacity"` +} + +type StorageType string + +const ( + StorageEphemeral StorageType = "ephemeral" + StoragePersistent StorageType = "persistent" +) + +type CPU struct { + Min resource.Quantity `json:"min"` + Max resource.Quantity `json:"max"` +} + +type Memory struct { + Limit resource.Quantity `json:"limit"` +} + +type Logging struct { + EnableVectorAgent bool `json:"enableVectorAgent"` + Containers map[string]ContainerLogging `json:"containers,omitempty"` +} + +type ContainerLogging struct { + Console Logger `json:"console"` + File Logger `json:"file"` + Loggers map[string]Logger `json:"loggers,omitempty"` +} + +type Logger struct { + Level string `json:"level"` +} + +// RoleDefinition separates inherited workload defaults from management defaults +// consumed once for the entire role, including when it has no groups. +type RoleDefinition[C any] struct { + Config Config[C] + RoleConfig RoleConfig +} + +type RoleConfig struct { + PodDisruptionBudget PodDisruptionBudgetConfig `json:"podDisruptionBudget"` +} + +type PodDisruptionBudgetConfig struct { + Enabled bool `json:"enabled"` + MaxUnavailable int32 `json:"maxUnavailable"` +} + +// ImageConfig contains product defaults. Input presence and image resolution +// belong to the input boundary and internal pipeline, respectively. +type ImageConfig struct { + Custom string `json:"custom"` + Repo string `json:"repo"` + ProductVersion string `json:"productVersion"` + KubedoopVersion string `json:"kubedoopVersion"` + PullPolicy corev1.PullPolicy `json:"pullPolicy"` + PullSecretName string `json:"pullSecretName"` +} + +type ResolvedImage struct { + Reference string `json:"reference"` + PullPolicy corev1.PullPolicy `json:"pullPolicy"` + PullSecretName string `json:"pullSecretName"` +} + +// ClusterOperation is separate from product cluster configuration. Generated +// bindings read these fixed controls before full input projection or validation. +type ClusterOperation struct { + Stopped bool `json:"stopped"` + ReconciliationPaused bool `json:"reconciliationPaused"` +} + +// AssemblyOptions supplies deployment-owned helper images and identity. It is +// neither a capability registry nor an image builder or runtime-user detector. +type AssemblyOptions struct { + MaterializerImage string + VectorImage string + HelperIdentity *corev1.SecurityContext + // VectorDestination is supplied by the controller after resolving the + // standard cluster reference. Nil selects the local stdout JSON sink. + VectorDestination *VectorDestination +} diff --git a/pkg/framework/dataops/AGENTS.md b/pkg/framework/dataops/AGENTS.md new file mode 100644 index 00000000..b417fb41 --- /dev/null +++ b/pkg/framework/dataops/AGENTS.md @@ -0,0 +1,42 @@ +# Independent retained data protocol + +Design: [data-operation protocol](../../../docs/architecture.md#framework-data-operations). + +`DataAsset` is a namespaced CRD with an immutable original data/cluster identity, +current binding, retained migration copies and append-only operation history. +It is created by normal product reconciliation after exact binding observation, +not by stop/retirement read guards. It has no product owner reference. +`CheckHistory` blocks missing original PVCs, operation-locked assets and automatic +recreation of a group whose data identity has moved to another owner. + +`DataOperation` is a separate immutable intent and RBAC capability. Approval is +SHA256 of canonical Go JSON with an empty approval field, including actual source +and target CR identities, exact PVC/PV identities and non-root worker UID/GID. +The hash binds reviewed intent; Kubernetes RBAC authorizes creation. +`Register` installs a separate direct-client controller. The product operator +only creates/reads assets and never acquires operation execution permissions. + +The internal `reconciler` persists each phase, worker attempt, Job UID and worker completion +receipt. Jobs use an embedded fixed Python worker. Migration copies payload children without changing provisioner-owned mount-root metadata. It compares SHA256 +manifests before/after copying and after copying to the target. Adoption rebinds +the exact retained PV to a target-named PVC. Destruction verifies an empty tree, +removes the exact PVC, switches the exact PV to Delete with an operation receipt, +and waits for the actual provisioner to reclaim the backend and remove the PV. +It does not remove finalizers or pretend deleting a Retain PV deletes storage. + +Source/target CRs must be paused (or the original source UID gone), all their +StatefulSets retired, and all unrelated actual Pod consumers absent. Checks are +repeated on every phase. Failed Jobs remain with evidence; a new explicit +`framework.kubedoop.dev/data-retry` attempt requires the previous failed Pods to +be inspected/removed. Controller restarts continue persisted phases without +re-running completed jobs or creating replacement identities. + +CRDs/DeepCopy are generated by root `make generate manifests` into +`config/framework-data/bases` and this package. `make verify-generate` checks them. +`cmd/dataops` is the independent executor command. `hack/framework-e2e/verify-dataops.py` +uses only a fresh disposable namespace and formal Trino storage fixture. + +Tests: fake client state transitions, real local filesystem copy/erase, and +`TestDataProtocolAPIIdentityAndImmutableIntent` with a private envtest API server. +Fake provisioner completion is labelled explicitly; actual CSI/provisioner and +Pod filesystem proof belongs to the live harness. diff --git a/pkg/framework/dataops/api_test.go b/pkg/framework/dataops/api_test.go new file mode 100644 index 00000000..c7d496bf --- /dev/null +++ b/pkg/framework/dataops/api_test.go @@ -0,0 +1,84 @@ +package dataops + +import ( + "os" + "path/filepath" + goruntime "runtime" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func TestDataProtocolAPIIdentityAndImmutableIntent(t *testing.T) { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + assets = filepath.Join("..", "..", "..", "bin", "k8s", "1.35.0-"+goruntime.GOOS+"-"+goruntime.GOARCH) + } + existing := false + environment := &envtest.Environment{UseExistingCluster: &existing, BinaryAssetsDirectory: assets, CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "framework-data", "bases")}, ErrorIfCRDPathMissing: true} + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + config, err := environment.Start() + if err != nil { + t.Fatal(err) + } + scheme := runtime.NewScheme() + if err = corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err = AddToScheme(scheme); err != nil { + t.Fatal(err) + } + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "data-protocol"}} + if err = c.Create(t.Context(), namespace); err != nil { + t.Fatal(err) + } + _, op, asset := testController(t, ActionDestroy) + asset.Namespace = namespace.Name + asset.UID = "" + asset.ResourceVersion = "" + if err = c.Create(t.Context(), asset); err != nil { + t.Fatal(err) + } + asset.Spec.Source.CRUID = "another" + if err = c.Update(t.Context(), asset); err == nil { + t.Fatal("asset initial identity was mutable") + } + op.Namespace = namespace.Name + op.UID = "" + op.ResourceVersion = "" + op.Spec.AssetUID = asset.UID + op.Spec.Approval = Approval(op.Spec) + if err = c.Create(t.Context(), op); err != nil { + t.Fatal(err) + } + op.Spec.Action = ActionAdopt + if err = c.Update(t.Context(), op); err == nil { + t.Fatal("operation spec changed after authorization") + } + if err = c.Get(t.Context(), client.ObjectKeyFromObject(op), op); err != nil { + t.Fatal(err) + } + op.Status.Phase = phaseLocked + op.Status.SpecDigest = Approval(op.Spec) + if err = c.Status().Update(t.Context(), op); err != nil { + t.Fatal(err) + } + if err = c.Get(t.Context(), client.ObjectKeyFromObject(op), op); err != nil { + t.Fatal(err) + } + if op.Status.Phase != phaseLocked || op.UID == "" { + t.Fatal("durable identity/phase did not roundtrip") + } +} diff --git a/pkg/framework/dataops/checks.go b/pkg/framework/dataops/checks.go new file mode 100644 index 00000000..45c3867c --- /dev/null +++ b/pkg/framework/dataops/checks.go @@ -0,0 +1,390 @@ +package dataops + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func validateIntent(op *DataOperation) error { + s := op.Spec.Source + if op.Spec.WorkerIdentity.UID <= 0 || op.Spec.WorkerIdentity.GID <= 0 { + return fmt.Errorf("operation requires explicit positive non-root worker UID/GID") + } + if op.UID == "" || op.Spec.AssetUID == "" || s.Binding.PVCUID == "" || s.Binding.PVUID == "" || s.Binding.VolumeName == "" || s.Source.CRUID == "" || s.ClaimName == "" { + return fmt.Errorf("operation requires exact asset, source PVC/PV and CR identities") + } + if op.Spec.SourceCluster != s.Cluster || op.Spec.SourceCluster.UID != s.Source.CRUID || op.Spec.SourceCluster.Name == "" || op.Spec.SourceCluster.APIVersion == "" || op.Spec.SourceCluster.Kind == "" { + return fmt.Errorf("source cluster identity is incomplete") + } + if op.Spec.Action == ActionDestroy { + if op.Spec.Target != nil { + return fmt.Errorf("destroy cannot have a target") + } + return nil + } + if err := validateTarget(op); err != nil { + return err + } + t := op.Spec.Target + capacity, err := resource.ParseQuantity(t.Source.Capacity) + if err != nil || capacity.Sign() <= 0 || t.Source.StorageClass == "" { + return fmt.Errorf("invalid target storage declaration") + } + prefix := t.Source.Slot + "-" + t.Cluster.Name + "-" + t.Source.Role + "-" + t.Source.Group + "-" + ordinal, parseErr := strconv.ParseUint(strings.TrimPrefix(t.ClaimName, prefix), 10, 32) + if !strings.HasPrefix(t.ClaimName, prefix) || parseErr != nil || t.ClaimName != prefix+strconv.FormatUint(ordinal, 10) { + return fmt.Errorf("target claim does not match the framework role-group slot") + } + if op.Spec.Action == ActionMigrate && t.ClaimName == s.ClaimName { + return fmt.Errorf("migration requires a distinct target claim") + } + if op.Spec.Action == ActionAdopt && (t.Source.StorageClass != s.Source.StorageClass || t.Source.Capacity != s.Source.Capacity) { + return fmt.Errorf("same-volume adoption cannot change class or capacity; use migrate") + } + return nil +} +func (r *reconciler) checkCluster(ctx context.Context, namespace string, ref ClusterRef, allowMissing bool) error { + cluster := &unstructured.Unstructured{} + cluster.SetAPIVersion(ref.APIVersion) + cluster.SetKind(ref.Kind) + err := r.Client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, cluster) + if apierrors.IsNotFound(err) && allowMissing { + return nil + } + if err != nil { + return err + } + if cluster.GetUID() != ref.UID { + if allowMissing { + return nil + } + return fmt.Errorf("cluster %s UID changed", ref.Name) + } + paused, _, err := unstructured.NestedBool(cluster.Object, "spec", "clusterConfig", "reconciliationPaused") + if err != nil || !paused { + return fmt.Errorf("cluster %s must be explicitly reconciliationPaused", ref.Name) + } + return nil +} +func (r *reconciler) checkQuiescent(ctx context.Context, op *DataOperation) error { + if err := r.checkCluster(ctx, op.Namespace, op.Spec.SourceCluster, true); err != nil { + return err + } + if op.Spec.Target != nil { + if err := r.checkCluster(ctx, op.Namespace, op.Spec.Target.Cluster, false); err != nil { + return err + } + } + var workloads appsv1.StatefulSetList + if err := r.Client.List(ctx, &workloads, client.InNamespace(op.Namespace)); err != nil { + return err + } + for _, sts := range workloads.Items { + owner := metav1.GetControllerOf(&sts) + if owner != nil && (owner.UID == op.Spec.SourceCluster.UID || (op.Spec.Target != nil && owner.UID == op.Spec.Target.Cluster.UID)) { + return fmt.Errorf("retire source and target StatefulSets before data operation: %s remains", sts.Name) + } + } + var pods corev1.PodList + if err := r.Client.List(ctx, &pods, client.InNamespace(op.Namespace)); err != nil { + return err + } + for _, pod := range pods.Items { + for _, volume := range pod.Spec.Volumes { + if volume.PersistentVolumeClaim == nil { + continue + } + name := volume.PersistentVolumeClaim.ClaimName + if name != op.Spec.Source.ClaimName && (op.Spec.Target == nil || name != op.Spec.Target.ClaimName) { + continue + } + owner := metav1.GetControllerOf(&pod) + if owner != nil && owner.Kind == "Job" && owner.UID == op.Status.JobUID && op.Status.JobUID != "" && owner.Name == jobName(op) { + continue + } + return fmt.Errorf("PVC %s still has consumer Pod %s", name, pod.Name) + } + } + return nil +} +func checkPV(pv *corev1.PersistentVolume, identity DataIdentity) error { + if pv.UID != identity.Binding.PVUID || pv.Name != identity.Binding.VolumeName || pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain || len(pv.OwnerReferences) != 0 || pv.Spec.StorageClassName != identity.Source.StorageClass { + return fmt.Errorf("PV identity, ownership or Retain policy changed") + } + return nil +} +func (r *reconciler) sourcePair(ctx context.Context, op *DataOperation, allowAbsent bool) (*corev1.PersistentVolumeClaim, *corev1.PersistentVolume, error) { + s := op.Spec.Source + class := &storagev1.StorageClass{} + if err := r.Client.Get(ctx, client.ObjectKey{Name: s.Source.StorageClass}, class); err != nil { + return nil, nil, err + } + if class.ReclaimPolicy == nil || *class.ReclaimPolicy != corev1.PersistentVolumeReclaimRetain || !class.DeletionTimestamp.IsZero() { + return nil, nil, fmt.Errorf("source StorageClass no longer retains") + } + pv := &corev1.PersistentVolume{} + if err := r.Client.Get(ctx, client.ObjectKey{Name: s.Binding.VolumeName}, pv); err != nil { + return nil, nil, err + } + if err := checkPV(pv, s); err != nil { + return nil, nil, err + } + claim := &corev1.PersistentVolumeClaim{} + err := r.Client.Get(ctx, client.ObjectKey{Namespace: op.Namespace, Name: s.ClaimName}, claim) + if apierrors.IsNotFound(err) && allowAbsent { + return nil, pv, nil + } + if err != nil { + return nil, nil, err + } + var source Source + var binding Binding + if err = json.Unmarshal([]byte(claim.Annotations[SourceAnnotation]), &source); err != nil { + return nil, nil, err + } + if err = json.Unmarshal([]byte(claim.Annotations[BindingAnnotation]), &binding); err != nil { + return nil, nil, err + } + if claim.Annotations[AssetAnnotation] != op.Spec.AssetName || claim.UID != s.Binding.PVCUID || source != s.Source || binding != s.Binding || len(claim.OwnerReferences) != 0 || claim.Spec.VolumeName != pv.Name || pv.Spec.ClaimRef == nil || pv.Spec.ClaimRef.UID != claim.UID || pv.Spec.ClaimRef.Name != claim.Name || pv.Spec.ClaimRef.Namespace != claim.Namespace { + return nil, nil, fmt.Errorf("source PVC/PV binding or provenance changed") + } + return claim, pv, nil +} +func (r *reconciler) targetClaim(ctx context.Context, op *DataOperation, volume string) (*corev1.PersistentVolumeClaim, error) { + t := op.Spec.Target + claim := &corev1.PersistentVolumeClaim{} + err := r.Client.Get(ctx, client.ObjectKey{Namespace: op.Namespace, Name: t.ClaimName}, claim) + if err == nil { + if claim.Annotations[LockAnnotation] != string(op.UID) { + return nil, fmt.Errorf("target PVC is not owned by this operation") + } + if op.Status.Target != nil && claim.UID != op.Status.Target.Binding.PVCUID { + return nil, fmt.Errorf("target PVC UID changed") + } + if op.Status.Target == nil { + op.Status.Target = &DataIdentity{Cluster: t.Cluster, ClaimName: claim.Name, Source: t.Source, Binding: Binding{Version: 1, PVCUID: claim.UID}} + } + return claim, nil + } + if !apierrors.IsNotFound(err) { + return nil, err + } + if op.Status.Target != nil { + return nil, fmt.Errorf("target PVC disappeared") + } + class := &storagev1.StorageClass{} + if err = r.Client.Get(ctx, client.ObjectKey{Name: t.Source.StorageClass}, class); err != nil { + return nil, err + } + if class.ReclaimPolicy == nil || *class.ReclaimPolicy != corev1.PersistentVolumeReclaimRetain { + return nil, fmt.Errorf("target StorageClass must retain") + } + mode := corev1.PersistentVolumeFilesystem + capacity := resource.MustParse(t.Source.Capacity) + claim = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: t.ClaimName, Namespace: op.Namespace, Annotations: map[string]string{LockAnnotation: string(op.UID)}}, Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: &t.Source.StorageClass, VolumeName: volume, VolumeMode: &mode, AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: capacity}}}} + if err = r.Client.Create(ctx, claim); err != nil { + return nil, err + } + op.Status.Target = &DataIdentity{Cluster: t.Cluster, ClaimName: claim.Name, Source: t.Source, Binding: Binding{Version: 1, PVCUID: claim.UID}} + return claim, nil +} +func (r *reconciler) finishTarget(ctx context.Context, op *DataOperation) (*DataIdentity, error) { + claim := &corev1.PersistentVolumeClaim{} + if err := r.Client.Get(ctx, client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.Target.ClaimName}, claim); err != nil { + return nil, err + } + if op.Spec.Target.ClaimName == op.Spec.Source.ClaimName { + if claim.UID != op.Spec.Source.Binding.PVCUID { + return nil, fmt.Errorf("same-name adoption PVC UID changed") + } + } else if op.Status.Target == nil || claim.UID != op.Status.Target.Binding.PVCUID || claim.Annotations[LockAnnotation] != string(op.UID) { + return nil, fmt.Errorf("target identity changed") + } + pv := &corev1.PersistentVolume{} + if err := r.Client.Get(ctx, client.ObjectKey{Name: claim.Spec.VolumeName}, pv); err != nil { + return nil, err + } + identity := DataIdentity{Cluster: op.Spec.Target.Cluster, ClaimName: claim.Name, Source: op.Spec.Target.Source, Binding: Binding{Version: 1, PVCUID: claim.UID, PVUID: pv.UID, VolumeName: pv.Name}} + if err := checkPV(pv, identity); err != nil { + return nil, err + } + if claim.Status.Phase != corev1.ClaimBound || pv.Status.Phase != corev1.VolumeBound || pv.Spec.ClaimRef == nil || pv.Spec.ClaimRef.UID != claim.UID || pv.Spec.ClaimRef.Name != claim.Name || pv.Spec.ClaimRef.Namespace != claim.Namespace { + return nil, fmt.Errorf("waiting for exact target bidirectional binding") + } + if op.Spec.Action == ActionAdopt && pv.UID != op.Spec.Source.Binding.PVUID { + return nil, fmt.Errorf("adoption changed PV identity") + } + source, _ := json.Marshal(identity.Source) + binding, _ := json.Marshal(identity.Binding) + if claim.Annotations == nil { + claim.Annotations = map[string]string{} + } + claim.Annotations[SourceAnnotation] = string(source) + claim.Annotations[BindingAnnotation] = string(binding) + claim.Annotations[AssetAnnotation] = op.Spec.AssetName + if err := r.Client.Update(ctx, claim); err != nil { + return nil, err + } + return &identity, nil +} +func (r *reconciler) runJob(ctx context.Context, op *DataOperation) (bool, error) { + job := &batchv1.Job{} + err := r.Client.Get(ctx, client.ObjectKey{Namespace: op.Namespace, Name: jobName(op)}, job) + if apierrors.IsNotFound(err) { + if op.Status.JobUID != "" { + return false, fmt.Errorf("operation Job disappeared; refusing to invent completion") + } + job = jobFor(op, r.WorkerImage) + if err = r.Client.Create(ctx, job); err != nil { + return false, err + } + op.Status.JobUID = job.UID + return false, nil + } + if err != nil { + return false, err + } + owner := metav1.GetControllerOf(job) + if owner == nil || owner.UID != op.UID || (op.Status.JobUID != "" && job.UID != op.Status.JobUID) { + return false, fmt.Errorf("operation Job identity changed") + } + if err := checkWorkerSpec(op, job, r.WorkerImage); err != nil { + return false, err + } + op.Status.JobUID = job.UID + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Status == corev1.ConditionTrue { + return false, r.retryWorker(ctx, op, job, condition.Reason+": "+condition.Message) + } + } + complete := false + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobComplete && condition.Status == corev1.ConditionTrue { + complete = true + } + } + if !complete { + return false, nil + } + var pods corev1.PodList + if err = r.Client.List(ctx, &pods, client.InNamespace(op.Namespace)); err != nil { + return false, err + } + if op.Status.WorkerReceipt == "" { + return false, captureReceipt(op, job, pods.Items) + } + found := false + for i := range pods.Items { + pod := &pods.Items[i] + owner := metav1.GetControllerOf(pod) + if owner != nil && owner.UID == job.UID { + found = true + if err = r.deleteExact(ctx, pod); err != nil { + return false, err + } + } + } + return !found, nil +} + +func validateTarget(op *DataOperation) error { + t := op.Spec.Target + if (op.Spec.Action != ActionMigrate && op.Spec.Action != ActionAdopt) || t == nil { + return fmt.Errorf("operation requires adopt/migrate target") + } + if t.Cluster.UID == "" || t.Cluster.UID != t.Source.CRUID || t.Cluster.Name == "" || t.Cluster.APIVersion == "" || t.Cluster.Kind == "" { + return fmt.Errorf("complete target cluster identity is required") + } + if t.Source.Role == "" || t.Source.Group == "" || t.Source.Slot == "" || t.Source.Version != 1 { + return fmt.Errorf("complete target source is required") + } + return nil +} + +func (r *reconciler) retryWorker(ctx context.Context, op *DataOperation, job *batchv1.Job, message string) error { + requested, err := strconv.ParseInt(op.Annotations[RetryAnnotation], 10, 32) + if err != nil || requested != int64(op.Status.Attempt)+1 { + return fmt.Errorf("worker failed: %s; inspect retained Job/logs, remove failed Pods and request next data-retry attempt", message) + } + var pods corev1.PodList + if err := r.Client.List(ctx, &pods, client.InNamespace(op.Namespace)); err != nil { + return err + } + for _, pod := range pods.Items { + owner := metav1.GetControllerOf(&pod) + if owner != nil && owner.UID == job.UID { + return fmt.Errorf("inspect and remove failed worker Pod %s before retry", pod.Name) + } + } + op.Status.Attempt = int32(requested) + op.Status.JobUID = "" + op.Status.WorkerReceipt = "" + return nil +} + +func captureReceipt(op *DataOperation, job *batchv1.Job, items []corev1.Pod) error { + + for _, pod := range items { + owner := metav1.GetControllerOf(&pod) + if owner == nil || owner.UID != job.UID { + continue + } + for _, status := range pod.Status.ContainerStatuses { + if status.Name == workerContainer && status.State.Terminated != nil && status.State.Terminated.ExitCode == 0 { + var receipt struct { + Operation string `json:"operation"` + Verified string `json:"verified"` + Digest string `json:"digest"` + } + if err := json.Unmarshal([]byte(status.State.Terminated.Message), &receipt); err != nil { + return err + } + expected := "empty-filesystem" + if op.Spec.Action == ActionMigrate { + expected = "sha256-tree" + } + if receipt.Operation != string(op.UID) || receipt.Verified != expected || (op.Spec.Action == ActionMigrate && len(receipt.Digest) != 64) { + return fmt.Errorf("invalid worker completion receipt") + } + op.Status.WorkerReceipt = status.State.Terminated.Message + return nil + } + } + } + return fmt.Errorf("completed Job has no verified worker receipt") +} + +func checkWorkerSpec(op *DataOperation, job *batchv1.Job, image string) error { + actual := job.Spec.Template.Spec + expected := jobFor(op, image).Spec.Template.Spec + if len(actual.Containers) != 1 || len(actual.InitContainers) != 0 || actual.ServiceAccountName != "" && actual.ServiceAccountName != "default" { + return fmt.Errorf("operation worker process shape changed") + } + process, want := actual.Containers[0], expected.Containers[0] + if !reflect.DeepEqual(process.SecurityContext, want.SecurityContext) { + return fmt.Errorf("worker container execution identity or security restrictions changed") + } + security := actual.SecurityContext + required := expected.SecurityContext + if security == nil || !reflect.DeepEqual(security.RunAsUser, required.RunAsUser) || !reflect.DeepEqual(security.RunAsGroup, required.RunAsGroup) || !reflect.DeepEqual(security.FSGroup, required.FSGroup) || !reflect.DeepEqual(security.RunAsNonRoot, required.RunAsNonRoot) || len(security.SupplementalGroups) != 0 || actual.AutomountServiceAccountToken == nil || *actual.AutomountServiceAccountToken { + return fmt.Errorf("worker execution identity changed") + } + if process.Image != want.Image || !reflect.DeepEqual(process.Command, want.Command) || len(process.Args) != 0 || len(process.Env) != 0 || len(process.EnvFrom) != 0 || !reflect.DeepEqual(process.VolumeMounts, want.VolumeMounts) || !reflect.DeepEqual(actual.Volumes, expected.Volumes) { + return fmt.Errorf("operation worker image, commands or data mounts changed") + } + return nil +} diff --git a/pkg/framework/dataops/controller.go b/pkg/framework/dataops/controller.go new file mode 100644 index 00000000..110952c2 --- /dev/null +++ b/pkg/framework/dataops/controller.go @@ -0,0 +1,371 @@ +package dataops + +import ( + "context" + "fmt" + "time" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Options struct{ WorkerImage string } + +// +kubebuilder:object:generate=false +type reconciler struct { + Client client.Client + WorkerImage string +} + +func Register(manager ctrl.Manager, options Options) error { + if options.WorkerImage == "" { + return fmt.Errorf("data operations require an explicit worker image") + } + if err := AddToScheme(manager.GetScheme()); err != nil { + return err + } + if err := batchv1.AddToScheme(manager.GetScheme()); err != nil { + return err + } + direct, err := client.New(manager.GetConfig(), client.Options{Scheme: manager.GetScheme(), Mapper: manager.GetRESTMapper()}) + if err != nil { + return err + } + return ctrl.NewControllerManagedBy(manager).Named("framework-data-operations").For(&DataOperation{}).Owns(&batchv1.Job{}).Complete(&reconciler{Client: direct, WorkerImage: options.WorkerImage}) +} +func (r *reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + op := &DataOperation{} + if err := r.Client.Get(ctx, request.NamespacedName, op); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if op.Status.Phase == phaseComplete { + return ctrl.Result{}, nil + } + before := op.Status.DeepCopy() + err := r.advance(ctx, op) + if err != nil { + op.Status.Message = err.Error() + } else { + op.Status.Message = "" + } + if !apiequality.Semantic.DeepEqual(*before, op.Status) { + if writeErr := r.Client.Status().Update(ctx, op); writeErr != nil { + return ctrl.Result{}, writeErr + } + } + if err != nil { + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil +} +func (r *reconciler) advance(ctx context.Context, op *DataOperation) error { + digest := Approval(op.Spec) + if op.Spec.Approval != digest || (op.Status.SpecDigest != "" && op.Status.SpecDigest != digest) { + return fmt.Errorf("operation approval does not bind the current immutable intent") + } + if !op.DeletionTimestamp.IsZero() { + return fmt.Errorf("operation deletion requested; execution suspended with retained locks") + } + if err := validateIntent(op); err != nil { + return err + } + asset := &DataAsset{} + if err := r.Client.Get(ctx, client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.AssetName}, asset); err != nil { + return err + } + if asset.UID != op.Spec.AssetUID { + return fmt.Errorf("asset UID changed") + } + if asset.Annotations[LockAnnotation] != "" && asset.Annotations[LockAnnotation] != string(op.UID) { + return fmt.Errorf("asset is locked by another operation") + } + if op.Status.Phase == "" { + current := asset.Spec + if asset.Status.Current != nil { + current = *asset.Status.Current + } + historicalDestroy := allowsHistoricalDestroy(asset, op) + if !historicalDestroy && (asset.Status.Destroyed || current != op.Spec.Source) { + return fmt.Errorf("authorized source differs from asset current identity") + } + if err := r.checkQuiescent(ctx, op); err != nil { + return err + } + if _, _, err := r.sourcePair(ctx, op, false); err != nil { + return err + } + if asset.Annotations == nil { + asset.Annotations = map[string]string{} + } + asset.Annotations[LockAnnotation] = string(op.UID) + if err := r.Client.Update(ctx, asset); err != nil { + return err + } + op.Status.SpecDigest = digest + op.Status.Phase = phaseLocked + return nil + } + if op.Status.Phase == phaseRecord { + for _, entry := range asset.Status.History { + if entry.OperationUID == op.UID { + return r.record(ctx, op, asset) + } + } + } + if asset.Annotations[LockAnnotation] != string(op.UID) { + return fmt.Errorf("operation lost asset lock") + } + if op.Status.Phase == phaseRecord { + return r.record(ctx, op, asset) + } + if err := r.checkQuiescent(ctx, op); err != nil { + return err + } + switch op.Spec.Action { + case ActionMigrate: + return r.migrate(ctx, op) + case ActionAdopt: + return r.adopt(ctx, op) + case ActionDestroy: + return r.destroy(ctx, op) + } + return fmt.Errorf("unsupported action") +} +func (r *reconciler) migrate(ctx context.Context, op *DataOperation) error { + if _, _, err := r.sourcePair(ctx, op, false); err != nil { + return err + } + switch op.Status.Phase { + case phaseLocked: + if _, err := r.targetClaim(ctx, op, ""); err != nil { + return err + } + op.Status.Phase = phaseCopy + return nil + case phaseCopy: + complete, err := r.runJob(ctx, op) + if err != nil { + return err + } + if !complete { + return nil + } + op.Status.Phase = phaseBindTarget + return nil + case phaseBindTarget: + target, err := r.finishTarget(ctx, op) + if err != nil { + return err + } + op.Status.Target = target + op.Status.Phase = phaseRecord + return nil + } + return fmt.Errorf("unknown migration phase %s", op.Status.Phase) +} +func (r *reconciler) adopt(ctx context.Context, op *DataOperation) error { + switch op.Status.Phase { + case phaseLocked: + if _, _, err := r.sourcePair(ctx, op, false); err != nil { + return err + } + if op.Spec.Target.ClaimName == op.Spec.Source.ClaimName { + op.Status.Phase = phaseBindTarget + return nil + } + if _, err := r.targetClaim(ctx, op, op.Spec.Source.Binding.VolumeName); err != nil { + return err + } + op.Status.Phase = "ReleaseSource" + return nil + case "ReleaseSource": + claim, _, err := r.sourcePair(ctx, op, true) + if err != nil { + return err + } + if claim != nil { + return r.deleteExact(ctx, claim) + } + op.Status.Phase = "Rebind" + return nil + case "Rebind": + claim, err := r.targetClaim(ctx, op, op.Spec.Source.Binding.VolumeName) + if err != nil { + return err + } + _, pv, err := r.sourcePair(ctx, op, true) + if err != nil { + return err + } + ref := pv.Spec.ClaimRef + if ref != nil && ref.UID != op.Spec.Source.Binding.PVCUID && ref.UID != claim.UID { + return fmt.Errorf("PV claimed by another identity") + } + if ref == nil || ref.UID != claim.UID { + pv.Spec.ClaimRef = &corev1.ObjectReference{APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: claim.Name, Namespace: claim.Namespace, UID: claim.UID} + if err = r.Client.Update(ctx, pv); err != nil { + return err + } + } + op.Status.Phase = phaseBindTarget + return nil + case phaseBindTarget: + target, err := r.finishTarget(ctx, op) + if err != nil { + return err + } + op.Status.Target = target + op.Status.Phase = phaseRecord + return nil + } + return fmt.Errorf("unknown adoption phase %s", op.Status.Phase) +} +func (r *reconciler) destroy(ctx context.Context, op *DataOperation) error { + switch op.Status.Phase { + case phaseLocked: + if _, _, err := r.sourcePair(ctx, op, false); err != nil { + return err + } + op.Status.Phase = phaseErase + return nil + case phaseErase: + if _, _, err := r.sourcePair(ctx, op, false); err != nil { + return err + } + complete, err := r.runJob(ctx, op) + if err != nil { + return err + } + if !complete { + return nil + } + op.Status.Phase = "DeleteClaim" + return nil + case "DeleteClaim": + claim, _, err := r.sourcePair(ctx, op, true) + if err != nil { + return err + } + if claim != nil { + return r.deleteExact(ctx, claim) + } + op.Status.Phase = phaseDeleteVolume + return nil + case phaseDeleteVolume, phaseReclaimVolume: + pv := &corev1.PersistentVolume{} + err := r.Client.Get(ctx, client.ObjectKey{Name: op.Spec.Source.Binding.VolumeName}, pv) + if apierrors.IsNotFound(err) { + if op.Status.Phase != phaseReclaimVolume { + return fmt.Errorf("volume disappeared before backend reclamation was persisted; completion is unknown") + } + op.Status.Phase = phaseRecord + return nil + } + if err != nil { + return err + } + if pv.UID != op.Spec.Source.Binding.PVUID || len(pv.OwnerReferences) != 0 { + return fmt.Errorf("volume identity changed before backend reclamation") + } + if pv.Spec.PersistentVolumeReclaimPolicy == corev1.PersistentVolumeReclaimDelete { + if pv.Annotations[LockAnnotation] != string(op.UID) { + return fmt.Errorf("volume deletion policy changed without this operation receipt") + } + op.Status.Phase = phaseReclaimVolume + return nil + } + if err := checkPV(pv, op.Spec.Source); err != nil { + return err + } + if op.Status.WorkerReceipt == "" { + return fmt.Errorf("backend reclamation requires persisted erase verification") + } + if pv.Annotations == nil { + pv.Annotations = map[string]string{} + } + pv.Annotations[LockAnnotation] = string(op.UID) + pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimDelete + if err := r.Client.Update(ctx, pv); err != nil { + return err + } + op.Status.Phase = phaseReclaimVolume + // The actual storage provisioner must delete its backend volume and the + // PV. Deleting a Retain PV object alone would silently orphan storage. + return nil + + } + return fmt.Errorf("unknown destruction phase %s", op.Status.Phase) +} +func (r *reconciler) deleteExact(ctx context.Context, object client.Object) error { + uid, rv := object.GetUID(), object.GetResourceVersion() + return client.IgnoreNotFound(r.Client.Delete(ctx, object, &client.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &rv}})) +} +func (r *reconciler) record(ctx context.Context, op *DataOperation, asset *DataAsset) error { + found := false + for _, entry := range asset.Status.History { + if entry.OperationUID == op.UID { + found = true + } + } + if !found { + asset.Status.History = append(asset.Status.History, History{OperationUID: op.UID, Action: op.Spec.Action, From: op.Spec.Source, To: op.Status.Target, Completed: metav1.Now(), Verification: verification(op)}) + if op.Spec.Action == ActionDestroy { + current := asset.Spec + if asset.Status.Current != nil { + current = *asset.Status.Current + } + if current == op.Spec.Source { + asset.Status.Destroyed = true + } + remaining := make([]DataIdentity, 0, len(asset.Status.RetiredCopies)) + for _, copy := range asset.Status.RetiredCopies { + if copy != op.Spec.Source { + remaining = append(remaining, copy) + } + } + asset.Status.RetiredCopies = remaining + } else { + if op.Spec.Action == ActionMigrate { + asset.Status.RetiredCopies = append(asset.Status.RetiredCopies, op.Spec.Source) + } + asset.Status.Current = op.Status.Target + asset.Status.Destroyed = false + } + if err := r.Client.Status().Update(ctx, asset); err != nil { + return err + } + } + delete(asset.Annotations, LockAnnotation) + if err := r.Client.Update(ctx, asset); err != nil { + return err + } + now := metav1.Now() + op.Status.Completed = &now + op.Status.Phase = phaseComplete + return nil +} + +func verification(op *DataOperation) string { + if op.Spec.Action == ActionAdopt { + return "same-pv-uid" + } + return op.Status.WorkerReceipt +} + +func allowsHistoricalDestroy(asset *DataAsset, op *DataOperation) bool { + if op.Spec.Action != ActionDestroy { + return false + } + for _, copy := range asset.Status.RetiredCopies { + if copy == op.Spec.Source { + return true + } + } + return false +} diff --git a/pkg/framework/dataops/controller_test.go b/pkg/framework/dataops/controller_test.go new file mode 100644 index 00000000..d74674f7 --- /dev/null +++ b/pkg/framework/dataops/controller_test.go @@ -0,0 +1,350 @@ +package dataops + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func testController(t *testing.T, action string) (*reconciler, *DataOperation, *DataAsset) { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{corev1.AddToScheme, appsv1.AddToScheme, batchv1.AddToScheme, storagev1.AddToScheme, AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + gvk := schema.GroupVersionKind{Group: "example.test", Version: "v1", Kind: "Cluster"} + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + source := Source{Version: 1, CRUID: "source-cr", Role: "worker", Group: "default", Slot: "data", StorageClass: "retained", Capacity: "64Mi"} + identity := DataIdentity{Cluster: ClusterRef{APIVersion: "example.test/v1", Kind: "Cluster", Name: "old", UID: "source-cr"}, ClaimName: "data-old-worker-default-0", Source: source, Binding: Binding{Version: 1, PVCUID: "source-pvc", PVUID: "source-pv", VolumeName: "old-pv"}} + asset := &DataAsset{ObjectMeta: metav1.ObjectMeta{Name: "asset", Namespace: "test", UID: "asset-uid"}, Spec: identity} + encodedSource, _ := json.Marshal(source) + encodedBinding, _ := json.Marshal(identity.Binding) + class := "retained" + mode := corev1.PersistentVolumeFilesystem + claim := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: identity.ClaimName, Namespace: "test", UID: identity.Binding.PVCUID, Annotations: map[string]string{SourceAnnotation: string(encodedSource), BindingAnnotation: string(encodedBinding), AssetAnnotation: asset.Name}}, Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "old-pv", StorageClassName: &class, VolumeMode: &mode, AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("64Mi")}}}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}} + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: "old-pv", UID: "source-pv"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, StorageClassName: class, ClaimRef: &corev1.ObjectReference{Name: claim.Name, Namespace: claim.Namespace, UID: claim.UID}}, Status: corev1.PersistentVolumeStatus{Phase: corev1.VolumeBound}} + target := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "example.test/v1", "kind": "Cluster", "metadata": map[string]any{"name": "new", "namespace": "test", "uid": "target-cr"}, "spec": map[string]any{"clusterConfig": map[string]any{"reconciliationPaused": true}}}} + retain := corev1.PersistentVolumeReclaimRetain + storageClass := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: class}, ReclaimPolicy: &retain} + op := &DataOperation{ObjectMeta: metav1.ObjectMeta{Name: "operation", Namespace: "test", UID: "operation-uid"}, Spec: OperationSpec{WorkerIdentity: WorkerIdentity{UID: 1000, GID: 1000}, Action: action, AssetName: asset.Name, AssetUID: asset.UID, Source: identity, SourceCluster: ClusterRef{APIVersion: "example.test/v1", Kind: "Cluster", Name: "old", UID: source.CRUID}}} + if action != ActionDestroy { + next := source + next.CRUID = "target-cr" + op.Spec.Target = &Target{Cluster: ClusterRef{APIVersion: "example.test/v1", Kind: "Cluster", Name: "new", UID: "target-cr"}, ClaimName: "data-new-worker-default-0", Source: next} + } + op.Spec.Approval = Approval(op.Spec) + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&DataAsset{}, &DataOperation{}, &corev1.PersistentVolumeClaim{}, &corev1.PersistentVolume{}, &batchv1.Job{}, &corev1.Pod{}).WithObjects(asset, claim, pv, target, storageClass, op).WithInterceptorFuncs(interceptor.Funcs{Create: func(ctx context.Context, c client.WithWatch, obj client.Object, options ...client.CreateOption) error { + if obj.GetUID() == "" { + obj.SetUID(types.UID("uid-" + obj.GetName())) + } + return c.Create(ctx, obj, options...) + }}).Build() + return &reconciler{Client: c, WorkerImage: "python:test"}, op, asset +} +func tick(t *testing.T, r *reconciler, op *DataOperation) { + t.Helper() + restarted := &reconciler{Client: r.Client, WorkerImage: r.WorkerImage} + if _, err := restarted.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(op)}); err != nil { + t.Fatal(err) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(op), op); err != nil { + t.Fatal(err) + } +} +func TestAdoptRebindPersistsAcrossEveryRestart(t *testing.T) { + r, op, asset := testController(t, ActionAdopt) + for i := 0; i < 5; i++ { + tick(t, r, op) + if op.Status.Message != "" { + t.Fatal(op.Status.Message) + } + } + claim := &corev1.PersistentVolumeClaim{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.Target.ClaimName}, claim); err != nil { + t.Fatal(err) + } + claim.Status.Phase = corev1.ClaimBound + if err := r.Client.Status().Update(t.Context(), claim); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + tick(t, r, op) + } + if op.Status.Phase != "Complete" { + t.Fatalf("%+v", op.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(asset), asset); err != nil { + t.Fatal(err) + } + if len(asset.Status.History) != 1 || asset.Status.Current.Binding.PVUID != "source-pv" || asset.Status.Current.Source.CRUID != "target-cr" { + t.Fatalf("%+v", asset.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(claim), claim); err != nil { + t.Fatal(err) + } + if err := EnsureAsset(t.Context(), r.Client, claim, op.Spec.Target.Cluster); err != nil { + t.Fatal(err) + } + tick(t, r, op) + if len(asset.Status.History) != 1 { + t.Fatal("duplicated history") + } +} +func finishWorker(t *testing.T, r *reconciler, op *DataOperation) { + t.Helper() + job := &batchv1.Job{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: jobName(op)}, job); err != nil { + t.Fatal(err) + } + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}} + if err := r.Client.Status().Update(t.Context(), job); err != nil { + t.Fatal(err) + } + yes := true + verified := "empty-filesystem" + if op.Spec.Action == ActionMigrate { + verified = "sha256-tree" + } + receipt := `{"operation":"` + string(op.UID) + `","verified":"` + verified + `","digest":"` + strings.Repeat("a", 64) + `"}` + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "worker", Namespace: op.Namespace, OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: job.Name, UID: job.UID, Controller: &yes}}}, Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{Name: "data", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, Message: receipt}}}}}} + if err := r.Client.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } +} +func TestDestroyRequiresVerifiedEraseBeforeDeletingIdentities(t *testing.T) { + r, op, asset := testController(t, ActionDestroy) + for i := 0; i < 3; i++ { + tick(t, r, op) + } + if op.Status.Phase != "Erase" { + t.Fatalf("%+v", op.Status) + } + claim := &corev1.PersistentVolumeClaim{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.Source.ClaimName}, claim); err != nil { + t.Fatal("deleted before worker completion", err) + } + finishWorker(t, r, op) + finishDestruction(t, r, op) + if op.Status.Phase != "Complete" { + t.Fatalf("%+v", op.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(asset), asset); err != nil { + t.Fatal(err) + } + if !asset.Status.Destroyed || len(asset.Status.History) != 1 || !strings.Contains(asset.Status.History[0].Verification, "empty-filesystem") { + t.Fatalf("%+v", asset.Status) + } +} +func TestOperationRefusesConsumerAndChangedApproval(t *testing.T) { + r, op, _ := testController(t, ActionDestroy) + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foreign", Namespace: op.Namespace}, Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "data", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: op.Spec.Source.ClaimName}}}}}} + if err := r.Client.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + tick(t, r, op) + if !strings.Contains(op.Status.Message, "consumer Pod") { + t.Fatalf("%+v", op.Status) + } + op.Spec.Approval = "wrong" + if err := r.Client.Update(t.Context(), op); err != nil { + t.Fatal(err) + } + tick(t, r, op) + if !strings.Contains(op.Status.Message, "approval") { + t.Fatalf("%+v", op.Status) + } +} +func TestWorkerCopiesVerifiesAndErasesActualBytes(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal(err) + } + source, target := t.TempDir(), t.TempDir() + if err = os.Chmod(target, 0770); err != nil { + t.Fatal(err) + } + // A worker with fsGroup write access does not own the provisioner's volume + // root. Model its metadata protection while executing the real copy script. + guardedScript := `import shutil, os, sys +original_copystat = shutil.copystat +def protected_copystat(source, destination, **kwargs): + if os.path.abspath(destination) == os.path.abspath(sys.argv[4]): + raise PermissionError("provisioner-owned volume root") + return original_copystat(source, destination, **kwargs) +shutil.copystat = protected_copystat +` + workerScript + if err = os.Mkdir(filepath.Join(source, "nested"), 0750); err != nil { + t.Fatal(err) + } + data := []byte(strings.Repeat("retained-marker\x00", 1000)) + if err = os.WriteFile(filepath.Join(source, "nested", "data"), data, 0640); err != nil { + t.Fatal(err) + } + if err = os.Symlink("nested/data", filepath.Join(source, "link")); err != nil { + t.Fatal(err) + } + run := func(action string) { + t.Helper() + out, err := exec.CommandContext(t.Context(), python, "-c", guardedScript, action, "test-operation", source, target).CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + } + run(ActionMigrate) + run(ActionMigrate) + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0770 { + t.Fatal("migration changed the provisioner-owned mount root", err) + } + copied, err := os.ReadFile(filepath.Join(target, "nested", "data")) + if err != nil || string(copied) != string(data) { + t.Fatal("copied bytes differ", err) + } + run(ActionDestroy) + entries, err := os.ReadDir(source) + if err != nil || len(entries) != 0 { + t.Fatal("erase did not remove bytes", err) + } +} + +func TestMigrationCopiesToNewBindingAndRetainsOperableSourceHistory(t *testing.T) { + r, op, asset := testController(t, ActionMigrate) + for i := 0; i < 3; i++ { + tick(t, r, op) + } + if op.Status.Phase != "Copy" || op.Status.Target == nil { + t.Fatalf("%+v", op.Status) + } + target := &corev1.PersistentVolumeClaim{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.Target.ClaimName}, target); err != nil { + t.Fatal(err) + } + target.Spec.VolumeName = "new-pv" + if err := r.Client.Update(t.Context(), target); err != nil { + t.Fatal(err) + } + target.Status.Phase = corev1.ClaimBound + if err := r.Client.Status().Update(t.Context(), target); err != nil { + t.Fatal(err) + } + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: "new-pv", UID: "new-pv-uid"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, StorageClassName: "retained", ClaimRef: &corev1.ObjectReference{Name: target.Name, Namespace: target.Namespace, UID: target.UID}}, Status: corev1.PersistentVolumeStatus{Phase: corev1.VolumeBound}} + if err := r.Client.Create(t.Context(), pv); err != nil { + t.Fatal(err) + } + finishWorker(t, r, op) + for i := 0; i < 7; i++ { + tick(t, r, op) + } + if op.Status.Phase != phaseComplete { + t.Fatalf("%+v", op.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(asset), asset); err != nil { + t.Fatal(err) + } + if asset.Status.Current.Binding.PVUID != "new-pv-uid" || len(asset.Status.RetiredCopies) != 1 || !strings.Contains(asset.Status.History[0].Verification, "sha256-tree") { + t.Fatalf("%+v", asset.Status) + } + old := &corev1.PersistentVolumeClaim{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: op.Spec.Source.ClaimName}, old); err != nil { + t.Fatal("migration deleted source before explicit authorization", err) + } + destroy := &DataOperation{ObjectMeta: metav1.ObjectMeta{Name: "destroy-copy", Namespace: op.Namespace, UID: "destroy-copy-uid"}, Spec: OperationSpec{WorkerIdentity: WorkerIdentity{UID: 1000, GID: 1000}, Action: ActionDestroy, AssetName: asset.Name, AssetUID: asset.UID, Source: op.Spec.Source, SourceCluster: op.Spec.SourceCluster}} + destroy.Spec.Approval = Approval(destroy.Spec) + if err := r.Client.Create(t.Context(), destroy); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + tick(t, r, destroy) + } + finishWorker(t, r, destroy) + finishDestruction(t, r, destroy) + if destroy.Status.Phase != phaseComplete { + t.Fatalf("%+v", destroy.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(asset), asset); err != nil { + t.Fatal(err) + } + if asset.Status.Destroyed || len(asset.Status.RetiredCopies) != 0 || asset.Status.Current.Binding.PVUID != "new-pv-uid" { + t.Fatalf("destroying retired copy affected current data: %+v", asset.Status) + } +} + +func TestFailedWorkerRequiresExplicitRetryAndPreservesJobEvidence(t *testing.T) { + r, op, _ := testController(t, ActionDestroy) + for i := 0; i < 3; i++ { + tick(t, r, op) + } + job := &batchv1.Job{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Namespace: op.Namespace, Name: jobName(op)}, job); err != nil { + t.Fatal(err) + } + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "DeadlineExceeded"}} + if err := r.Client.Status().Update(t.Context(), job); err != nil { + t.Fatal(err) + } + tick(t, r, op) + if !strings.Contains(op.Status.Message, "data-retry") || op.Status.Attempt != 0 { + t.Fatalf("%+v", op.Status) + } + op.Annotations = map[string]string{RetryAnnotation: "1"} + if err := r.Client.Update(t.Context(), op); err != nil { + t.Fatal(err) + } + tick(t, r, op) + tick(t, r, op) + if op.Status.Attempt != 1 || op.Status.JobUID == job.UID || op.Status.JobUID == "" { + t.Fatalf("%+v", op.Status) + } + if err := r.Client.Get(t.Context(), client.ObjectKeyFromObject(job), job); err != nil { + t.Fatal("failed Job evidence lost", err) + } +} + +func TestLedgerRefusesLostOriginalClaim(t *testing.T) { + r, op, _ := testController(t, ActionDestroy) + if err := CheckHistory(t.Context(), r.Client, op.Namespace, op.Spec.Source.Source, nil); err == nil { + t.Fatal("lost data was treated as first creation") + } +} + +func finishDestruction(t *testing.T, r *reconciler, op *DataOperation) { + t.Helper() + for i := 0; i < 12; i++ { + tick(t, r, op) + if op.Status.Phase == "ReclaimVolume" { + pv := &corev1.PersistentVolume{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Name: op.Spec.Source.Binding.VolumeName}, pv); err != nil { + t.Fatal(err) + } + if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimDelete || pv.Annotations[LockAnnotation] != string(op.UID) { + t.Fatal("reclaim was not bound to the exact authorized operation") + } + // A fake client has no provisioner. Its completion is explicitly simulated; + // the live harness must observe real backend reclamation before completion. + if err := r.Client.Delete(t.Context(), pv); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/pkg/framework/dataops/doc.go b/pkg/framework/dataops/doc.go new file mode 100644 index 00000000..a69b3cfb --- /dev/null +++ b/pkg/framework/dataops/doc.go @@ -0,0 +1,5 @@ +// Package dataops owns retained data identities and explicitly approved data operations. +// +kubebuilder:object:generate=true +// +versionName=v1alpha1 +// +groupName=data.framework.kubedoop.dev +package dataops diff --git a/pkg/framework/dataops/jobs.go b/pkg/framework/dataops/jobs.go new file mode 100644 index 00000000..77b9298e --- /dev/null +++ b/pkg/framework/dataops/jobs.go @@ -0,0 +1,55 @@ +package dataops + +import ( + _ "embed" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//go:embed worker.py +var workerScript string + +func jobFor(op *DataOperation, image string) *batchv1.Job { + zero, deadline := int32(0), int64(1800) + yes, no := true, false + uid, gid := op.Spec.WorkerIdentity.UID, op.Spec.WorkerIdentity.GID + volumes := []corev1.Volume{{Name: "source", VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: op.Spec.Source.ClaimName, ReadOnly: op.Spec.Action == ActionMigrate, + }, + }}} + mounts := []corev1.VolumeMount{{Name: "source", MountPath: "/source", ReadOnly: op.Spec.Action == ActionMigrate}} + if op.Spec.Action == ActionMigrate { + volumes = append(volumes, corev1.Volume{Name: "target", VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: op.Spec.Target.ClaimName}, + }}) + mounts = append(mounts, corev1.VolumeMount{Name: "target", MountPath: "/target"}) + } + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName(op), Namespace: op.Namespace, Labels: map[string]string{LockAnnotation: string(op.UID)}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: GroupVersion.String(), Kind: "DataOperation", + Name: op.Name, UID: op.UID, Controller: &yes}}, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &zero, ActiveDeadlineSeconds: &deadline, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{LockAnnotation: string(op.UID)}}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, AutomountServiceAccountToken: &no, + SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: &yes, RunAsUser: &uid, RunAsGroup: &gid, FSGroup: &gid}, + Volumes: volumes, + Containers: []corev1.Container{{Name: workerContainer, Image: image, + Command: []string{"python3", "-c", workerScript, op.Spec.Action, string(op.UID)}, VolumeMounts: mounts, + SecurityContext: &corev1.SecurityContext{AllowPrivilegeEscalation: &no, ReadOnlyRootFilesystem: &yes, + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}, + }}, + }, + }, + }, + } +} +func jobName(op *DataOperation) string { return fmt.Sprintf("data-%s-%d", op.UID, op.Status.Attempt) } diff --git a/pkg/framework/dataops/ledger.go b/pkg/framework/dataops/ledger.go new file mode 100644 index 00000000..8732a66e --- /dev/null +++ b/pkg/framework/dataops/ledger.go @@ -0,0 +1,98 @@ +package dataops + +import ( + "context" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// EnsureAsset records a fully checked binding independently of the product CR. +// Its immutable initial identity is never synthesized from a replacement claim. +func EnsureAsset(ctx context.Context, c client.Client, claim *corev1.PersistentVolumeClaim, owner ClusterRef) error { + var source Source + var binding Binding + if err := json.Unmarshal([]byte(claim.Annotations[SourceAnnotation]), &source); err != nil { + return err + } + if err := json.Unmarshal([]byte(claim.Annotations[BindingAnnotation]), &binding); err != nil { + return err + } + if owner.UID != source.CRUID || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" || source.CRUID == "" || binding.PVCUID != claim.UID || binding.PVUID == "" { + return fmt.Errorf("incomplete retained identity") + } + identity := DataIdentity{Cluster: owner, ClaimName: claim.Name, Source: source, Binding: binding} + name := claim.Annotations[AssetAnnotation] + if name == "" { + name = "data-" + string(claim.UID) + } + asset := &DataAsset{} + err := c.Get(ctx, client.ObjectKey{Namespace: claim.Namespace, Name: name}, asset) + if apierrors.IsNotFound(err) { + if claim.Annotations[AssetAnnotation] != "" { + return fmt.Errorf("data asset %s was lost", name) + } + asset = &DataAsset{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: claim.Namespace}, Spec: identity} + if err = c.Create(ctx, asset); err != nil { + return err + } + } else if err != nil { + return err + } + current := asset.Spec + if asset.Status.Current != nil { + current = *asset.Status.Current + } + if asset.Status.Destroyed || current != identity { + return fmt.Errorf("data asset identity differs from retained binding") + } + if claim.Annotations[AssetAnnotation] == name { + return nil + } + next := claim.DeepCopy() + next.Annotations[AssetAnnotation] = name + return c.Update(ctx, next) +} + +// CheckHistory prevents a disappeared recorded ordinal from being recreated as fresh data. +func CheckHistory(ctx context.Context, c client.Client, namespace string, source Source, claims []corev1.PersistentVolumeClaim) error { + var assets DataAssetList + if err := c.List(ctx, &assets, client.InNamespace(namespace)); err != nil { + return err + } + observed := map[string]corev1.PersistentVolumeClaim{} + for _, claim := range claims { + observed[claim.Name] = claim + } + for _, asset := range assets.Items { + current := asset.Spec + if asset.Status.Current != nil { + current = *asset.Status.Current + } + for _, previous := range append([]History{{From: asset.Spec}}, asset.Status.History...) { + former := previous.From.Source + if former.CRUID == source.CRUID && former.Role == source.Role && former.Group == source.Group && + (current.Source.CRUID != former.CRUID || current.Source.Role != former.Role || current.Source.Group != former.Group) { + return fmt.Errorf("data asset %s moved to another owner; source group cannot invent replacement data", asset.Name) + } + } + if current.Source.CRUID != source.CRUID || current.Source.Role != source.Role || current.Source.Group != source.Group { + continue + } + if asset.Status.Destroyed { + return fmt.Errorf("data asset %s was destroyed; a fresh data identity requires a new group", asset.Name) + } + claim, ok := observed[current.ClaimName] + if !ok || claim.UID != current.Binding.PVCUID { + return fmt.Errorf("recorded data asset %s lost its original PVC", asset.Name) + } + if asset.Annotations[LockAnnotation] != "" { + return fmt.Errorf("data asset %s has an active explicit operation", asset.Name) + } + } + return nil +} diff --git a/pkg/framework/dataops/recovery_test.go b/pkg/framework/dataops/recovery_test.go new file mode 100644 index 00000000..6ffa64fc --- /dev/null +++ b/pkg/framework/dataops/recovery_test.go @@ -0,0 +1,56 @@ +package dataops + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestDestroyMissingVolumeRequiresPersistedReclamation(t *testing.T) { + for _, phase := range []string{phaseDeleteVolume, phaseReclaimVolume} { + t.Run(phase, func(t *testing.T) { + r, op, _ := testController(t, ActionDestroy) + pv := &corev1.PersistentVolume{} + if err := r.Client.Get(t.Context(), client.ObjectKey{Name: op.Spec.Source.Binding.VolumeName}, pv); err != nil { + t.Fatal(err) + } + if err := r.Client.Delete(t.Context(), pv); err != nil { + t.Fatal(err) + } + op.Status.Phase = phase + err := r.destroy(t.Context(), op) + if phase == phaseReclaimVolume { + if err != nil || op.Status.Phase != phaseRecord { + t.Fatalf("persisted reclamation should finish: %s, %v", op.Status.Phase, err) + } + } else if err == nil || !strings.Contains(err.Error(), "completion is unknown") || op.Status.Phase != phase { + t.Fatalf("lost Retain PV invented completion: %s, %v", op.Status.Phase, err) + } + }) + } +} + +func TestWorkerRejectsContainerIdentityAndSecurityOverrides(t *testing.T) { + r, op, _ := testController(t, ActionDestroy) + mutations := map[string]func(*corev1.SecurityContext){ + "root identity": func(s *corev1.SecurityContext) { value := int64(0); s.RunAsUser = &value }, + "root group": func(s *corev1.SecurityContext) { value := int64(0); s.RunAsGroup = &value }, + "privilege escalation": func(s *corev1.SecurityContext) { value := true; s.AllowPrivilegeEscalation = &value }, + "writable root": func(s *corev1.SecurityContext) { value := false; s.ReadOnlyRootFilesystem = &value }, + "capabilities": func(s *corev1.SecurityContext) { s.Capabilities.Add = []corev1.Capability{"SYS_ADMIN"} }, + } + if err := checkWorkerSpec(op, jobFor(op, r.WorkerImage), r.WorkerImage); err != nil { + t.Fatal(err) + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + job := jobFor(op, r.WorkerImage) + mutate(job.Spec.Template.Spec.Containers[0].SecurityContext) + if err := checkWorkerSpec(op, job, r.WorkerImage); err == nil { + t.Fatal("worker accepted container override of approved identity or security restrictions") + } + }) + } +} diff --git a/pkg/framework/dataops/types.go b/pkg/framework/dataops/types.go new file mode 100644 index 00000000..c79a00ff --- /dev/null +++ b/pkg/framework/dataops/types.go @@ -0,0 +1,203 @@ +// Package dataops implements explicitly authorized, durable operations on retained data. +package dataops + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +var GroupVersion = schema.GroupVersion{Group: "data.framework.kubedoop.dev", Version: "v1alpha1"} + +const ( + RetryAnnotation = "framework.kubedoop.dev/data-retry" + SourceAnnotation = "framework.kubedoop.dev/retained-data" + BindingAnnotation = "framework.kubedoop.dev/retained-binding" + AssetAnnotation = "framework.kubedoop.dev/data-asset" + LockAnnotation = "framework.kubedoop.dev/data-operation" +) + +type ClusterRef struct { + // +kubebuilder:validation:MaxLength=4096 + APIVersion string `json:"apiVersion"` + // +kubebuilder:validation:MaxLength=4096 + Kind string `json:"kind"` + // +kubebuilder:validation:MaxLength=4096 + Name string `json:"name"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + UID types.UID `json:"uid"` +} +type Source struct { + Version int `json:"version"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + CRUID types.UID `json:"crUID"` + // +kubebuilder:validation:MaxLength=4096 + Role string `json:"role"` + // +kubebuilder:validation:MaxLength=4096 + Group string `json:"group"` + // +kubebuilder:validation:MaxLength=4096 + Slot string `json:"slot"` + // +kubebuilder:validation:MaxLength=4096 + StorageClass string `json:"storageClass"` + // +kubebuilder:validation:MaxLength=4096 + Capacity string `json:"capacity"` +} +type Binding struct { + Version int `json:"version"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + PVCUID types.UID `json:"pvcUID"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + PVUID types.UID `json:"pvUID"` + // +kubebuilder:validation:MaxLength=4096 + VolumeName string `json:"volumeName"` +} +type DataIdentity struct { + Cluster ClusterRef `json:"cluster"` + // +kubebuilder:validation:MaxLength=4096 + ClaimName string `json:"claimName"` + Binding Binding `json:"binding"` + Source Source `json:"source"` +} +type History struct { + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + OperationUID types.UID `json:"operationUID"` + // +kubebuilder:validation:MaxLength=4096 + Action string `json:"action"` + From DataIdentity `json:"from"` + To *DataIdentity `json:"to,omitempty"` + Completed metav1.Time `json:"completed"` + // +kubebuilder:validation:MaxLength=4096 + Verification string `json:"verification"` +} + +// DataAsset is independent of a product CR's ownership and survives its deletion. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type DataAsset struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="initial data identity is immutable" + Spec DataIdentity `json:"spec"` + Status AssetStatus `json:"status,omitempty"` +} +type AssetStatus struct { + // +kubebuilder:validation:MaxItems=1000 + RetiredCopies []DataIdentity `json:"retiredCopies,omitempty"` + Current *DataIdentity `json:"current,omitempty"` + Destroyed bool `json:"destroyed,omitempty"` + // +kubebuilder:validation:MaxItems=1000 + History []History `json:"history,omitempty"` +} + +// +kubebuilder:object:root=true +type DataAssetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DataAsset `json:"items"` +} +type Target struct { + Cluster ClusterRef `json:"cluster"` + // +kubebuilder:validation:MaxLength=4096 + ClaimName string `json:"claimName"` + Source Source `json:"source"` +} + +type WorkerIdentity struct { + UID int64 `json:"uid"` + GID int64 `json:"gid"` +} + +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="operation intent is immutable" +type OperationSpec struct { + // +kubebuilder:validation:MaxLength=4096 + Action string `json:"action"` + // +kubebuilder:validation:MaxLength=4096 + AssetName string `json:"assetName"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + AssetUID types.UID `json:"assetUID"` + Source DataIdentity `json:"source"` + SourceCluster ClusterRef `json:"sourceCluster"` + WorkerIdentity WorkerIdentity `json:"workerIdentity"` + Target *Target `json:"target,omitempty"` + // +kubebuilder:validation:MaxLength=4096 + Approval string `json:"approval"` +} +type OperationStatus struct { + // +kubebuilder:validation:MaxLength=4096 + WorkerReceipt string `json:"workerReceipt,omitempty"` + Attempt int32 `json:"attempt,omitempty"` + // +kubebuilder:validation:MaxLength=4096 + Phase string `json:"phase,omitempty"` + // +kubebuilder:validation:MaxLength=4096 + Message string `json:"message,omitempty"` + // +kubebuilder:validation:MaxLength=4096 + SpecDigest string `json:"specDigest,omitempty"` + Target *DataIdentity `json:"target,omitempty"` + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:Type=string + JobUID types.UID `json:"jobUID,omitempty"` + Completed *metav1.Time `json:"completed,omitempty"` +} + +// DataOperation's approval is an exact digest of the immutable operation intent. +// Creating operations is a separate RBAC capability from managing product CRs. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type DataOperation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec OperationSpec `json:"spec"` + Status OperationStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type DataOperationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DataOperation `json:"items"` +} + +// +kubebuilder:validation:MaxLength=4096 +func Approval(spec OperationSpec) string { + spec.Approval = "" + b, _ := json.Marshal(spec) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} +func AddToScheme(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &DataAsset{}, &DataAssetList{}, &DataOperation{}, &DataOperationList{}) + metav1.AddToGroupVersion(s, GroupVersion) + return nil +} + +const ( + ActionMigrate = "migrate" + ActionAdopt = "adopt" + ActionDestroy = "destroy" + phaseLocked = "Locked" + phaseRecord = "Record" + phaseBindTarget = "BindTarget" +) + +const ( + phaseComplete = "Complete" + phaseErase = "Erase" + workerContainer = "data" +) + +const phaseCopy = "Copy" + +const phaseReclaimVolume = "ReclaimVolume" + +const phaseDeleteVolume = "DeleteVolume" diff --git a/pkg/framework/dataops/worker.py b/pkg/framework/dataops/worker.py new file mode 100644 index 00000000..6361ad9b --- /dev/null +++ b/pkg/framework/dataops/worker.py @@ -0,0 +1,87 @@ +import hashlib, json, os, shutil, stat, sys +from pathlib import Path + +action, operation = sys.argv[1:3] +source = Path(sys.argv[3] if len(sys.argv) > 3 else '/source') +target = Path(sys.argv[4] if len(sys.argv) > 4 else '/target') +receipt = '.framework-data-operation.json' + +def manifest(root): + result = {} + for directory, dirs, files in os.walk(root, followlinks=False): + for name in sorted(dirs + files): + path = Path(directory) / name + relative = str(path.relative_to(root)) + if relative == receipt: + continue + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + result[relative] = ['link', os.readlink(path)] + elif stat.S_ISDIR(mode): + result[relative] = ['directory', stat.S_IMODE(mode)] + elif stat.S_ISREG(mode): + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + result[relative] = ['file', stat.S_IMODE(mode), path.stat().st_size, digest.hexdigest()] + else: + raise RuntimeError('unsupported special file: ' + relative) + return result + +def report(result): + encoded = json.dumps(result) + if len(sys.argv) <= 3: + Path('/dev/termination-log').write_text(encoded) + print(encoded) + +if action == 'migrate': + marker = target / receipt + if marker.exists(): + if json.loads(marker.read_text())['operation'] != operation: + raise RuntimeError('target belongs to another operation') + elif list(target.iterdir()): + raise RuntimeError('migration target is not empty') + else: + with marker.open('x') as out: + json.dump({'operation': operation}, out) + out.flush() + os.fsync(out.fileno()) + before = manifest(source) + for entry in target.iterdir(): + if entry.name == receipt: + continue + if entry.is_dir() and not entry.is_symlink(): + shutil.rmtree(entry) + else: + entry.unlink() + # Volume mount roots belong to the provisioner. fsGroup permits payload + # writes but does not authorize chmod/utime of that root. Copy its children + # so payload metadata is preserved without replacing mount-root metadata. + for entry in source.iterdir(): + if entry.name == receipt: + continue + destination = target / entry.name + if entry.is_dir() and not entry.is_symlink(): + shutil.copytree(entry, destination, symlinks=True) + else: + shutil.copy2(entry, destination, follow_symlinks=False) + os.sync() + after = manifest(source) + copied = manifest(target) + if before != after or before != copied: + raise RuntimeError('source changed or destination SHA256 manifest differs') + report({'operation': operation, 'verified': 'sha256-tree', + 'digest': hashlib.sha256(json.dumps(copied, sort_keys=True).encode()).hexdigest()}) +elif action == 'destroy': + for entry in source.iterdir(): + if entry.is_dir() and not entry.is_symlink(): + shutil.rmtree(entry) + else: + entry.unlink() + os.sync() + if list(source.iterdir()): + raise RuntimeError('data directory is not empty after destruction') + report({'operation': operation, 'verified': 'empty-filesystem'}) +else: + raise RuntimeError('unknown operation') diff --git a/pkg/framework/dataops/zz_generated.deepcopy.go b/pkg/framework/dataops/zz_generated.deepcopy.go new file mode 100644 index 00000000..ca62bb71 --- /dev/null +++ b/pkg/framework/dataops/zz_generated.deepcopy.go @@ -0,0 +1,354 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2024 ZNCDataDev. + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package dataops + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AssetStatus) DeepCopyInto(out *AssetStatus) { + *out = *in + if in.RetiredCopies != nil { + in, out := &in.RetiredCopies, &out.RetiredCopies + *out = make([]DataIdentity, len(*in)) + copy(*out, *in) + } + if in.Current != nil { + in, out := &in.Current, &out.Current + *out = new(DataIdentity) + **out = **in + } + if in.History != nil { + in, out := &in.History, &out.History + *out = make([]History, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AssetStatus. +func (in *AssetStatus) DeepCopy() *AssetStatus { + if in == nil { + return nil + } + out := new(AssetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Binding) DeepCopyInto(out *Binding) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Binding. +func (in *Binding) DeepCopy() *Binding { + if in == nil { + return nil + } + out := new(Binding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterRef) DeepCopyInto(out *ClusterRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRef. +func (in *ClusterRef) DeepCopy() *ClusterRef { + if in == nil { + return nil + } + out := new(ClusterRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataAsset) DeepCopyInto(out *DataAsset) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataAsset. +func (in *DataAsset) DeepCopy() *DataAsset { + if in == nil { + return nil + } + out := new(DataAsset) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataAsset) 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 *DataAssetList) DeepCopyInto(out *DataAssetList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DataAsset, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataAssetList. +func (in *DataAssetList) DeepCopy() *DataAssetList { + if in == nil { + return nil + } + out := new(DataAssetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataAssetList) 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 *DataIdentity) DeepCopyInto(out *DataIdentity) { + *out = *in + out.Cluster = in.Cluster + out.Binding = in.Binding + out.Source = in.Source +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataIdentity. +func (in *DataIdentity) DeepCopy() *DataIdentity { + if in == nil { + return nil + } + out := new(DataIdentity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataOperation) DeepCopyInto(out *DataOperation) { + *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 DataOperation. +func (in *DataOperation) DeepCopy() *DataOperation { + if in == nil { + return nil + } + out := new(DataOperation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataOperation) 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 *DataOperationList) DeepCopyInto(out *DataOperationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DataOperation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataOperationList. +func (in *DataOperationList) DeepCopy() *DataOperationList { + if in == nil { + return nil + } + out := new(DataOperationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataOperationList) 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 *History) DeepCopyInto(out *History) { + *out = *in + out.From = in.From + if in.To != nil { + in, out := &in.To, &out.To + *out = new(DataIdentity) + **out = **in + } + in.Completed.DeepCopyInto(&out.Completed) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new History. +func (in *History) DeepCopy() *History { + if in == nil { + return nil + } + out := new(History) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperationSpec) DeepCopyInto(out *OperationSpec) { + *out = *in + out.Source = in.Source + out.SourceCluster = in.SourceCluster + out.WorkerIdentity = in.WorkerIdentity + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(Target) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationSpec. +func (in *OperationSpec) DeepCopy() *OperationSpec { + if in == nil { + return nil + } + out := new(OperationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperationStatus) DeepCopyInto(out *OperationStatus) { + *out = *in + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(DataIdentity) + **out = **in + } + if in.Completed != nil { + in, out := &in.Completed, &out.Completed + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationStatus. +func (in *OperationStatus) DeepCopy() *OperationStatus { + if in == nil { + return nil + } + out := new(OperationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Options) DeepCopyInto(out *Options) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Options. +func (in *Options) DeepCopy() *Options { + if in == nil { + return nil + } + out := new(Options) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Source) DeepCopyInto(out *Source) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Source. +func (in *Source) DeepCopy() *Source { + if in == nil { + return nil + } + out := new(Source) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Target) DeepCopyInto(out *Target) { + *out = *in + out.Cluster = in.Cluster + out.Source = in.Source +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target. +func (in *Target) DeepCopy() *Target { + if in == nil { + return nil + } + out := new(Target) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkerIdentity) DeepCopyInto(out *WorkerIdentity) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkerIdentity. +func (in *WorkerIdentity) DeepCopy() *WorkerIdentity { + if in == nil { + return nil + } + out := new(WorkerIdentity) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/framework/definition.go b/pkg/framework/definition.go new file mode 100644 index 00000000..cc55d722 --- /dev/null +++ b/pkg/framework/definition.go @@ -0,0 +1,104 @@ +package framework + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// ProductDefinition declares product behavior once. Callbacks consume isolated +// effective values and return intent or checks; they do not perform cluster I/O. +type ProductDefinition[C, S, F any] struct { + ClusterConfigDefaults S + Name string + ImageDefaults ImageConfig + Roles map[string]RoleDefinition[C] + ValidateInput func(EffectiveInput[C, S, F]) error + GenerateGroup func(EffectiveInput[C, S, F]) (RuntimeDescription, error) + GenerateCluster func(ClusterOutputInput[S, F]) (ClusterOutput, error) + ValidateFinal func(FinalView) []Check +} + +// EffectiveInput carries resolved configuration and facts for one group. Input +// presence and the source projection remain outside this product-facing value. +type EffectiveInput[C, S, F any] struct { + Platform ClusterConfig + ClusterConfig S + Group GroupIdentity + Config Config[C] + Image ResolvedImage + Facts F + Topology []ResolvedGroup[C] +} + +// ResolvedGroup preserves declared topology when a sibling cannot resolve its +// configuration. Config is nil on failure; the declared identity remains present. +type ResolvedGroup[C any] struct { + Group GroupIdentity + Config *Config[C] + Error string +} + +type GroupOutcome struct { + Platform *PlatformObservation + Group GroupIdentity + GeneratedEndpoints []Endpoint // declarations, not observed ready endpoints + Error string + Facts *FactDiagnostic +} + +type ClusterOutputInput[S, F any] struct { + ClusterConfig S + Cluster ClusterIdentity + Shared F + Groups []GroupOutcome +} + +type ClusterOutputState string + +const ( + ClusterOutputReady ClusterOutputState = "Ready" + ClusterOutputPending ClusterOutputState = "Pending" +) + +// ClusterOutput distinguishes a complete shared-resource inventory from waiting +// for inputs. Ready with no ConfigMaps withdraws all previous shared outputs. +// Pending supplies a reason and no partial output. An error also preserves old +// outputs. Absence of the optional callback is a complete empty inventory. +type ClusterOutput struct { + State ClusterOutputState + ConfigMaps []corev1.ConfigMap + Reason string +} + +// ValidateClusterOutput checks the state/value contract only. Resource identity, +// ownership and application are checked by the framework's execution layers. +func ValidateClusterOutput(output ClusterOutput) error { + switch output.State { + case ClusterOutputReady: + return nil + case ClusterOutputPending: + if strings.TrimSpace(output.Reason) == "" { + return fmt.Errorf("pending cluster output requires a reason") + } + if len(output.ConfigMaps) != 0 { + return fmt.Errorf("pending cluster output cannot include partial ConfigMaps") + } + return nil + default: + return fmt.Errorf("cluster output must explicitly be Ready or Pending") + } +} + +// FinalView is an isolated view after overrides and assembly. Product checks +// report relationships; they cannot repair or replace the final resources. +type FinalView struct { + Generated RuntimeDescription + Files []File + // These are structural premises, not proof of execution or log delivery. + FilePreparationKnown bool + LogCollectionKnown bool + Pod corev1.PodTemplateSpec + Services []corev1.Service +} diff --git a/pkg/framework/definition_test.go b/pkg/framework/definition_test.go new file mode 100644 index 00000000..e8cdcf3b --- /dev/null +++ b/pkg/framework/definition_test.go @@ -0,0 +1,96 @@ +package framework_test + +import ( + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestClusterOutputRequiresExplicitCompleteOrPendingResult(t *testing.T) { + configMap := corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "discovery"}} + for _, test := range []struct { + name string + output framework.ClusterOutput + valid bool + }{ + {"missing-state", framework.ClusterOutput{}, false}, + {"unknown-state", framework.ClusterOutput{State: "Complete"}, false}, + {"ready-withdraws-all", framework.ClusterOutput{State: framework.ClusterOutputReady}, true}, + {"ready-empty-list", framework.ClusterOutput{ + State: framework.ClusterOutputReady, ConfigMaps: []corev1.ConfigMap{}}, true}, + {"ready-output", framework.ClusterOutput{ + State: framework.ClusterOutputReady, ConfigMaps: []corev1.ConfigMap{configMap}}, true}, + {"pending-preserves", framework.ClusterOutput{ + State: framework.ClusterOutputPending, Reason: "coordinator input unavailable"}, true}, + {"pending-with-partial-output", framework.ClusterOutput{ + State: framework.ClusterOutputPending, Reason: "waiting", ConfigMaps: []corev1.ConfigMap{configMap}}, false}, + {"pending-no-reason", framework.ClusterOutput{State: framework.ClusterOutputPending}, false}, + {"pending-blank-reason", framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: " \n"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + before := test.output + err := framework.ValidateClusterOutput(test.output) + if (err == nil) != test.valid { + t.Fatalf("valid=%t, got %v", test.valid, err) + } + if !reflect.DeepEqual(before, test.output) { + t.Fatal("validation modified a product result") + } + }) + } +} + +func TestProductDefinitionExposesValuesAndExplicitSharedResult(t *testing.T) { + type groupConfig struct{ Port int32 } + type clusterConfig struct{ Environment string } + type facts struct{ Ready bool } + definition := framework.ProductDefinition[groupConfig, clusterConfig, facts]{ + Name: "example", Roles: map[string]framework.RoleDefinition[groupConfig]{"workers": { + Config: framework.Config[groupConfig]{Product: groupConfig{Port: 8080}}, + }}, + GenerateGroup: func(in framework.EffectiveInput[groupConfig, clusterConfig, facts], + ) (framework.RuntimeDescription, error) { + return framework.RuntimeDescription{Main: framework.Process{Name: "example", Image: in.Image.Reference}, + Endpoints: []framework.Endpoint{{Name: "http", Port: in.Config.Product.Port}}, + LogOutputs: []framework.LogOutput{{Container: "example", Directory: "logs", RelativePath: "server.log"}}}, nil + }, + GenerateCluster: func(in framework.ClusterOutputInput[clusterConfig, facts]) (framework.ClusterOutput, error) { + if !in.Shared.Ready { + return framework.ClusterOutput{State: framework.ClusterOutputPending, Reason: "waiting for facts"}, nil + } + return framework.ClusterOutput{State: framework.ClusterOutputReady}, nil + }, + } + description, err := definition.GenerateGroup(framework.EffectiveInput[groupConfig, clusterConfig, facts]{ + Config: definition.Roles["workers"].Config, Image: framework.ResolvedImage{Reference: "example:1"}}) + if err != nil || description.Main.Image != "example:1" || description.Endpoints[0].Port != 8080 { + t.Fatalf("product declaration lost its effective data: %+v, %v", description, err) + } + for _, ready := range []bool{false, true} { + input := framework.ClusterOutputInput[clusterConfig, facts]{Shared: facts{Ready: ready}} + output, err := definition.GenerateCluster(input) + valid := framework.ValidateClusterOutput(output) + if err != nil || valid != nil || (output.State == framework.ClusterOutputReady) != ready { + t.Fatalf("shared output confused pending with an empty complete inventory: %+v, %v", output, err) + } + } +} + +func TestIdentityNamesRetainDeclaredComponents(t *testing.T) { + cluster := framework.ClusterIdentity{Name: "demo", Namespace: "products"} + group := framework.GroupIdentity{ClusterIdentity: cluster, Role: "workers", Name: "large", Replicas: 3} + role := framework.RoleIdentity{ClusterIdentity: cluster, Name: "workers"} + if group.ServiceName() != "demo-workers-large" || group.ServiceDNS() != "demo-workers-large.products.svc" || + role.PodDisruptionBudgetName() != "demo-workers-pdb" { + t.Fatal("identity helpers disagree with the established resource naming rule") + } + group.Name = strings.Repeat("x", 100) + if !strings.HasSuffix(group.ServiceName(), group.Name) { + t.Fatal("naming helper silently truncated a name instead of leaving validation to the pipeline") + } +} diff --git a/pkg/framework/doc.go b/pkg/framework/doc.go new file mode 100644 index 00000000..312222b3 --- /dev/null +++ b/pkg/framework/doc.go @@ -0,0 +1,7 @@ +// Package framework defines the product-facing contracts of the new operator +// framework. Products declare configuration and runtime intent; input generation, +// merging, resource assembly and reconciliation belong to separate packages. +// +// This package contains domain values and pure value helpers. It does not read +// Kubernetes resources, apply changes or expose the framework's execution plan. +package framework diff --git a/pkg/framework/facts.go b/pkg/framework/facts.go new file mode 100644 index 00000000..cc4648e6 --- /dev/null +++ b/pkg/framework/facts.go @@ -0,0 +1,67 @@ +package framework + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +// FactResource is a Kubernetes object accepted by the exact-read facts seam. +// It adds no write operations or dependency on a controller implementation. +type FactResource interface { + metav1.Object + runtime.Object +} + +// FactsReader permits exact object reads only. The framework records provenance +// and schedules refresh; a product resolver cannot write, list or add watches here. +type FactsReader interface { + Get(context.Context, types.NamespacedName, FactResource) error +} + +type FactState string + +const ( + FactsResolved FactState = "resolved" + FactsPending FactState = "pending" + FactsInvalid FactState = "invalid" + FactsReadError FactState = "readError" +) + +// FactObject records observed identity and freshness, never object contents. +type FactObject struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + UID string `json:"uid,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` +} + +type FactDiagnostic struct { + State FactState `json:"state"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Observed []FactObject `json:"observed,omitempty"` // supplied by the framework's tracked reader +} + +// FactResult contains a value only when Resolved. The framework owns Observed +// provenance and replaces any observations returned by a product resolver. +type FactResult[F any] struct { + Value *F + Diagnostic FactDiagnostic +} + +// FactInput carries folded intent and declared topology, not observed workloads. +// Shared is registration-level base data; the resolver supplies this group's Facts. +type FactInput[C, S, F any] struct { + Platform ClusterConfig + ClusterConfig S + Group GroupIdentity + Config Config[C] + Image ResolvedImage + Shared F + Topology []ResolvedGroup[C] +} diff --git a/pkg/framework/identity.go b/pkg/framework/identity.go new file mode 100644 index 00000000..56555475 --- /dev/null +++ b/pkg/framework/identity.go @@ -0,0 +1,36 @@ +package framework + +type ClusterIdentity struct { + Name string + Namespace string + // Labels are cluster metadata, not inherited product configuration. Each + // callback receives its own data copy from the framework. + Labels map[string]string +} + +type GroupIdentity struct { + ClusterIdentity `json:"cluster"` + Role string + Name string + Replicas int32 +} + +// ServiceName returns the canonical group name used by resource assembly. Name +// validity and collisions are checked by the pipeline; this helper never truncates. +func (g GroupIdentity) ServiceName() string { + return g.ClusterIdentity.Name + "-" + g.Role + "-" + g.Name +} + +// ServiceDNS describes a declared endpoint, not observed service availability. +func (g GroupIdentity) ServiceDNS() string { + return g.ServiceName() + "." + g.Namespace + ".svc" +} + +type RoleIdentity struct { + ClusterIdentity `json:"cluster"` + Name string +} + +func (r RoleIdentity) PodDisruptionBudgetName() string { + return r.ClusterIdentity.Name + "-" + r.Name + "-pdb" +} diff --git a/pkg/framework/input/clone.go b/pkg/framework/input/clone.go new file mode 100644 index 00000000..597dc815 --- /dev/null +++ b/pkg/framework/input/clone.go @@ -0,0 +1,68 @@ +package input + +import ( + "reflect" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// Clone copies generated presence data, preserving nil versus empty maps/slices +// and native Quantity/Duration/Affinity values. It is not a general clone for +// clients, closures, codecs or arbitrary Go state. Generated root methods copy +// Kubernetes metadata with its own DeepCopyInto implementation. +func Clone[T any](value T) T { + in := reflect.ValueOf(value) + if !in.IsValid() { + return value + } + return cloneValue(in).Interface().(T) +} + +func cloneValue(value reflect.Value) reflect.Value { + switch value.Type() { + case affinityType: + affinity := value.Interface().(corev1.Affinity) + return reflect.ValueOf(*affinity.DeepCopy()) + case durationType: + return value + case quantityType: + quantity := value.Interface().(resource.Quantity).DeepCopy() + return reflect.ValueOf(quantity) + } + switch value.Kind() { + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.New(value.Type().Elem()) + out.Elem().Set(cloneValue(value.Elem())) + return out + case reflect.Struct: + out := reflect.New(value.Type()).Elem() + for index := 0; index < value.NumField(); index++ { + out.Field(index).Set(cloneValue(value.Field(index))) + } + return out + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.MakeMapWithSize(value.Type(), value.Len()) + for iterator := value.MapRange(); iterator.Next(); { + out.SetMapIndex(iterator.Key(), cloneValue(iterator.Value())) + } + return out + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for index := 0; index < value.Len(); index++ { + out.Index(index).Set(cloneValue(value.Index(index))) + } + return out + default: + return value + } +} diff --git a/pkg/framework/input/contract.go b/pkg/framework/input/contract.go new file mode 100644 index 00000000..2d6dccbb --- /dev/null +++ b/pkg/framework/input/contract.go @@ -0,0 +1,113 @@ +package input + +import ( + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +// ContractVersion versions the generated binding, separately from SDK releases. +// Increment it when generated inputs and their runtime contract become incompatible. +const ContractVersion = 1 + +// CheckVersion rejects a binding from an incompatible generator contract. +func CheckVersion(version int) error { + if version != ContractVersion { + return fmt.Errorf("generated input contract version %d is incompatible with version %d; regenerate inputs", version, ContractVersion) + } + return nil +} + +// Object is the Kubernetes object shape required by generated root bindings. +type Object interface { + metav1.Object + runtime.Object +} + +// Binding is emitted by inputgen. It does not expose the reconciler or a staged +// resource pipeline. Operation is separate so pause does not depend on Project. +type Binding[CR Object] struct { + Version int + Roles []string + AddToScheme func(*runtime.Scheme) error + NewObject func() CR + Operation func(CR) framework.ClusterOperation + Project func(CR) (Projection, error) + Status func(CR) *framework.ReconcileStatus +} + +// Projection is an isolated raw CR snapshot. Replicas retain presence; neither +// role inheritance nor defaults are applied here. Empty roles are not discarded. +// Runtime facts and resolved values belong to the internal pipeline, not this ABI. +type Projection struct { + Cluster framework.ClusterIdentity + Image json.RawMessage + ClusterConfig json.RawMessage + Roles []Role +} + +// Role carries management and workload input in separate raw blocks. +type Role struct { + Name string + Replicas *int32 + Config json.RawMessage + RoleConfig json.RawMessage + Overrides *Overrides + Groups []Group +} + +// Group contains exactly the user's group layer, without inherited role fields. +type Group struct { + Name string + Replicas *int32 + Config json.RawMessage + Overrides *Overrides +} + +// ImageInput holds presence independently for image source and pull settings. +type ImageInput struct { + Custom *string `json:"custom,omitempty"` + Repo *string `json:"repo,omitempty"` + ProductVersion *string `json:"productVersion,omitempty"` + KubedoopVersion *string `json:"kubedoopVersion,omitempty"` + PullPolicy *corev1.PullPolicy `json:"pullPolicy,omitempty"` + PullSecretName *string `json:"pullSecretName,omitempty"` +} + +type RoleConfigInput struct { + PodDisruptionBudget *PodDisruptionBudgetInput `json:"podDisruptionBudget,omitempty"` +} + +type PodDisruptionBudgetInput struct { + Enabled *bool `json:"enabled,omitempty"` + MaxUnavailable *int32 `json:"maxUnavailable,omitempty"` +} + +// Overrides groups the wire's flat channels after projection. A nil channel is +// absent; CLIOverrides pointing to an empty slice explicitly clears arguments. +type Overrides struct { + ConfigOverrides map[string]FileOverride `json:"configOverrides,omitempty"` + EnvOverrides map[string]string `json:"envOverrides,omitempty"` + CLIOverrides *[]string `json:"cliOverrides,omitempty"` + PodOverrides json.RawMessage `json:"podOverrides,omitempty"` +} + +// FileOverride expresses one file action. Execution and cross-file validation +// belong to the pipeline, not the generated input binding. +type FileOverride struct { + Properties *PropertyOverride `json:"properties,omitempty"` + Lines *[]string `json:"lines,omitempty"` + Text *string `json:"text,omitempty"` + Remove *bool `json:"remove,omitempty"` +} + +type PropertyOverride struct { + Set *map[string]string `json:"set,omitempty"` + Remove *[]string `json:"remove,omitempty"` + Replace *map[string]string `json:"replace,omitempty"` +} diff --git a/pkg/framework/input/decode.go b/pkg/framework/input/decode.go new file mode 100644 index 00000000..8847d73e --- /dev/null +++ b/pkg/framework/input/decode.go @@ -0,0 +1,241 @@ +package input + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "time" + "unicode/utf8" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + kubernetesjson "sigs.k8s.io/json" +) + +const ( + clusterConfigField = "clusterConfig" + jsonNull = "null" +) + +// DecodeJSON strictly decodes a generated root object. It rejects ambiguity +// before native codecs can normalize it, and leaves into unchanged on failure. +// A typed API-server GET is the other input path; API unknown-field pruning is +// not the same contract as this local rejection. +func DecodeJSON(data []byte, into any) error { + destination := reflect.ValueOf(into) + if !destination.IsValid() || destination.Kind() != reflect.Pointer || destination.IsNil() || + destination.Elem().Kind() != reflect.Struct { + return fmt.Errorf("generated input must be a non-nil pointer to a struct") + } + typ := destination.Elem().Type() + field, ok := typ.FieldByName("Spec") + if !ok || !field.IsExported() { + return fmt.Errorf("generated input must declare exported Spec") + } + document, err := decodeObject(data, "input") + if err != nil { + return err + } + spec, ok := document["spec"] + if !ok { + return fmt.Errorf("spec is required") + } + if err := validateJSON(spec, field.Type, "spec"); err != nil { + return err + } + value := reflect.New(typ) + strict, err := kubernetesjson.UnmarshalStrict(data, value.Interface()) + if failure := errors.Join(append(strict, err)...); failure != nil { + return failure + } + destination.Elem().Set(value.Elem()) + return nil +} + +// ConfigJSON preserves absence versus an explicitly empty object. Generated +// callers pass only presence input types after DecodeJSON or a typed API read. +func ConfigJSON[T any](config *T) (json.RawMessage, error) { + if config == nil { + return nil, nil + } + data, err := json.Marshal(config) + if err != nil { + return nil, err + } + if err := rejectNulls(data, "config"); err != nil { + return nil, err + } + return data, nil +} + +// ClusterConfigJSON strips fixed controls from the product cluster layer. +// Operation is generated independently and never calls this function. +func ClusterConfigJSON(value any) (json.RawMessage, error) { + if value == nil || (reflect.ValueOf(value).Kind() == reflect.Pointer && reflect.ValueOf(value).IsNil()) { + return nil, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("clusterConfig: %w", err) + } + values, err := decodeObject(data, clusterConfigField) + if err != nil { + return nil, err + } + if err := rejectNulls(data, clusterConfigField); err != nil { + return nil, err + } + for _, name := range []string{"stopped", "reconciliationPaused"} { + if raw, present := values[name]; present { + var flag bool + if err := json.Unmarshal(raw, &flag); err != nil { + return nil, fmt.Errorf("clusterConfig.%s: %w", name, err) + } + delete(values, name) + } + } + return json.Marshal(values) +} + +func decodeObject(data json.RawMessage, path string) (map[string]json.RawMessage, error) { + var values map[string]json.RawMessage + strict, err := kubernetesjson.UnmarshalStrict(data, &values) + if failure := errors.Join(append(strict, err)...); failure != nil { + return nil, fmt.Errorf("%s: %w", path, failure) + } + if values == nil { + return nil, fmt.Errorf("%s must be an object", path) + } + return values, nil +} + +func validateJSON(data json.RawMessage, typ reflect.Type, path string) error { + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if strings.TrimSpace(string(data)) == jsonNull { + return fmt.Errorf("%s: null is not an inheritance or deletion operation", path) + } + if handled, err := validateNative(data, typ, path); handled { + return err + } + switch typ.Kind() { + case reflect.Struct, reflect.Map: + return validateObjectFields(data, typ, path) + case reflect.Slice: + var values []json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return fmt.Errorf("%s: expected an array", path) + } + for index, value := range values { + if err := validateJSON(value, typ.Elem(), fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + default: + if err := json.Unmarshal(data, reflect.New(typ).Interface()); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return nil +} + +func validateObjectFields(data json.RawMessage, typ reflect.Type, path string) error { + values, err := decodeObject(data, path) + if err != nil { + return err + } + var fields map[string]reflect.Type + if typ.Kind() == reflect.Struct { + fields, err = jsonFields(typ) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + for _, key := range sortedKeys(values) { + var child reflect.Type + if typ.Kind() == reflect.Map { + child = typ.Elem() + } else if child = fields[key]; child == nil { + return fmt.Errorf("%s[%q]: unknown field", path, key) + } + if err := validateJSON(values[key], child, fmt.Sprintf("%s[%q]", path, key)); err != nil { + return err + } + } + return nil +} + +func validateNative(data json.RawMessage, typ reflect.Type, path string) (bool, error) { + switch typ { + case reflect.TypeFor[json.RawMessage](): + // RawMessage is reserved for PodTemplate patches. Their nested null and + // $patch directives are valid; duplicate keys are still ambiguous. + var object map[string]any + strict, err := kubernetesjson.UnmarshalStrict(data, &object) + if failure := errors.Join(append(strict, err)...); failure != nil { + return true, fmt.Errorf("%s: %w", path, failure) + } + if object == nil { + return true, fmt.Errorf("%s: pod override must be an object", path) + } + case affinityType: + if err := rejectNulls(data, path); err != nil { + return true, err + } + var affinity corev1.Affinity + strict, err := kubernetesjson.UnmarshalStrict(data, &affinity) + if failure := errors.Join(append(strict, err)...); failure != nil { + return true, fmt.Errorf("%s: %w", path, failure) + } + case quantityType: + var value string + if err := json.Unmarshal(data, &value); err != nil { + return true, fmt.Errorf("%s: quantity must be a string", path) + } + if _, err := resource.ParseQuantity(value); err != nil { + return true, fmt.Errorf("%s: invalid quantity: %w", path, err) + } + case durationType: + var value string + if err := json.Unmarshal(data, &value); err != nil || utf8.RuneCountInString(value) > 128 { + return true, fmt.Errorf("%s: duration must be a string of at most 128 characters", path) + } + if _, err := time.ParseDuration(value); err != nil { + return true, fmt.Errorf("%s: invalid duration: %w", path, err) + } + default: + return false, nil + } + return true, nil +} + +func rejectNulls(data json.RawMessage, path string) error { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return err + } + return rejectNullValue(value, path) +} + +func rejectNullValue(value any, path string) error { + switch node := value.(type) { + case nil: + return fmt.Errorf("%s: explicit null is not allowed", path) + case map[string]any: + for _, key := range sortedKeys(node) { + if err := rejectNullValue(node[key], fmt.Sprintf("%s[%q]", path, key)); err != nil { + return err + } + } + case []any: + for index, child := range node { + if err := rejectNullValue(child, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/framework/input/doc.go b/pkg/framework/input/doc.go new file mode 100644 index 00000000..7e886ea5 --- /dev/null +++ b/pkg/framework/input/doc.go @@ -0,0 +1,5 @@ +// Package input defines the generated CR input contract. Product authors use +// framework values; generated APIs use this package to preserve raw presence, +// project the declared inventory and access operation/status independently. +// It contains no configuration folding, external facts, resource plan or IO. +package input diff --git a/pkg/framework/input/input_test.go b/pkg/framework/input/input_test.go new file mode 100644 index 00000000..60a99edb --- /dev/null +++ b/pkg/framework/input/input_test.go @@ -0,0 +1,141 @@ +package input_test + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +type localSpec struct { + Enabled *bool `json:"enabled,omitempty"` + Args *[]string `json:"args,omitempty"` + CPU *resource.Quantity `json:"cpu,omitempty"` + Timeout *metav1.Duration `json:"timeout,omitempty"` + Affinity *corev1.Affinity `json:"affinity,omitempty"` + Pod json.RawMessage `json:"pod,omitempty"` +} + +type localCR struct { + Spec localSpec `json:"spec"` +} + +func TestDecodePreservesPresenceAndNativePatch(t *testing.T) { + var object localCR + data := []byte(`{"spec":{"enabled":false,"args":[],"cpu":"250m","timeout":"0s",` + + `"affinity":{"nodeAffinity":{}},"pod":{"spec":{"containers":[{"name":"main","env":null,"$patch":"replace"}]}}}}`) + if err := input.DecodeJSON(data, &object); err != nil { + t.Fatal(err) + } + if object.Spec.Enabled == nil || *object.Spec.Enabled || object.Spec.Args == nil || + *object.Spec.Args == nil || len(*object.Spec.Args) != 0 || object.Spec.Affinity.NodeAffinity == nil { + t.Fatalf("explicit values were lost: %#v", object.Spec) + } + if object.Spec.CPU.Cmp(resource.MustParse("250m")) != 0 || object.Spec.Timeout.Duration != 0 || + !strings.Contains(string(object.Spec.Pod), `"env":null`) { + t.Fatalf("native input changed: %#v", object.Spec) + } + before := input.Clone(object) + for _, data := range []string{ + `{"spec":{"enabled":true,"enabled":false}}`, + `{"spec":{"Enabled":true}}`, + `{"spec":{"cpu":0.5}}`, + `{"spec":{"args":null}}`, + `{"spec":{"affinity":{"nodeAffinity":null}}}`, + `{"spec":{"timeout":"not-a-duration"}}`, + `{"spec":{"pod":{"spec":{"hostNetwork":true,"hostNetwork":false}}}}`, + } { + if err := input.DecodeJSON([]byte(data), &object); err == nil { + t.Errorf("ambiguous input accepted: %s", data) + } + if !reflect.DeepEqual(before, object) { + t.Fatalf("failed decode changed the previous object: %s", data) + } + } +} + +func TestCloneIsolatesCollectionsAndNativeValues(t *testing.T) { + original := struct { + Values map[string]*[]string + CPU resource.Quantity + Wait metav1.Duration + Rules corev1.Affinity + }{ + Values: map[string]*[]string{"empty": ptr.To([]string{}), "list": ptr.To([]string{"original"}), "absent": nil}, + CPU: resource.MustParse("123456789012345678901m"), + Wait: metav1.Duration{Duration: time.Second}, + Rules: corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{{Weight: 1}}, + }}, + } + copy := input.Clone(original) + (*copy.Values["list"])[0] = "changed" + copy.CPU.Add(resource.MustParse("1")) + copy.Rules.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 10 + if (*original.Values["list"])[0] != "original" || original.CPU.Cmp(copy.CPU) == 0 || + original.Rules.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight != 1 { + t.Fatal("clone retained mutable aliases") + } + if copy.Values["empty"] == nil || *copy.Values["empty"] == nil || copy.Values["absent"] != nil || copy.Wait != original.Wait { + t.Fatal("clone lost collection presence or native duration") + } +} + +func TestRawConfigSeparatesOperationsAndPresence(t *testing.T) { + type cluster struct { + Stopped *bool `json:"stopped,omitempty"` + Paused *bool `json:"reconciliationPaused,omitempty"` + Label *string `json:"label,omitempty"` + } + data, err := input.ClusterConfigJSON(&cluster{Stopped: ptr.To(true), Paused: ptr.To(false), Label: ptr.To("")}) + if err != nil || string(data) != `{"label":""}` { + t.Fatalf("product projection includes controls or lost value: %s, %v", data, err) + } + absent, err := input.ConfigJSON[cluster](nil) + if err != nil || absent != nil { + t.Fatalf("absent config changed: %s, %v", absent, err) + } + empty, err := input.ConfigJSON(&cluster{}) + if err != nil || string(empty) != `{}` { + t.Fatalf("empty object changed: %s, %v", empty, err) + } +} + +type recursive map[string]recursive +type encoded string + +func (encoded) MarshalText() ([]byte, error) { return []byte("custom"), nil } + +func TestProductProfileRejectsUnsupportedRepresentations(t *testing.T) { + for _, typ := range []reflect.Type{ + nil, reflect.TypeFor[*string](), reflect.TypeFor[any](), reflect.TypeFor[[]byte](), + reflect.TypeFor[map[int]string](), reflect.TypeFor[recursive](), reflect.TypeFor[encoded](), + reflect.TypeFor[struct{ Embedded localSpec }](), + } { + if err := input.ValidateProductType(typ); err == nil { + t.Errorf("unsupported product type accepted: %v", typ) + } + } + if err := input.ValidateProductType(reflect.TypeFor[struct { + Enabled bool `json:"enabled"` + Values map[string][]int32 `json:"values"` + CPU resource.Quantity `json:"cpu"` + Wait metav1.Duration `json:"wait"` + }]()); err != nil { + t.Fatal(err) + } + if err := input.CheckVersion(input.ContractVersion); err != nil { + t.Fatal(err) + } + if err := input.CheckVersion(input.ContractVersion + 1); err == nil { + t.Fatal("incompatible generated contract accepted") + } +} diff --git a/pkg/framework/input/profile.go b/pkg/framework/input/profile.go new file mode 100644 index 00000000..e5e86573 --- /dev/null +++ b/pkg/framework/input/profile.go @@ -0,0 +1,124 @@ +package input + +import ( + "encoding" + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + quantityType = reflect.TypeFor[resource.Quantity]() + durationType = reflect.TypeFor[metav1.Duration]() + affinityType = reflect.TypeFor[corev1.Affinity]() +) + +// ValidateProductType checks the fixed data profile shared by runtime input +// validation and generation. Top-level C/S naming and common-field collisions +// are checked by inputgen. No type can register a custom merge or wire codec. +func ValidateProductType(typ reflect.Type) error { + return checkProfile(typ, make(map[reflect.Type]bool), make(map[reflect.Type]bool)) +} + +func checkProfile(typ reflect.Type, active, checked map[reflect.Type]bool) error { + if typ == nil { + return fmt.Errorf("a concrete product data type is required") + } + if typ == quantityType || typ == durationType || checked[typ] { + return nil + } + if active[typ] { + return fmt.Errorf("%s: recursive product types are unsupported", typ) + } + active[typ] = true + defer delete(active, typ) + for _, codec := range []reflect.Type{ + reflect.TypeFor[json.Marshaler](), reflect.TypeFor[json.Unmarshaler](), + reflect.TypeFor[encoding.TextMarshaler](), reflect.TypeFor[encoding.TextUnmarshaler](), + } { + if typ.Implements(codec) || reflect.PointerTo(typ).Implements(codec) { + return fmt.Errorf("%s: custom codecs are unsupported", typ) + } + } + if err := checkProfileFields(typ, active, checked); err != nil { + return err + } + checked[typ] = true + return nil +} + +func checkProfileFields(typ reflect.Type, active, checked map[reflect.Type]bool) error { + switch typ.Kind() { + case reflect.Struct: + fields, err := jsonFields(typ) + if err != nil { + return err + } + for _, name := range sortedKeys(fields) { + if err := checkProfile(fields[name], active, checked); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + case reflect.Map: + if typ.Key().Kind() != reflect.String { + return fmt.Errorf("%s: map keys must be strings", typ) + } + if err := checkProfile(typ.Key(), active, checked); err != nil { + return err + } + return checkProfile(typ.Elem(), active, checked) + case reflect.Slice: + if typ.Elem().Kind() == reflect.Uint8 { + return fmt.Errorf("%s: byte slices are unsupported", typ) + } + return checkProfile(typ.Elem(), active, checked) + case reflect.Bool, reflect.String, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: + default: + return fmt.Errorf("%s: unsupported product data type", typ) + } + return nil +} + +func jsonFields(typ reflect.Type) (map[string]reflect.Type, error) { + fields := make(map[string]reflect.Type, typ.NumField()) + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + if !field.IsExported() || field.Anonymous { + return nil, fmt.Errorf("%s: fields must be public and non-embedded", typ) + } + tag := strings.Split(field.Tag.Get("json"), ",") + name := tag[0] + if name == "-" { + return nil, fmt.Errorf("%s.%s: ignored fields are unsupported", typ, field.Name) + } + if name == "" { + name = field.Name + } + for _, option := range tag[1:] { + if option != "omitempty" { + return nil, fmt.Errorf("%s.%s: unsupported JSON option %q", typ, field.Name, option) + } + } + if _, exists := fields[name]; exists { + return nil, fmt.Errorf("%s: duplicate JSON field %q", typ, name) + } + fields[name] = field.Type + } + return fields, nil +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} diff --git a/pkg/framework/inputgen/AGENTS.md b/pkg/framework/inputgen/AGENTS.md new file mode 100644 index 00000000..a03e96a8 --- /dev/null +++ b/pkg/framework/inputgen/AGENTS.md @@ -0,0 +1,47 @@ +# Framework input and registration generation + +Parent: [../AGENTS.md](../AGENTS.md). Design: [architecture](../../../docs/architecture.md#framework-design), sections 7–8. + +`Generate[C,S](Names, roles)` emits deterministic presence API and structural CRD +bytes. `Names.ImportPath` is an optional, explicit Go import path for the generated +API package, independent of output directories. Nonempty produces +`Artifacts.RegistrationSource` for a separate `registration` package; empty retains +API-only generation. Generate and Check never write files. + +Generated API imports framework/input and public domain/status values. It does +not import operator, internal execution packages or the original C/S packages. +The registration companion imports the API package, the original concrete C/S +types and framework/operator. It exposes `Options[F]` and +`Register(manager, definition, options) error`; C/S are fixed, F is inferred. +Products must keep their definition/types package independent from generated API +and registration packages to avoid import cycles. Static type names and import +aliases are checked and deterministic; external compilation remains required. + +Both source artifacts record an integer `InputContractVersion`. Companion Register +checks its own version before passing the API Binding to operator registration, +which checks the binding version. `Check(expected, actualGo, actualCRD, +actualRegistration...)` supports the existing API-only call. A generated companion +requires exactly one actual nonempty source; missing, extra, unsupported-version +or changed companion content is rejected. Unexpected companions are not ignored. + +The external-module fixture uses this checkout through replace and disables +network module lookup. It generates/compiles Trino and presence API companions, +then exercises real envtest persistence and a manager created through generated +Register. Catalog facts refresh from an external ConfigMap without CR edits; +missing/invalid references retain previous resources; updated/new-UID sources +reach actual generated catalog bytes after final file overrides. There is no +kubelet, product process or restarter in that test. + +Run `go test ./pkg/framework/inputgen`. An explicit invalid KUBEBUILDER_ASSETS must +fail; envtest skips only if neither explicit nor discovered assets exist. Full +root checks provide envtest assets, so API verification requires non-skipped output. + +Standard storage input is generated from `framework.Storage` with presence fields. +The CRD restricts the type to ephemeral/persistent and rejects class/capacity alongside +an explicit ephemeral type. Partial persistent fields are allowed for role inheritance; +final completeness is checked by the pipeline. No admission defaults are generated. + +The flat clusterConfig schema combines operation fields, framework.ClusterConfig +and product S with collision checks across all three. Generated status includes +PlatformObservation diagnostics and observed Listener addresses, with no admission +defaults or input inheritance rules applied to status. diff --git a/pkg/framework/inputgen/affinity_schema.go b/pkg/framework/inputgen/affinity_schema.go new file mode 100644 index 00000000..5f94bfd0 --- /dev/null +++ b/pkg/framework/inputgen/affinity_schema.go @@ -0,0 +1,57 @@ +package inputgen + +import ( + "fmt" + "reflect" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// AffinityCollectionLimit bounds the native scheduling collections in this +// generated CRD so nested null checks fit the API admission cost budget. It is +// a schema capacity boundary, not a product merge policy. +const AffinityCollectionLimit int64 = 16 + +// This traversal is reachable only from the fixed corev1.Affinity domain. It +// follows native optional pointers without admitting arbitrary product pointers +// or generating another set of Kubernetes Go types. Kubernetes still validates +// scheduling constraints when the final workload is admitted. +func affinitySchema() (apiextensionsv1.JSONSchemaProps, error) { + return affinityNodeSchema(affinityType) +} + +func affinityNodeSchema(typ reflect.Type) (apiextensionsv1.JSONSchemaProps, error) { + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + switch typ.Kind() { + case reflect.Struct: + fields, err := jsonFields(typ) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + properties := make(map[string]apiextensionsv1.JSONSchemaProps, len(fields)) + for _, name := range sortedKeys(fields) { + child, err := affinityNodeSchema(fields[name]) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, fmt.Errorf("%s: %w", name, err) + } + properties[name] = child + } + return nonNullObjectSchema(properties) + case reflect.Map: + child, err := affinityNodeSchema(typ.Elem()) + out := nonNullMapSchema(child) + limit := AffinityCollectionLimit + out.MaxProperties = &limit + return out, err + case reflect.Slice: + child, err := affinityNodeSchema(typ.Elem()) + out := nonNullListSchema(child) + limit := AffinityCollectionLimit + out.MaxItems = &limit + return out, err + default: + return schemaForType(typ, make(map[reflect.Type]bool)) + } +} diff --git a/pkg/framework/inputgen/check.go b/pkg/framework/inputgen/check.go new file mode 100644 index 00000000..76fbdc1b --- /dev/null +++ b/pkg/framework/inputgen/check.go @@ -0,0 +1,96 @@ +package inputgen + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + "strconv" + + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const contractVersionName = "InputContractVersion" + +// Check rejects unsupported generated contracts before comparing the current +// generated source and CRD. The caller owns reading and writing artifact files. +func Check(expected Artifacts, actualGo, actualCRD []byte, actualRegistration ...[]byte) error { + if err := checkRegistration(expected.RegistrationSource, actualRegistration); err != nil { + return err + } + for _, source := range [][]byte{expected.GoSource, actualGo} { + version, err := generatedVersion(source) + if err != nil { + return err + } + if err := input.CheckVersion(version); err != nil { + return err + } + } + if !bytes.Equal(expected.GoSource, actualGo) { + return fmt.Errorf("generated Go source differs; regenerate with the current inputgen") + } + if !bytes.Equal(expected.CRD, actualCRD) { + return fmt.Errorf("generated CRD differs; regenerate with the current inputgen") + } + return nil +} + +func generatedVersion(source []byte) (int, error) { + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", source, parser.SkipObjectResolution) + if err != nil { + return 0, fmt.Errorf("generated input contract: %w", err) + } + for _, declaration := range file.Decls { + decl, ok := declaration.(*ast.GenDecl) + if !ok || decl.Tok != token.CONST { + continue + } + for _, spec := range decl.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || value.Names[0].Name != contractVersionName || len(value.Values) != 1 { + continue + } + literal, ok := value.Values[0].(*ast.BasicLit) + if ok && literal.Kind == token.INT { + version, err := strconv.Atoi(literal.Value) + if err == nil { + return version, nil + } + } + return 0, fmt.Errorf("generated InputContractVersion must be an integer literal") + } + } + return 0, fmt.Errorf("generated input is missing InputContractVersion; regenerate legacy artifacts") +} + +// Optional only for API-only generation. A requested companion must be supplied, +// version-compatible and byte-identical; callers cannot accidentally omit it. +func checkRegistration(expected []byte, actual [][]byte) error { + if len(actual) > 1 { + return fmt.Errorf("at most one generated registration source is allowed") + } + if len(expected) == 0 { + if len(actual) == 1 && len(actual[0]) != 0 { + return fmt.Errorf("unexpected registration source for API-only generation") + } + return nil + } + if len(actual) != 1 || len(actual[0]) == 0 { + return fmt.Errorf("generated registration source is required; regenerate the companion") + } + for _, source := range [][]byte{expected, actual[0]} { + version, err := generatedVersion(source) + if err != nil { + return fmt.Errorf("registration: %w", err) + } + if err := input.CheckVersion(version); err != nil { + return fmt.Errorf("registration: %w", err) + } + } + if !bytes.Equal(expected, actual[0]) { + return fmt.Errorf("generated registration source differs; regenerate with the current inputgen") + } + return nil +} diff --git a/pkg/framework/inputgen/doc.go b/pkg/framework/inputgen/doc.go new file mode 100644 index 00000000..db9e0e2c --- /dev/null +++ b/pkg/framework/inputgen/doc.go @@ -0,0 +1,4 @@ +// Package inputgen generates presence-aware Kubernetes API types, versioned +// input bindings, structural CRD schemas and optional registration companions from +// product configuration types. The generator performs no cluster I/O. +package inputgen diff --git a/pkg/framework/inputgen/generate.go b/pkg/framework/inputgen/generate.go new file mode 100644 index 00000000..8b529134 --- /dev/null +++ b/pkg/framework/inputgen/generate.go @@ -0,0 +1,344 @@ +package inputgen + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/token" + "reflect" + "slices" + "strings" + "text/template" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/yaml" + + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// Names identifies the generated Go package and namespaced Kubernetes API. +type Names struct { + Package, Group, Version, Kind, Plural string + // ImportPath is the generated API package's Go import path. Nonempty enables + // an independent registration companion; output directory remains caller-owned. + ImportPath string +} + +// Artifacts contains deterministic source and CRD bytes; callers own file IO. +type Artifacts struct { + GoSource []byte + CRD []byte + RegistrationSource []byte +} + +type inputRoleName struct{ Wire, Go string } + +type inputTemplateData struct { + Names + Roles []inputRoleName + Types string + ContractVersion int +} + +// Generate emits a presence-aware API and CRD from group and cluster product +// types. An explicit ImportPath also emits a separate registration companion. +func Generate[C, S any](names Names, roles []string) (Artifacts, error) { + var out Artifacts + for _, typ := range []reflect.Type{reflect.TypeFor[C](), reflect.TypeFor[S]()} { + if err := validateRootReference(typ); err != nil { + return out, err + } + } + if !token.IsIdentifier(names.Package) || names.Package == "_" || !token.IsIdentifier(names.Kind) || + !ast.IsExported(names.Kind) || len(validation.IsDNS1123Subdomain(names.Group)) != 0 || + len(validation.IsDNS1035Label(names.Plural)) != 0 || len(validation.IsDNS1035Label(names.Version)) != 0 { + return out, fmt.Errorf("invalid generated package or API names") + } + roleNames, err := inputRoleNames(roles) + if err != nil { + return out, err + } + root, err := crdSchema[C, S](roles) + if err != nil { + return out, err + } + clusterTypes, err := emitClusterConfigTypes[S]() + if err != nil { + return out, err + } + types, err := emitConfigTypes[C]() + if err != nil { + return out, err + } + types += "\n" + clusterTypes + parsed, err := template.New("input").Parse(inputGoTemplate) + if err != nil { + return out, err + } + var raw bytes.Buffer + data := inputTemplateData{Names: names, Roles: roleNames, Types: types, ContractVersion: input.ContractVersion} + if err := parsed.Execute(&raw, data); err != nil { + return out, err + } + if err := validateGeneratedNames(raw.Bytes()); err != nil { + return out, err + } + out.GoSource, err = format.Source(raw.Bytes()) + if err != nil { + return out, fmt.Errorf("format generated input: %w", err) + } + crd := &apiextensionsv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition"}, + ObjectMeta: metav1.ObjectMeta{Name: names.Plural + "." + names.Group}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: names.Group, Scope: apiextensionsv1.NamespaceScoped, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Plural: names.Plural, Kind: names.Kind, ListKind: names.Kind + "List", + }, + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{ + Name: names.Version, Served: true, Storage: true, + Schema: &apiextensionsv1.CustomResourceValidation{OpenAPIV3Schema: &root}, + Subresources: &apiextensionsv1.CustomResourceSubresources{ + Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, + }, + }}, + }, + } + out.CRD, err = yaml.Marshal(crd) + if err != nil { + return out, err + } + if names.ImportPath != "" { + out.RegistrationSource, err = generateRegistration[C, S](names) + if err != nil { + return Artifacts{}, err + } + } + return out, nil +} + +func inputRoleNames(roles []string) ([]inputRoleName, error) { + if len(roles) == 0 { + return nil, fmt.Errorf("at least one role is required") + } + names := make([]inputRoleName, 0, len(roles)) + seen := map[string]bool{"Image": true, "ClusterConfig": true} + for _, role := range slices.Sorted(slices.Values(roles)) { + if role == imageInputField || role == clusterConfigInputField { + return nil, fmt.Errorf("role name %q is reserved for a fixed spec input", role) + } + if len(validation.IsDNS1123Label(role)) != 0 { + return nil, fmt.Errorf("invalid role name %q", role) + } + var field strings.Builder + for _, part := range strings.Split(role, "-") { + if part != "" { + field.WriteString(strings.ToUpper(part[:1]) + part[1:]) + } + } + name := field.String() + if !token.IsIdentifier(name) || !ast.IsExported(name) || seen[name] { + return nil, fmt.Errorf("role %q cannot produce a unique exported Go field", role) + } + seen[name] = true + names = append(names, inputRoleName{Wire: role, Go: name}) + } + return names, nil +} + +const inputGoTemplate = `// Code generated by operator-go inputgen; DO NOT EDIT. +package {{.Package}} + +import ( + "encoding/json" + "fmt" + "sort" + + 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/runtime/schema" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const InputContractVersion = {{.ContractVersion}} + +var GroupVersion = schema.GroupVersion{Group: {{printf "%q" .Group}}, Version: {{printf "%q" .Version}}} +var SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(GroupVersion, &{{.Kind}}{}, &{{.Kind}}List{}) + metav1.AddToGroupVersion(scheme, GroupVersion) + return nil +}) +var AddToScheme = SchemeBuilder.AddToScheme + +type {{.Kind}} struct { + metav1.TypeMeta ` + "`json:\",inline\"`" + ` + metav1.ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Spec SpecInput ` + "`json:\"spec\"`" + ` + Status framework.ReconcileStatus ` + "`json:\"status,omitempty\"`" + ` +} + +type {{.Kind}}List struct { + metav1.TypeMeta ` + "`json:\",inline\"`" + ` + metav1.ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items []{{.Kind}} ` + "`json:\"items\"`" + ` +} + +type SpecInput struct { + Image *input.ImageInput ` + "`json:\"image,omitempty\"`" + ` + ClusterConfig *ClusterConfigInput ` + "`json:\"clusterConfig,omitempty\"`" + ` +{{range .Roles}} {{.Go}} *RoleInput ` + "`json:\"{{.Wire}},omitempty\"`" + ` +{{end}}} + +type RoleInput struct { + RoleConfig *input.RoleConfigInput ` + "`json:\"roleConfig,omitempty\"`" + ` + Replicas *int32 ` + "`json:\"replicas,omitempty\"`" + ` + Config *ConfigInput ` + "`json:\"config,omitempty\"`" + ` + ConfigOverrides *map[string]input.FileOverride ` + "`json:\"configOverrides,omitempty\"`" + ` + EnvOverrides *map[string]string ` + "`json:\"envOverrides,omitempty\"`" + ` + CLIOverrides *[]string ` + "`json:\"cliOverrides,omitempty\"`" + ` + PodOverrides json.RawMessage ` + "`json:\"podOverrides,omitempty\"`" + ` + RoleGroups map[string]RoleGroupInput ` + "`json:\"roleGroups,omitempty\"`" + ` +} + +type RoleGroupInput struct { + Replicas *int32 ` + "`json:\"replicas,omitempty\"`" + ` + Config *ConfigInput ` + "`json:\"config,omitempty\"`" + ` + ConfigOverrides *map[string]input.FileOverride ` + "`json:\"configOverrides,omitempty\"`" + ` + EnvOverrides *map[string]string ` + "`json:\"envOverrides,omitempty\"`" + ` + CLIOverrides *[]string ` + "`json:\"cliOverrides,omitempty\"`" + ` + PodOverrides json.RawMessage ` + "`json:\"podOverrides,omitempty\"`" + ` +} + +{{.Types}} + +func (in *{{.Kind}}) DeepCopyInto(out *{{.Kind}}) { + *out = *in + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = input.Clone(in.Spec) + in.Status.DeepCopyInto(&out.Status) +} +func (in *{{.Kind}}) DeepCopy() *{{.Kind}} { + if in == nil { return nil } + out := new({{.Kind}}) + in.DeepCopyInto(out) + return out +} +func (in *{{.Kind}}) DeepCopyObject() runtime.Object { + if in == nil { return nil } + return in.DeepCopy() +} +func (in *{{.Kind}}List) DeepCopyInto(out *{{.Kind}}List) { + *out = *in + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]{{.Kind}}, len(in.Items)) + for i := range in.Items { in.Items[i].DeepCopyInto(&out.Items[i]) } + } +} +func (in *{{.Kind}}List) DeepCopy() *{{.Kind}}List { + if in == nil { return nil } + out := new({{.Kind}}List) + in.DeepCopyInto(out) + return out +} +func (in *{{.Kind}}List) DeepCopyObject() runtime.Object { + if in == nil { return nil } + return in.DeepCopy() +} + +// Decode is the strict local JSON entry point. A typed API-server GET is the +// other supported input path; ordinary json.Unmarshal alone loses null values. +func Decode(data []byte) (*{{.Kind}}, error) { + var out {{.Kind}} + if err := input.DecodeJSON(data, &out); err != nil { return nil, err } + return &out, nil +} + +// Operation reads only fixed controls, before projection or product validation. +// It neither marshals product fields nor changes the declared replica inventory. +func Operation(in *{{.Kind}}) framework.ClusterOperation { + var out framework.ClusterOperation + if in == nil || in.Spec.ClusterConfig == nil { return out } + controls := in.Spec.ClusterConfig + if controls.Stopped != nil { out.Stopped = *controls.Stopped } + if controls.ReconciliationPaused != nil { out.ReconciliationPaused = *controls.ReconciliationPaused } + return out +} + +// Project preserves raw layer presence and the complete declared role inventory. +// It neither folds replicas/config nor carries facts or runtime control state. +func Project(in *{{.Kind}}) (input.Projection, error) { + var out input.Projection + if in == nil { return out, fmt.Errorf("input CR is required") } + out.Cluster = framework.ClusterIdentity{ + Name: in.Name, Namespace: in.Namespace, Labels: input.Clone(in.Labels), + } + image, err := input.ConfigJSON(in.Spec.Image) + if err != nil { return out, fmt.Errorf("image: %w", err) } + out.Image = image + clusterConfig, err := input.ClusterConfigJSON(in.Spec.ClusterConfig) + if err != nil { return out, fmt.Errorf("clusterConfig: %w", err) } + out.ClusterConfig = clusterConfig + roles := []struct { name string; value *RoleInput }{ +{{range .Roles}} { {{printf "%q" .Wire}}, in.Spec.{{.Go}} }, +{{end}} } + for _, entry := range roles { + if entry.value == nil { continue } + role := entry.value + management, err := input.ConfigJSON(role.RoleConfig) + if err != nil { return out, fmt.Errorf("%s.roleConfig: %w", entry.name, err) } + config, err := input.ConfigJSON(role.Config) + if err != nil { return out, fmt.Errorf("%s.config: %w", entry.name, err) } + projected := input.Role{Name: entry.name, Replicas: input.Clone(role.Replicas), + Config: config, RoleConfig: management, + Overrides: inputOverrides(role.ConfigOverrides, role.EnvOverrides, role.CLIOverrides, role.PodOverrides), + } + groups := make([]string, 0, len(role.RoleGroups)) + for name := range role.RoleGroups { groups = append(groups, name) } + sort.Strings(groups) + for _, name := range groups { + group := role.RoleGroups[name] + config, err := input.ConfigJSON(group.Config) + if err != nil { return out, fmt.Errorf("%s/%s.config: %w", entry.name, name, err) } + projected.Groups = append(projected.Groups, input.Group{ + Name: name, Replicas: input.Clone(group.Replicas), Config: config, + Overrides: inputOverrides( + group.ConfigOverrides, group.EnvOverrides, group.CLIOverrides, group.PodOverrides), + }) + } + out.Roles = append(out.Roles, projected) + } + return out, nil +} + +// Binding is the versioned generated-code bridge, not a product controller. +func Binding() input.Binding[*{{.Kind}}] { + return input.Binding[*{{.Kind}}]{ + Version: InputContractVersion, + Roles: []string{ {{range .Roles}}{{printf "%q" .Wire}},{{end}} }, + AddToScheme: AddToScheme, + NewObject: func() *{{.Kind}} { return &{{.Kind}}{} }, + Operation: Operation, Project: Project, + Status: func(in *{{.Kind}}) *framework.ReconcileStatus { return &in.Status }, + } +} + +func inputOverrides( + files *map[string]input.FileOverride, env *map[string]string, cli *[]string, pod json.RawMessage, +) *input.Overrides { + if files == nil && env == nil && cli == nil && len(pod) == 0 { return nil } + out := &input.Overrides{ + CLIOverrides: input.Clone(cli), PodOverrides: input.Clone(pod), + } + if files != nil { out.ConfigOverrides = input.Clone(*files) } + if env != nil { out.EnvOverrides = input.Clone(*env) } + return out +} +` diff --git a/pkg/framework/inputgen/generate_test.go b/pkg/framework/inputgen/generate_test.go new file mode 100644 index 00000000..3240b439 --- /dev/null +++ b/pkg/framework/inputgen/generate_test.go @@ -0,0 +1,162 @@ +package inputgen + +import ( + "bytes" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +type ProductConfig struct { + Port int32 `json:"port"` + Enabled bool `json:"enabled"` + Args []string `json:"args"` + Labels map[string]string `json:"labels"` +} + +type ProductClusterConfig struct { + Environment string `json:"environment"` +} + +func artifactNames() Names { + return Names{Package: "generated", Group: "example.test", Version: "v1alpha1", + Kind: "ExampleCluster", Plural: "exampleclusters"} +} + +func generatedArtifacts(t *testing.T) Artifacts { + t.Helper() + artifacts, err := Generate[ProductConfig, ProductClusterConfig](artifactNames(), []string{"workers", "coordinators"}) + if err != nil { + t.Fatal(err) + } + return artifacts +} + +func TestGenerateDeterministicContract(t *testing.T) { + a := generatedArtifacts(t) + b, err := Generate[ProductConfig, ProductClusterConfig](artifactNames(), []string{"coordinators", "workers"}) + if err != nil || !bytes.Equal(a.GoSource, b.GoSource) || !bytes.Equal(a.CRD, b.CRD) { + t.Fatalf("role declaration order changed artifacts: %v", err) + } + fields := string(bytes.Join(bytes.Fields(a.GoSource), []byte(" "))) + for _, expected := range []string{ + "const InputContractVersion = 1", "Port *int32", "Enabled *bool", "Args *[]string", + "Labels *map[string]string", "func Project(in *ExampleCluster) (input.Projection, error)", + "func Binding() input.Binding[*ExampleCluster]", "func Operation(in *ExampleCluster) framework.ClusterOperation", + } { + if !strings.Contains(fields, expected) { + t.Errorf("generated source lacks %q", expected) + } + } + for _, forbidden := range []string{ + "docs/discussions", "internal/framework", "SourceSnapshot", "Snapshot[", "Register[", + } { + if bytes.Contains(a.GoSource, []byte(forbidden)) { + t.Errorf("generated API depends on %q", forbidden) + } + } + if bytes.Contains(a.CRD, []byte("default:")) { + t.Fatal("admission defaults erase inheritance presence") + } +} + +func TestGenerateRejectsAmbiguousNames(t *testing.T) { + for _, roles := range [][]string{ + {"foo-bar", "foo--bar"}, {"workers", "workers"}, {"9role"}, nil, + {"image"}, {"clusterConfig"}, {"cluster-config"}, + } { + if _, err := Generate[ProductConfig, struct{}](artifactNames(), roles); err == nil { + t.Fatalf("invalid roles accepted: %v", roles) + } + } + for _, kind := range []string{"ConfigInput", "SpecInput", "Project", "Binding", contractVersionName} { + names := artifactNames() + names.Kind = kind + if _, err := Generate[ProductConfig, struct{}](names, []string{"workers"}); err == nil { + t.Fatalf("conflicting kind %q accepted", kind) + } + } +} + +type privateRoot struct{ Value bool } +type GenericRoot[T any] struct{ Value T } +type NativeAffinityProduct struct{ Affinity corev1.Affinity } +type PointerProduct struct{ Value *bool } + +func TestGenerateRejectsUnpublishableRoots(t *testing.T) { + for _, generate := range []func(Names, []string) (Artifacts, error){ + Generate[privateRoot, struct{}], Generate[GenericRoot[bool], struct{}], + Generate[struct{ Value bool }, struct{}], Generate[struct{}, privateRoot], + Generate[PointerProduct, struct{}], Generate[NativeAffinityProduct, struct{}], + } { + if _, err := generate(artifactNames(), []string{"workers"}); err == nil { + t.Fatal("unpublishable or unsupported product root accepted") + } + } + if _, err := Generate[struct{}, struct{}](artifactNames(), []string{"workers"}); err != nil { + t.Fatalf("empty product domains rejected: %v", err) + } +} + +type CollidingClusterConfig struct { + Stopped string `json:"productStopped"` +} + +func TestGenerateClusterOperationCollision(t *testing.T) { + _, err := Generate[ProductConfig, CollidingClusterConfig](artifactNames(), []string{"workers"}) + if err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("operation Go name collision accepted: %v", err) + } +} + +func TestCheckRejectsLegacyAndChangedArtifacts(t *testing.T) { + expected := generatedArtifacts(t) + if err := Check(expected, expected.GoSource, expected.CRD); err != nil { + t.Fatal(err) + } + for _, scenario := range []struct { + name, want string + goSource []byte + crd []byte + }{ + {"legacy", "missing InputContractVersion", []byte("package generated\n"), expected.CRD}, + {"old contract", "incompatible", bytes.Replace(expected.GoSource, + []byte("InputContractVersion = 1"), []byte("InputContractVersion = 0"), 1), expected.CRD}, + {"expression", "integer literal", bytes.Replace(expected.GoSource, + []byte("InputContractVersion = 1"), []byte("InputContractVersion = 1 + 0"), 1), expected.CRD}, + {"source drift", "Go source differs", append(bytes.Clone(expected.GoSource), '\n'), expected.CRD}, + {"CRD drift", "CRD differs", expected.GoSource, append(bytes.Clone(expected.CRD), '\n')}, + } { + t.Run(scenario.name, func(t *testing.T) { + err := Check(expected, scenario.goSource, scenario.crd) + if err == nil || !strings.Contains(err.Error(), scenario.want) { + t.Fatalf("want %q, got %v", scenario.want, err) + } + }) + } +} + +func TestSchemaNativeDomainsRetainTheirBoundaries(t *testing.T) { + schema, err := configSchema[ProductConfig]() + if err != nil { + t.Fatal(err) + } + affinity := schema.Properties["affinity"] + terms := affinity.Properties["nodeAffinity"].Properties["requiredDuringSchedulingIgnoredDuringExecution"]. + Properties["nodeSelectorTerms"] + if terms.MaxItems == nil || *terms.MaxItems != AffinityCollectionLimit || + schema.Properties["labels"].MaxProperties == nil || *schema.Properties["labels"].MaxProperties != CollectionLimit { + t.Fatal("native and ordinary collection capacities were combined") + } + duration := schema.Properties["gracefulShutdownTimeout"] + if duration.MaxLength == nil || *duration.MaxLength != 128 || len(duration.XValidations) != 1 || + duration.XValidations[0].Rule != "duration(self) == duration(self)" { + t.Fatal("duration schema must check syntax without imposing effective shutdown policy") + } + declaration, err := emitConfigTypes[ProductConfig]() + if err != nil || !strings.Contains(declaration, "*corev1.Affinity") || + !strings.Contains(declaration, "*metav1.Duration") { + t.Fatalf("native presence types lost: %v", err) + } +} diff --git a/pkg/framework/inputgen/integration_test.go b/pkg/framework/inputgen/integration_test.go new file mode 100644 index 00000000..e212914e --- /dev/null +++ b/pkg/framework/inputgen/integration_test.go @@ -0,0 +1,100 @@ +package inputgen_test + +import ( + "context" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +// Generate and consume the SDK from outside its module path. This catches Go +// internal visibility and import dependencies that in-module fixtures conceal. +func TestExternalConsumerGenerationAndAPIRoundtrip(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate the SDK checkout") + } + root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + consumer := t.TempDir() + copyConsumerFixture(t, filepath.Join(filepath.Dir(file), "testdata", "consumer"), consumer) + module, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + t.Fatal(err) + } + const sdk = "github.com/zncdatadev/operator-go" + moduleText := strings.Replace(string(module), "module "+sdk, "module example.com/framework-consumer", 1) + moduleText += "\nrequire " + sdk + " v0.0.0\nreplace " + sdk + " => " + strconv.Quote(root) + "\n" + if err := os.WriteFile(filepath.Join(consumer, "go.mod"), []byte(moduleText), 0o600); err != nil { + t.Fatal(err) + } + sums, err := os.ReadFile(filepath.Join(root, "go.sum")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(consumer, "go.sum"), sums, 0o600); err != nil { + t.Fatal(err) + } + environment := externalConsumerEnvironment(root) + runConsumerGo(t, consumer, environment, "run", "-mod=readonly", "./cmd/generate") + runConsumerGo(t, consumer, environment, "test", "-mod=readonly", "-count=1", "-timeout=90s", "-v", "./...") +} + +func copyConsumerFixture(t *testing.T, source, destination string) { + t.Helper() + err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o700) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o600) + }) + if err != nil { + t.Fatal(err) + } +} + +func externalConsumerEnvironment(root string) []string { + result := make([]string, 0, len(os.Environ())+5) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + switch key { + case "GOWORK", "GOPROXY", "GOSUMDB", "GOFLAGS", "FRAMEWORK_ENVTEST_ASSETS": + continue + } + result = append(result, entry) + } + assets := filepath.Join(root, "bin", "k8s", "1.35.0-"+runtime.GOOS+"-"+runtime.GOARCH) + return append(result, "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=", + "FRAMEWORK_ENVTEST_ASSETS="+assets) +} + +func runConsumerGo(t *testing.T, directory string, environment []string, arguments ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", arguments...) + command.Dir, command.Env = directory, environment + command.WaitDelay = 5 * time.Second + output, err := command.CombinedOutput() + t.Logf("external consumer: go %s\n%s", strings.Join(arguments, " "), output) + if err != nil { + t.Fatalf("external consumer failed: %v (context=%v)", err, ctx.Err()) + } +} diff --git a/pkg/framework/inputgen/names.go b/pkg/framework/inputgen/names.go new file mode 100644 index 00000000..5e40e15b --- /dev/null +++ b/pkg/framework/inputgen/names.go @@ -0,0 +1,82 @@ +package inputgen + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path" + "strconv" +) + +// validateGeneratedNames checks the declarations and imports of this generator's +// fixed template. Formatting validates syntax but does not detect a requested CR +// kind that shadows ConfigInput, a nested input type, or an imported package. +func validateGeneratedNames(source []byte) error { + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", source, parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("parse generated input: %w", err) + } + names := make(map[string]string) + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return err + } + // All unaliased imports in the fixed template use their final path + // segment as package name. Package declarations themselves bind no name. + name := path.Base(importPath) + if spec.Name != nil { + name = spec.Name.Name + } + if err := reserveGeneratedName(names, name, "import "+importPath); err != nil { + return err + } + } + for _, declaration := range file.Decls { + switch item := declaration.(type) { + case *ast.FuncDecl: + // Methods have receiver scope. Go also permits multiple init functions. + if item.Recv != nil || item.Name.Name == "init" { + continue + } + if err := reserveGeneratedName(names, item.Name.Name, "function "+item.Name.Name); err != nil { + return err + } + case *ast.GenDecl: + if err := reserveGeneratedSpecs(names, item.Specs); err != nil { + return err + } + } + } + return nil +} + +func reserveGeneratedSpecs(names map[string]string, specs []ast.Spec) error { + for _, spec := range specs { + switch item := spec.(type) { + case *ast.TypeSpec: + if err := reserveGeneratedName(names, item.Name.Name, "type "+item.Name.Name); err != nil { + return err + } + case *ast.ValueSpec: + for _, name := range item.Names { + if err := reserveGeneratedName(names, name.Name, "value "+name.Name); err != nil { + return err + } + } + } + } + return nil +} + +func reserveGeneratedName(names map[string]string, name, source string) error { + if name == "_" { + return nil + } + if previous, exists := names[name]; exists { + return fmt.Errorf("generated name %q conflicts between %s and %s", name, previous, source) + } + names[name] = source + return nil +} diff --git a/pkg/framework/inputgen/names_test.go b/pkg/framework/inputgen/names_test.go new file mode 100644 index 00000000..a016bbaa --- /dev/null +++ b/pkg/framework/inputgen/names_test.go @@ -0,0 +1,83 @@ +package inputgen + +import ( + "bytes" + "strings" + "testing" + "text/template" +) + +func TestValidateGeneratedNamesWithRequestedKind(t *testing.T) { + declarations, err := emitConfigTypes[struct{ Port int32 }]() + if err != nil { + t.Fatal(err) + } + cluster, err := emitClusterConfigTypes[struct{ Environment struct{ Name string } }]() + if err != nil { + t.Fatal(err) + } + declarations += "\n" + cluster + parsed, err := template.New("input").Parse(inputGoTemplate) + if err != nil { + t.Fatal(err) + } + for _, item := range []struct { + kind string + bad bool + }{ + {"TrinoInputCluster", false}, + {"CloneInput", false}, + {"ConfigInput", true}, + {"ConfigInputResources", true}, + {"ClusterConfigInput", true}, + {"ClusterConfigInputEnvironment", true}, + {"SpecInput", true}, + {"GroupVersion", true}, + {"Decode", true}, + {"Operation", true}, + {"Project", true}, {"Binding", true}, {contractVersionName, true}, + } { + t.Run(item.kind, func(t *testing.T) { + var source bytes.Buffer + data := inputTemplateData{ + Names: Names{Package: "resource", Group: "example.com", Version: "v1", Kind: item.kind}, + Roles: []inputRoleName{{Wire: "workers", Go: "Workers"}}, Types: declarations, + } + if err := parsed.Execute(&source, data); err != nil { + t.Fatal(err) + } + err := validateGeneratedNames(source.Bytes()) + if item.bad && (err == nil || !strings.Contains(err.Error(), "conflicts")) { + t.Fatalf("requested kind collision was not rejected: %v", err) + } + if !item.bad && err != nil { + t.Fatalf("valid generated kind rejected: %v", err) + } + }) + } +} + +func TestValidateGeneratedNamesScope(t *testing.T) { + cases := []struct { + name, source string + bad bool + }{ + {"import and type", `package generated; import "fmt"; type fmt struct{}`, true}, + {"alias and function", `package generated; import resource "fmt"; func resource() {}`, true}, + {"constant and variable", `package generated; const A = 1; var B, A int`, true}, + {"type and function", `package generated; type A struct{}; func A() {}`, true}, + {"receiver method is separate", `package generated; type A struct{}; func (A) A() {}`, false}, + {"different receiver methods", `package generated; type A struct{}; type B struct{} +func (A) DeepCopy() {}; func (B) DeepCopy() {}`, false}, + {"blank names and init", `package generated; var _ int; var _ bool; func init() {}; func init() {}`, false}, + {"syntax", `package generated; type`, true}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + err := validateGeneratedNames([]byte(item.source)) + if (err != nil) != item.bad { + t.Fatalf("bad=%t, got %v", item.bad, err) + } + }) + } +} diff --git a/pkg/framework/inputgen/profile.go b/pkg/framework/inputgen/profile.go new file mode 100644 index 00000000..b3b3d79d --- /dev/null +++ b/pkg/framework/inputgen/profile.go @@ -0,0 +1,97 @@ +package inputgen + +import ( + "fmt" + "go/ast" + "go/token" + "reflect" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +var ( + quantityType = reflect.TypeFor[resource.Quantity]() + durationType = reflect.TypeFor[metav1.Duration]() + affinityType = reflect.TypeFor[corev1.Affinity]() +) + +// The generated API does not import C/S yet, but its future registration bridge +// must be able to reference the same product types from another package. +func validateRootReference(typ reflect.Type) error { + if typ.Kind() != reflect.Struct || typ == quantityType || typ == durationType { + return fmt.Errorf("product configuration must be a struct: %s", typ) + } + if typ.Name() == "" && typ.NumField() == 0 { + return nil + } + if !token.IsIdentifier(typ.Name()) || !ast.IsExported(typ.Name()) || typ.PkgPath() == "" { + return fmt.Errorf("product configuration must be an exported named struct or struct{}: %s", typ) + } + return nil +} + +func checkClusterConfigType(typ reflect.Type) error { + if typ.Kind() != reflect.Struct || typ == quantityType || typ == durationType { + return fmt.Errorf("product cluster config must be a struct") + } + if err := input.ValidateProductType(typ); err != nil { + return fmt.Errorf("product cluster config: %w", err) + } + return checkFlatFields([]reflect.Type{reflect.TypeFor[framework.ClusterOperation](), reflect.TypeFor[framework.ClusterConfig](), typ}) +} + +// Keep JSON field traversal local to the generator; product type admissibility +// is owned by the input contract, not by a second merge or validation engine. +func jsonFields(typ reflect.Type) (map[string]reflect.Type, error) { + fields := make(map[string]reflect.Type) + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + if !field.IsExported() || field.Anonymous { + return nil, fmt.Errorf("%s: fields must be public and non-embedded", typ) + } + tag := strings.Split(field.Tag.Get("json"), ",") + name := tag[0] + if name == "-" { + return nil, fmt.Errorf("%s.%s: ignored fields are unsupported", typ, field.Name) + } + if name == "" { + name = field.Name + } + for _, option := range tag[1:] { + if option != "omitempty" { + return nil, fmt.Errorf("%s.%s: unsupported JSON option %q", typ, field.Name, option) + } + } + if _, exists := fields[name]; exists { + return nil, fmt.Errorf("%s: duplicate JSON field %q", typ, name) + } + fields[name] = field.Type + } + return fields, nil +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func durationSchema() apiextensionsv1.JSONSchemaProps { + limit := int64(128) + return apiextensionsv1.JSONSchemaProps{Type: schemaStringType, Nullable: true, MaxLength: &limit, + XValidations: apiextensionsv1.ValidationRules{{ + Rule: "duration(self) == duration(self)", Message: "must be a valid duration string", + }}, + } +} diff --git a/pkg/framework/inputgen/registration.go b/pkg/framework/inputgen/registration.go new file mode 100644 index 00000000..3a996440 --- /dev/null +++ b/pkg/framework/inputgen/registration.go @@ -0,0 +1,165 @@ +package inputgen + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/token" + "reflect" + "slices" + "strings" + "text/template" + + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const frameworkImportPath = "github.com/zncdatadev/operator-go/pkg/framework" + +type registrationType struct{ Path, Name string } +type registrationImport struct{ Path, Alias string } +type registrationTemplateData struct { + Kind string + Config string + Cluster string + Imports []registrationImport + ContractVersion int +} + +func generateRegistration[C, S any](names Names) ([]byte, error) { + if err := checkRegistrationImportPath(names.ImportPath); err != nil { + return nil, fmt.Errorf("registration input import path: %w", err) + } + imports := map[string]string{ + frameworkImportPath: "framework", frameworkImportPath + "/operator": "operator", + frameworkImportPath + "/input": "input", + "sigs.k8s.io/controller-runtime": "ctrl", + } + if _, exists := imports[names.ImportPath]; exists { + return nil, fmt.Errorf("registration input import path %q conflicts with a framework dependency", names.ImportPath) + } + imports[names.ImportPath] = "generated" + config, err := registrationTypeFor(reflect.TypeFor[C]()) + if err != nil { + return nil, fmt.Errorf("registration product config: %w", err) + } + cluster, err := registrationTypeFor(reflect.TypeFor[S]()) + if err != nil { + return nil, fmt.Errorf("registration cluster config: %w", err) + } + for _, typ := range []registrationType{config, cluster} { + if typ.Path == names.ImportPath+"/registration" { + return nil, fmt.Errorf("registration config type %s would import its own companion package", typ.Name) + } + if typ.Path != "" { + if _, exists := imports[typ.Path]; !exists { + imports[typ.Path] = "" + } + } + } + data := registrationTemplateData{Kind: names.Kind, ContractVersion: input.ContractVersion, Imports: registrationImports(imports)} + data.Config, data.Cluster = config.reference(imports), cluster.reference(imports) + parsed, err := template.New("registration").Parse(registrationGoTemplate) + if err != nil { + return nil, err + } + var raw bytes.Buffer + if err := parsed.Execute(&raw, data); err != nil { + return nil, err + } + if err := validateGeneratedNames(raw.Bytes()); err != nil { + return nil, err + } + source, err := format.Source(raw.Bytes()) + if err != nil { + return nil, fmt.Errorf("format generated registration: %w", err) + } + return source, nil +} + +// Root configuration types are already checked by the input profile. Registration +// additionally needs a static reference usable from a separate Go package. +func registrationTypeFor(typ reflect.Type) (registrationType, error) { + if typ.Kind() != reflect.Struct { + return registrationType{}, fmt.Errorf("configuration must be a struct") + } + if typ.Name() == "" && typ.NumField() == 0 { + return registrationType{Name: "struct{}"}, nil + } + if !token.IsIdentifier(typ.Name()) || !ast.IsExported(typ.Name()) || typ.PkgPath() == "" { + return registrationType{}, fmt.Errorf("configuration must be an exported named struct or struct{}: %s", typ) + } + if err := checkRegistrationImportPath(typ.PkgPath()); err != nil { + return registrationType{}, fmt.Errorf("configuration type import path: %w", err) + } + return registrationType{Path: typ.PkgPath(), Name: typ.Name()}, nil +} + +func (typ registrationType) reference(imports map[string]string) string { + if typ.Path == "" { + return typ.Name + } + return imports[typ.Path] + "." + typ.Name +} + +func registrationImports(imports map[string]string) []registrationImport { + paths := make([]string, 0, len(imports)) + for importPath := range imports { + paths = append(paths, importPath) + } + slices.Sort(paths) + result := make([]registrationImport, 0, len(paths)) + index := 0 + for _, importPath := range paths { + if imports[importPath] == "" { + imports[importPath] = fmt.Sprintf("product%d", index) + index++ + } + result = append(result, registrationImport{Path: importPath, Alias: imports[importPath]}) + } + return result +} + +// This bounded generator accepts conventional Go import paths, not filesystem +// paths, module versions or relative imports. Output location is a separate option. +func checkRegistrationImportPath(importPath string) error { + if importPath == "" { + return fmt.Errorf("import path is empty") + } + for _, part := range strings.Split(importPath, "/") { + if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") { + return fmt.Errorf("invalid import path %q", importPath) + } + for _, char := range part { + allowed := char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || + char == '-' || char == '_' || char == '.' + if !allowed { + return fmt.Errorf("invalid import path %q", importPath) + } + } + } + return nil +} + +const registrationGoTemplate = `// Code generated by operator-go inputgen; DO NOT EDIT. +// Package registration connects the generated API to the framework operator. +package registration + +import ( +{{range .Imports}} {{.Alias}} {{printf "%q" .Path}} +{{end}}) + +const InputContractVersion = {{.ContractVersion}} + +// Options supplies product facts and platform settings without pipeline bindings. +type Options[F any] = operator.Options[{{.Config}}, {{.Cluster}}, F] + +// Register fixes the generated API's C/S types; F is inferred from the definition +// and deployment options. It registers the controller but does not start manager. +func Register[F any](manager ctrl.Manager, + definition framework.ProductDefinition[{{.Config}}, {{.Cluster}}, F], options Options[F], +) error { + if err := input.CheckVersion(InputContractVersion); err != nil { return err } + return operator.Register(manager, definition, options, generated.Binding()) +} +` diff --git a/pkg/framework/inputgen/registration_test.go b/pkg/framework/inputgen/registration_test.go new file mode 100644 index 00000000..1e07fe31 --- /dev/null +++ b/pkg/framework/inputgen/registration_test.go @@ -0,0 +1,109 @@ +package inputgen + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +func companionNames() Names { + names := artifactNames() + names.ImportPath = "example.com/operator/generated" + return names +} + +func TestRegistrationGenerationKeepsAPIPackageIndependent(t *testing.T) { + names := companionNames() + artifacts, err := Generate[ProductConfig, ProductClusterConfig](names, []string{"workers", "coordinators"}) + if err != nil { + t.Fatal(err) + } + reordered, err := Generate[ProductConfig, ProductClusterConfig](names, []string{"coordinators", "workers"}) + if err != nil || !bytes.Equal(artifacts.RegistrationSource, reordered.RegistrationSource) { + t.Fatalf("registration is not deterministic: %v", err) + } + names.ImportPath = "" + apiOnly, err := Generate[ProductConfig, ProductClusterConfig](names, []string{"workers", "coordinators"}) + if err != nil || len(apiOnly.RegistrationSource) != 0 || !bytes.Equal(apiOnly.GoSource, artifacts.GoSource) || + !bytes.Equal(apiOnly.CRD, artifacts.CRD) { + t.Fatalf("registration changed the API/schema: %v", err) + } + source := string(bytes.Join(bytes.Fields(artifacts.RegistrationSource), []byte(" "))) + for _, expected := range []string{ + "package registration", "const InputContractVersion = 1", "func Register[F any]", + "type Options[F any] = operator.Options[product0.ProductConfig, product0.ProductClusterConfig, F]", + "definition framework.ProductDefinition[product0.ProductConfig, product0.ProductClusterConfig, F]", + "input.CheckVersion(InputContractVersion)", "operator.Register(manager, definition, options, generated.Binding())", + } { + if !strings.Contains(source, expected) { + t.Fatalf("missing static registration contract %q", expected) + } + } + for _, forbidden := range []string{"internal/", "docs/discussions", "InputRegistration", "Bind(F)", "SourceSnapshot"} { + if strings.Contains(source, forbidden) { + t.Fatalf("public companion leaked %q", forbidden) + } + } + if bytes.Contains(artifacts.GoSource, []byte(frameworkImportPath+"/operator")) { + t.Fatal("generated API now depends on the controller registration") + } + if err := Check(artifacts, artifacts.GoSource, artifacts.CRD, artifacts.RegistrationSource); err != nil { + t.Fatal(err) + } +} + +func TestRegistrationGenerationStaticReferences(t *testing.T) { + artifacts, err := Generate[framework.ClusterOperation, struct{}](companionNames(), []string{"workers"}) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(artifacts.RegistrationSource, []byte("operator.Options[framework.ClusterOperation, struct{}, F]")) || + bytes.Count(artifacts.RegistrationSource, []byte(`"`+frameworkImportPath+`"`)) != 1 { + t.Fatal("framework root types did not reuse one import, or empty cluster lost its literal") + } + for _, typ := range []reflect.Type{reflect.TypeFor[privateRoot](), reflect.TypeFor[GenericRoot[string]](), + reflect.TypeFor[struct{ Value string }](), reflect.TypeFor[*ProductConfig]()} { + if _, err := registrationTypeFor(typ); err == nil { + t.Fatalf("unreferenceable registration type accepted: %v", typ) + } + } + for _, path := range []string{"/absolute", "./relative", "../relative", "example.com//api", "example.com/api/", + "example.com/../api", `example.com\api`, "example.com/api@v1", "example.com/a b", "example.com/a\nb", + frameworkImportPath, frameworkImportPath + "/operator", frameworkImportPath + "/input", "sigs.k8s.io/controller-runtime"} { + names := companionNames() + names.ImportPath = path + if _, err := Generate[ProductConfig, ProductClusterConfig](names, []string{"workers"}); err == nil { + t.Fatalf("invalid/conflicting registration import path accepted: %q", path) + } + } +} + +func TestRegistrationCheckRequiresCompatibleCompanion(t *testing.T) { + a, err := Generate[ProductConfig, ProductClusterConfig](companionNames(), []string{"workers"}) + if err != nil { + t.Fatal(err) + } + for _, actual := range [][][]byte{nil, {nil}, {a.RegistrationSource, a.RegistrationSource}, + {append(bytes.Clone(a.RegistrationSource), '\n')}, {[]byte("package registration\n")}} { + if err := Check(a, a.GoSource, a.CRD, actual...); err == nil { + t.Fatal("missing, duplicate or stale companion accepted") + } + } + future := bytes.Replace(a.RegistrationSource, []byte("InputContractVersion = 1"), + []byte("InputContractVersion = 999"), 1) + if input.CheckVersion(999) == nil { + t.Fatal("test requires an unsupported version") + } + a.RegistrationSource = future + if err := Check(a, a.GoSource, a.CRD, future); err == nil || !strings.Contains(err.Error(), "incompatible") { + t.Fatalf("mutually matching unsupported companions accepted: %v", err) + } + a.RegistrationSource = nil + if err := Check(a, a.GoSource, a.CRD, future); err == nil { + t.Fatal("unexpected companion silently ignored") + } +} diff --git a/pkg/framework/inputgen/s3_schema.go b/pkg/framework/inputgen/s3_schema.go new file mode 100644 index 00000000..f874b979 --- /dev/null +++ b/pkg/framework/inputgen/s3_schema.go @@ -0,0 +1,17 @@ +package inputgen + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// The schema validates a single partial layer. Required selected-branch fields +// are checked after role/group inheritance, not filled by admission defaults. +func s3ConnectionSchema(out apiextensionsv1.JSONSchemaProps) apiextensionsv1.JSONSchemaProps { + kind := out.Properties["type"] + kind.Enum = []apiextensionsv1.JSON{{Raw: []byte(`"disabled"`)}, {Raw: []byte(`"inline"`)}, {Raw: []byte(`"reference"`)}} + out.Properties["type"] = kind + out.XValidations = append(out.XValidations, apiextensionsv1.ValidationRule{ + Rule: "!has(self.type) || (self.type == 'disabled' ? (!has(self.inline) && !has(self.reference)) : " + + "(self.type == 'inline' ? !has(self.reference) : !has(self.inline)))", + Message: "fields must belong to the selected S3 connection branch", + }) + return out +} diff --git a/pkg/framework/inputgen/schema.go b/pkg/framework/inputgen/schema.go new file mode 100644 index 00000000..a4ff5216 --- /dev/null +++ b/pkg/framework/inputgen/schema.go @@ -0,0 +1,383 @@ +package inputgen + +import ( + "fmt" + "math" + "reflect" + "strings" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// CollectionLimit bounds nested configuration collections in generated CRDs. +// It is an admission-cost boundary, not a merge policy. Larger or more deeply +// nested product schemas require their own real API installation validation. +const CollectionLimit int64 = 32 + +const ( + imageInputField = "image" + clusterConfigInputField = "clusterConfig" + schemaConfigField = "config" + schemaSpecField = "spec" + schemaObjectType = "object" + schemaStringType = "string" + schemaIntegerType = "integer" + schemaBooleanType = "boolean" + schemaArrayType = "array" + schemaInt32Format = "int32" + schemaInt64Format = "int64" + schemaReplaceField = "replace" + schemaRemoveField = "remove" + schemaSetField = "set" + schemaStatusField = "status" +) + +// configSchema generates the flat CR config from the common and product value +// types. All fields are optional and no schema default is emitted: admission +// must not erase the distinction between an absent field and an explicit zero. +// Nullable retains explicit null through pruning; enclosing CEL rules reject it. +func configSchema[C any]() (apiextensionsv1.JSONSchemaProps, error) { + product := reflect.TypeFor[C]() + if err := input.ValidateProductType(product); err != nil { + return apiextensionsv1.JSONSchemaProps{}, fmt.Errorf("product config: %w", err) + } + if product.Kind() != reflect.Struct || product == quantityType || product == durationType { + return apiextensionsv1.JSONSchemaProps{}, fmt.Errorf("product config must be a struct") + } + common, err := schemaForType(reflect.TypeFor[framework.CommonConfig](), make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + specific, err := schemaForType(product, make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, fmt.Errorf("product config: %w", err) + } + for name, property := range specific.Properties { + if _, exists := common.Properties[name]; exists { + return apiextensionsv1.JSONSchemaProps{}, fmt.Errorf("product config field %q collides with common config", name) + } + common.Properties[name] = property + } + return nonNullObjectSchema(common.Properties) +} + +// clusterConfigSchema flattens framework operations and product cluster config. +// Optional fields preserve presence without workload fields or schema defaults. +func clusterConfigSchema[S any]() (apiextensionsv1.JSONSchemaProps, error) { + typ := reflect.TypeFor[S]() + if err := checkClusterConfigType(typ); err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + product, err := schemaForType(typ, make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + operation, err := schemaForType(reflect.TypeFor[framework.ClusterOperation](), make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + platform, err := schemaForType(reflect.TypeFor[framework.ClusterConfig](), make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + for name, field := range platform.Properties { + product.Properties[name] = field + } + for name, field := range operation.Properties { + product.Properties[name] = field + } + return nonNullObjectSchema(product.Properties) +} + +// crdSchema describes the supported image, cluster, role/group input envelope +// and the fixed observation status. Unsupported platform fields are not emitted. +func crdSchema[C, S any](roles []string) (apiextensionsv1.JSONSchemaProps, error) { + if _, err := inputRoleNames(roles); err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + config, err := configSchema[C]() + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + zero := float64(0) + replicas := apiextensionsv1.JSONSchemaProps{ + Type: schemaIntegerType, Format: schemaInt32Format, Nullable: true, Minimum: &zero, + } + groupFields, err := overrideInputSchemas() + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + groupFields[schemaConfigField], groupFields["replicas"] = config, replicas + group, err := nonNullObjectSchema(groupFields) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + roleFields := group.DeepCopy().Properties + roleFields["roleGroups"] = nonNullMapSchema(group) + management, err := schemaForType(reflect.TypeFor[framework.RoleConfig](), make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + pdb := management.Properties["podDisruptionBudget"] + maxUnavailable := pdb.Properties["maxUnavailable"] + maxUnavailable.Minimum = &zero + pdb.Properties["maxUnavailable"] = maxUnavailable + management.Properties["podDisruptionBudget"] = pdb + roleFields["roleConfig"] = management + role, err := nonNullObjectSchema(roleFields) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + image, err := imageInputSchema() + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + cluster, err := clusterConfigSchema[S]() + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + properties := map[string]apiextensionsv1.JSONSchemaProps{imageInputField: image, clusterConfigInputField: cluster} + for _, name := range roles { + properties[name] = *role.DeepCopy() + } + spec, err := nonNullObjectSchema(properties) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + // Kubernetes exposes these metadata fields implicitly in CEL. The root + // count must include them even though this schema does not redefine them. + return apiextensionsv1.JSONSchemaProps{ + Type: schemaObjectType, Required: []string{schemaSpecField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + schemaSpecField: spec, schemaStatusField: reconcileStatusSchema(), + }, + XValidations: apiextensionsv1.ValidationRules{{ + Rule: "size(dyn(self)) == " + + "[has(self.spec),has(self.status),has(self.apiVersion),has(self.kind),has(self.metadata)].filter(v,v).size()", + Message: "explicit null spec is not allowed", + }}, + }, nil +} + +// Image source values are validated after presence-based selection. The fixed +// pull policy is independent of that selection and can be bounded at admission. +func imageInputSchema() (apiextensionsv1.JSONSchemaProps, error) { + image, err := schemaForType(reflect.TypeFor[framework.ImageConfig](), make(map[reflect.Type]bool)) + if err != nil { + return apiextensionsv1.JSONSchemaProps{}, err + } + policy := image.Properties["pullPolicy"] + policy.Enum = []apiextensionsv1.JSON{ + {Raw: []byte(`"Always"`)}, {Raw: []byte(`"IfNotPresent"`)}, {Raw: []byte(`"Never"`)}, + } + image.Properties["pullPolicy"] = policy + return image, nil +} + +// overrideInputSchemas describes fixed framework operation domains. These +// schemas are not inferred from product fields and expose no merge strategies. +// Only podOverrides preserves arbitrary nested keys and null: that field carries +// a native Kubernetes strategic merge patch, including its deletion directives. +func overrideInputSchemas() (map[string]apiextensionsv1.JSONSchemaProps, error) { + stringValue := apiextensionsv1.JSONSchemaProps{Type: schemaStringType, Nullable: true} + stringMap := nonNullMapSchema(stringValue) + stringsList := nonNullListSchema(stringValue) + properties, err := nonNullObjectSchema(map[string]apiextensionsv1.JSONSchemaProps{ + schemaSetField: stringMap, schemaRemoveField: stringsList, schemaReplaceField: stringMap, + }) + if err != nil { + return nil, err + } + properties.XValidations = append(properties.XValidations, + apiextensionsv1.ValidationRule{ + Rule: "!has(self.replace) || (!has(self.set) && !has(self.remove))", + Message: "properties.replace cannot be combined with set or remove", + }, + apiextensionsv1.ValidationRule{ + Rule: "!has(self.set) || !has(self.remove) || self.remove.all(k, !(k in self.set))", + Message: "a property cannot be both set and removed in one layer", + }, + ) + lineValue := *stringValue.DeepCopy() + lineValue.Pattern = `^[^\r\n]*$` + file, err := nonNullObjectSchema(map[string]apiextensionsv1.JSONSchemaProps{ + "properties": properties, "lines": nonNullListSchema(lineValue), "text": stringValue, + schemaRemoveField: {Type: schemaBooleanType, Nullable: true, Enum: []apiextensionsv1.JSON{{Raw: []byte("true")}}}, + }) + if err != nil { + return nil, err + } + file.XValidations = append(file.XValidations, apiextensionsv1.ValidationRule{ + Rule: "[has(self.properties),has(self.lines),has(self.text),has(self.remove)].filter(v,v).size() == 1", + Message: "select exactly one of properties, lines, text or remove", + }) + preserve := true + return map[string]apiextensionsv1.JSONSchemaProps{ + "configOverrides": nonNullMapSchema(file), + "envOverrides": stringMap, + "cliOverrides": stringsList, + "podOverrides": { + Type: schemaObjectType, Nullable: true, XPreserveUnknownFields: &preserve, + }, + }, nil +} + +func schemaForType(typ reflect.Type, active map[reflect.Type]bool) (apiextensionsv1.JSONSchemaProps, error) { + if typ == affinityType { + return affinitySchema() + } + if typ == durationType { + return durationSchema(), nil + } + if active[typ] { + return apiextensionsv1.JSONSchemaProps{}, + fmt.Errorf("%s: recursive input types cannot generate a finite structural schema", typ) + } + active[typ] = true + defer delete(active, typ) + if typ == quantityType { + // Quantity is a fixed domain scalar. Syntax belongs to the individual + // input layer; positivity and min <= max belong to the effective input. + length := int64(128) + return apiextensionsv1.JSONSchemaProps{Type: schemaStringType, Nullable: true, MaxLength: &length, + XValidations: apiextensionsv1.ValidationRules{{ + Rule: "isQuantity(self)", Message: "must be a Kubernetes quantity string", + }}, + }, nil + } + out := apiextensionsv1.JSONSchemaProps{Nullable: true} + switch typ.Kind() { + case reflect.Struct: + fields, err := jsonFields(typ) + if err != nil { + return out, err + } + properties := make(map[string]apiextensionsv1.JSONSchemaProps, len(fields)) + for _, name := range sortedKeys(fields) { + child, err := schemaForType(fields[name], active) + if err != nil { + return out, fmt.Errorf("%s: %w", name, err) + } + properties[name] = child + } + out, err := nonNullObjectSchema(properties) + if typ == reflect.TypeFor[framework.S3Connection]() && err == nil { + out = s3ConnectionSchema(out) + } + if typ == reflect.TypeFor[framework.Storage]() && err == nil { + kind := out.Properties["type"] + kind.Enum = []apiextensionsv1.JSON{{Raw: []byte(`"ephemeral"`)}, {Raw: []byte(`"persistent"`)}} + out.Properties["type"] = kind + out.XValidations = append(out.XValidations, apiextensionsv1.ValidationRule{ + Rule: "!has(self.type) || self.type != 'ephemeral' || (!has(self.storageClassName) && !has(self.capacity))", + Message: "ephemeral storage cannot specify storageClassName or capacity", + }) + } + return out, err + case reflect.Map: + child, err := schemaForType(typ.Elem(), active) + if err != nil { + return out, err + } + return nonNullMapSchema(child), nil + case reflect.Slice: + child, err := schemaForType(typ.Elem(), active) + if err != nil { + return out, err + } + return nonNullListSchema(child), nil + case reflect.Bool: + out.Type = schemaBooleanType + case reflect.String: + out.Type = schemaStringType + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + out.Type, out.Format = schemaIntegerType, schemaInt64Format + if typ.Bits() <= 32 { + out.Format = schemaInt32Format + maximum := float64(int64(1)<<(typ.Bits()-1) - 1) + minimum := -maximum - 1 + out.Maximum, out.Minimum = &maximum, &minimum + } + case reflect.Uint8, reflect.Uint16, reflect.Uint32: + minimum, maximum := float64(0), float64(uint64(1)<= '0' && name[0] <= '9' { + return "", false + } + for _, char := range name { + allowed := char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || + char == '_' || char == '.' || char == '-' || char == '/' + if !allowed { + return "", false + } + } + switch name { + case "true", "false", "null", "in", "as", "break", "const", "continue", "else", "for", "function", "if", + "import", "let", "loop", "package", schemaNamespaceField, "return", "var", "void", "while": + return "__" + name + "__", true + default: + replacer := strings.NewReplacer("__", "__underscores__", ".", "__dot__", "-", "__dash__", "/", "__slash__") + return replacer.Replace(name), true + } +} + +func nonNullMapSchema(value apiextensionsv1.JSONSchemaProps) apiextensionsv1.JSONSchemaProps { + limit := CollectionLimit + return apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, Nullable: true, MaxProperties: &limit, + AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Allows: true, Schema: &value}, + XValidations: apiextensionsv1.ValidationRules{{ + Rule: "self.all(k, dyn(self[k]) != null)", Message: "null map values are not allowed", + }}, + } +} + +func nonNullListSchema(value apiextensionsv1.JSONSchemaProps) apiextensionsv1.JSONSchemaProps { + limit := CollectionLimit + return apiextensionsv1.JSONSchemaProps{Type: schemaArrayType, Nullable: true, MaxItems: &limit, + Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &value}, + XValidations: apiextensionsv1.ValidationRules{{ + Rule: "self.all(v, dyn(v) != null)", Message: "null array elements are not allowed", + }}, + } +} diff --git a/pkg/framework/inputgen/schema_test.go b/pkg/framework/inputgen/schema_test.go new file mode 100644 index 00000000..57e8bcda --- /dev/null +++ b/pkg/framework/inputgen/schema_test.go @@ -0,0 +1,190 @@ +package inputgen + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +type schemaSampleConfig struct { + HTTPPort int32 `json:"httpPort"` + Enabled bool `json:"enabled"` + Args []string `json:"args"` + Labels map[string]string `json:"labels"` + Children []schemaSampleChild `json:"children"` + Dotted string `json:"with.dot"` + Reserved string `json:"namespace"` +} + +type schemaSampleChild struct { + Count int16 `json:"count"` +} + +func TestGeneratedConfigSchema(t *testing.T) { + schema, err := configSchema[schemaSampleConfig]() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{ + "resources", "logging", "httpPort", "enabled", "args", "labels", "children", "with.dot", "namespace", + } { + if _, exists := schema.Properties[name]; !exists { + t.Fatalf("generated config lost field %s", name) + } + } + if _, exists := schema.Properties["product"]; exists { + t.Fatal("common/product Go ownership must not change the flat CR layout") + } + port := schema.Properties["httpPort"] + if port.Type != "integer" || port.Format != "int32" || port.Minimum == nil || *port.Minimum >= 0 { + t.Fatalf("schema must preserve the Go domain without imposing the product port range: %#v", port) + } + quantity := schema.Properties["resources"].Properties["cpu"].Properties["min"] + if quantity.Type != "string" || quantity.Minimum != nil || len(quantity.Properties) != 0 { + t.Fatalf("quantity must remain a scalar and permit later layers to repair zero: %#v", quantity) + } + if !strings.Contains(schema.XValidations[0].Rule, "with__dot__dot") { + t.Fatalf("JSON field name was not escaped for CEL: %s", schema.XValidations[0].Rule) + } + assertSchemaPresence(t, schema) + second, err := configSchema[schemaSampleConfig]() + if err != nil || !reflect.DeepEqual(schema, second) { + t.Fatalf("schema generation must be deterministic: %v", err) + } + delete(second.Properties, "httpPort") + if _, exists := schema.Properties["httpPort"]; !exists { + t.Fatal("generated schemas must not share a mutable cache") + } +} + +func TestGeneratedClusterConfigSchema(t *testing.T) { + schema, err := clusterConfigSchema[schemaSampleConfig]() + if err != nil { + t.Fatal(err) + } + if schema.Properties["resources"].Type != "" || schema.Properties["logging"].Type != "" || + schema.Properties["httpPort"].Type != schemaIntegerType { + t.Fatal("cluster schema was mixed with CommonConfig") + } + assertSchemaPresence(t, schema) + empty, err := clusterConfigSchema[struct{}]() + if err != nil || empty.Type != schemaObjectType || len(empty.Properties) != 4 || + empty.Properties["stopped"].Type != schemaBooleanType || + empty.Properties["reconciliationPaused"].Type != schemaBooleanType { + t.Fatalf("empty product cluster config must expose only framework controls: %+v %v", empty, err) + } + for _, build := range []func() (apiextensionsv1.JSONSchemaProps, error){ + clusterConfigSchema[string], clusterConfigSchema[struct{ Value *string }], + clusterConfigSchema[struct{ Tree schemaRecursive }], + } { + if _, err := build(); err == nil { + t.Fatal("unsupported cluster config profile accepted") + } + } +} + +func assertSchemaPresence(t *testing.T, node apiextensionsv1.JSONSchemaProps) { + t.Helper() + if !node.Nullable || node.Default != nil || len(node.Required) != 0 { + t.Fatalf("config node loses input presence: %#v", node) + } + if (node.Type == "object" || node.Type == "array") && len(node.XValidations) == 0 { + t.Fatalf("container node does not reject null children: %#v", node) + } + for _, child := range node.Properties { + assertSchemaPresence(t, child) + } + if node.AdditionalProperties != nil && node.AdditionalProperties.Schema != nil { + assertSchemaPresence(t, *node.AdditionalProperties.Schema) + } + if node.Items != nil && node.Items.Schema != nil { + assertSchemaPresence(t, *node.Items.Schema) + } +} + +func TestGeneratedInputSchemaEnvelope(t *testing.T) { + schema, err := crdSchema[schemaSampleConfig, struct{}]([]string{"coordinator", "worker"}) + if err != nil { + t.Fatal(err) + } + if schema.Nullable || !reflect.DeepEqual(schema.Required, []string{"spec"}) { + t.Fatalf("root must require a non-null spec: %#v", schema) + } + role := schema.Properties["spec"].Properties["worker"] + if role.Properties["replicas"].Default != nil || *role.Properties["replicas"].Minimum != 0 { + t.Fatal("replicas must preserve absence and accept explicit zero") + } + data, err := json.Marshal(schema) + if err != nil { + t.Fatal(err) + } + hasDefaults := strings.Contains(string(data), `"default"`) + if hasDefaults { + t.Fatal("generated CRD must not default inherited input") + } + assertPodOnlyUnknownPreservation(t, schema, "") +} + +func assertPodOnlyUnknownPreservation(t *testing.T, node apiextensionsv1.JSONSchemaProps, name string) { + t.Helper() + if node.XPreserveUnknownFields != nil && *node.XPreserveUnknownFields && name != "podOverrides" { + t.Fatalf("unknown fields preserved outside the explicit Pod patch domain: %s", name) + } + for key, child := range node.Properties { + assertPodOnlyUnknownPreservation(t, child, key) + } + if node.AdditionalProperties != nil && node.AdditionalProperties.Schema != nil { + assertPodOnlyUnknownPreservation(t, *node.AdditionalProperties.Schema, name+"[*]") + } + if node.Items != nil && node.Items.Schema != nil { + assertPodOnlyUnknownPreservation(t, *node.Items.Schema, name+"[]") + } +} + +type schemaRecursive map[string]schemaRecursive + +func TestGeneratedSchemaRejectsUnsupportedDefinitions(t *testing.T) { + for _, test := range []struct { + name string + run func() error + }{ + {"collision", func() error { + _, err := configSchema[struct { + Resources string `json:"resources"` + }]() + return err + }}, + {"recursive", func() error { + _, err := configSchema[struct { + Tree schemaRecursive `json:"tree"` + }]() + return err + }}, + {"unaddressable-json-name", func() error { + _, err := configSchema[struct { + Value string `json:"space name"` + }]() + return err + }}, + {"uint64", func() error { + _, err := configSchema[struct { + Value uint64 `json:"value"` + }]() + return err + }}, + {"no-roles", func() error { _, err := crdSchema[schemaSampleConfig, struct{}](nil); return err }}, + {"duplicate-roles", func() error { + _, err := crdSchema[schemaSampleConfig, struct{}]([]string{"worker", "worker"}) + return err + }}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.run(); err == nil { + t.Fatal("unsupported definition accepted") + } + }) + } +} diff --git a/pkg/framework/inputgen/status_schema.go b/pkg/framework/inputgen/status_schema.go new file mode 100644 index 00000000..a20a4965 --- /dev/null +++ b/pkg/framework/inputgen/status_schema.go @@ -0,0 +1,121 @@ +package inputgen + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +const ( + statusTypeField = "type" + statusAppliedField = "applied" + statusRolesField = "roles" + statusNameField = "name" + statusReasonField = "reason" + statusRoleField = "role" + statusMessageField = "message" + statusStateField = "state" + schemaNamespaceField = "namespace" +) + +// Status is a fixed observation domain. It has no inherited configuration, CEL +// presence rules, defaults or CollectionLimit restriction. +func reconcileStatusSchema() apiextensionsv1.JSONSchemaProps { + zero := float64(0) + mapType := "map" + text := apiextensionsv1.JSONSchemaProps{Type: schemaStringType} + identity := *text.DeepCopy() + minimumLength := int64(1) + identity.MinLength = &minimumLength + count := apiextensionsv1.JSONSchemaProps{Type: schemaIntegerType, Format: schemaInt32Format, Minimum: &zero} + check := apiextensionsv1.JSONSchemaProps{ + Type: schemaObjectType, Required: []string{"subject", statusStateField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "subject": text, statusReasonField: text, + statusStateField: {Type: schemaStringType, Enum: []apiextensionsv1.JSON{ + {Raw: []byte(`"consistent"`)}, {Raw: []byte(`"conflict"`)}, {Raw: []byte(`"unknown"`)}, + }}, + }, + } + group := apiextensionsv1.JSONSchemaProps{ + Type: schemaObjectType, + Required: []string{statusRoleField, statusNameField, "desiredReplicas", "readyReplicas", statusAppliedField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + statusRoleField: identity, statusNameField: identity, + "desiredReplicas": count, "readyReplicas": count, "executionReplicas": count, + statusAppliedField: {Type: schemaBooleanType}, statusMessageField: text, + "checks": {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &check}}, + "facts": factDiagnosticSchema(), + "platform": platformObservationSchema(), + }, + } + role := apiextensionsv1.JSONSchemaProps{ + Type: schemaObjectType, Required: []string{statusNameField, statusAppliedField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + statusNameField: identity, statusAppliedField: {Type: schemaBooleanType}, statusMessageField: text, + }} + condition := conditionStatusSchema() + return apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "observedGeneration": {Type: schemaIntegerType, Format: schemaInt64Format, Minimum: &zero}, + "conditions": {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &condition}, + XListType: &mapType, XListMapKeys: []string{statusTypeField}}, + statusRolesField: {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &role}, + XListType: &mapType, XListMapKeys: []string{statusNameField}}, + "groups": {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &group}, + XListType: &mapType, XListMapKeys: []string{statusRoleField, statusNameField}}, + }, + } +} + +// These constraints follow metav1.Condition's wire contract; condition names +// are not limited to the controller's current set of observations. +func conditionStatusSchema() apiextensionsv1.JSONSchemaProps { + zero, one := float64(0), int64(1) + maxType, maxReason, maxMessage := int64(316), int64(1024), int64(32768) + conditionTypePattern := `^([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])$` + return apiextensionsv1.JSONSchemaProps{ + Type: schemaObjectType, + Required: []string{statusTypeField, schemaStatusField, statusReasonField, statusMessageField, "lastTransitionTime"}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + statusTypeField: {Type: schemaStringType, MaxLength: &maxType, Pattern: conditionTypePattern}, + schemaStatusField: {Type: schemaStringType, Enum: []apiextensionsv1.JSON{ + {Raw: []byte(`"True"`)}, {Raw: []byte(`"False"`)}, {Raw: []byte(`"Unknown"`)}, + }}, + statusReasonField: {Type: schemaStringType, MinLength: &one, MaxLength: &maxReason, + Pattern: `^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$`}, + statusMessageField: {Type: schemaStringType, MaxLength: &maxMessage}, + "lastTransitionTime": {Type: schemaStringType, Format: "date-time"}, + "observedGeneration": {Type: schemaIntegerType, Format: schemaInt64Format, Minimum: &zero}, + }, + } +} + +func factDiagnosticSchema() apiextensionsv1.JSONSchemaProps { + text := apiextensionsv1.JSONSchemaProps{Type: schemaStringType} + object := apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, + Required: []string{"apiVersion", "kind", schemaNamespaceField, statusNameField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "apiVersion": text, "kind": text, schemaNamespaceField: text, statusNameField: text, + "uid": text, "resourceVersion": text, + }, + } + return apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, Required: []string{statusStateField}, + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + statusStateField: {Type: schemaStringType, Enum: []apiextensionsv1.JSON{ + {Raw: []byte(`"resolved"`)}, {Raw: []byte(`"pending"`)}, + {Raw: []byte(`"invalid"`)}, {Raw: []byte(`"readError"`)}, + }}, + "reason": text, "message": text, + "observed": {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &object}}, + }, + } +} + +func platformObservationSchema() apiextensionsv1.JSONSchemaProps { + text := apiextensionsv1.JSONSchemaProps{Type: schemaStringType} + port := apiextensionsv1.JSONSchemaProps{Type: schemaIntegerType, Format: schemaInt32Format} + address := apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, Required: []string{"pod", "directory", "address", "ports"}, Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "pod": text, "directory": text, "address": text, "ports": {Type: schemaObjectType, AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{Allows: true, Schema: &port}}, + }} + return apiextensionsv1.JSONSchemaProps{Type: schemaObjectType, Required: []string{"phase", "diagnostic"}, Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "phase": text, "diagnostic": factDiagnosticSchema(), "listeners": {Type: schemaArrayType, Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &address}}, + }} +} diff --git a/pkg/framework/inputgen/testdata/consumer/cmd/generate/main.go b/pkg/framework/inputgen/testdata/consumer/cmd/generate/main.go new file mode 100644 index 00000000..e48a3aa2 --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/cmd/generate/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + + "example.com/framework-consumer/product" + "github.com/zncdatadev/operator-go/pkg/framework/input" + "github.com/zncdatadev/operator-go/pkg/framework/inputgen" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + trinoNames := inputgen.Names{Package: "trino", Group: "consumer.example.com", Version: "v1alpha1", + Kind: "TrinoCluster", Plural: "trinoclusters", ImportPath: "example.com/framework-consumer/generated/trino"} + roles := slices.Sorted(maps.Keys(product.Definition().Roles)) + trino, err := inputgen.Generate[product.TrinoConfig, product.TrinoClusterConfig](trinoNames, roles) + if err != nil { + return err + } + slices.Reverse(roles) + reordered, err := inputgen.Generate[product.TrinoConfig, product.TrinoClusterConfig](trinoNames, roles) + if err != nil || !bytes.Equal(trino.GoSource, reordered.GoSource) || !bytes.Equal(trino.CRD, reordered.CRD) || !bytes.Equal(trino.RegistrationSource, reordered.RegistrationSource) { + return fmt.Errorf("generation is not deterministic: %v", err) + } + presence, err := inputgen.Generate[product.PresenceConfig, product.PresenceClusterConfig]( + inputgen.Names{Package: "presence", Group: "consumer.example.com", Version: "v1alpha1", + Kind: "PresenceCluster", Plural: "presenceclusters", ImportPath: "example.com/framework-consumer/generated/presence"}, []string{"workers"}) + if err != nil { + return err + } + for name, artifacts := range map[string]inputgen.Artifacts{"trino": trino, "presence": presence} { + if bytes.Contains(artifacts.GoSource, []byte("docs/discussions")) || + bytes.Contains(artifacts.RegistrationSource, []byte("docs/discussions")) || + bytes.Contains(artifacts.RegistrationSource, []byte("internal/framework")) { + return fmt.Errorf("generated %s imports the prototype", name) + } + directory := filepath.Join("generated", name) + if err := os.MkdirAll(filepath.Join(directory, "registration"), 0o700); err != nil { + return err + } + goPath, crdPath := filepath.Join(directory, "zz_generated.input.go"), filepath.Join(directory, "crd.yaml") + if err := os.WriteFile(goPath, artifacts.GoSource, 0o600); err != nil { + return err + } + if err := os.WriteFile(crdPath, artifacts.CRD, 0o600); err != nil { + return err + } + registrationPath := filepath.Join(directory, "registration", "zz_generated.register.go") + if err := os.WriteFile(registrationPath, artifacts.RegistrationSource, 0o600); err != nil { + return err + } + actualRegistration, err := os.ReadFile(registrationPath) + if err != nil { + return err + } + actualGo, err := os.ReadFile(goPath) + if err != nil { + return err + } + actualCRD, err := os.ReadFile(crdPath) + if err != nil { + return err + } + if err := inputgen.Check(artifacts, actualGo, actualCRD, actualRegistration); err != nil { + return err + } + if err := inputgen.Check(artifacts, append(bytes.Clone(actualGo), '\n'), actualCRD, actualRegistration); err == nil { + return fmt.Errorf("generated-source drift was accepted") + } + if err := inputgen.Check(artifacts, actualGo, append(bytes.Clone(actualCRD), '\n'), actualRegistration); err == nil { + return fmt.Errorf("generated-schema drift was accepted") + } + version := fmt.Sprintf("const InputContractVersion = %d", input.ContractVersion) + unsupported := fmt.Sprintf("const InputContractVersion = %d", input.ContractVersion+1) + futureGo := bytes.Replace(actualGo, []byte(version), []byte(unsupported), 1) + future := inputgen.Artifacts{GoSource: futureGo, CRD: actualCRD} + if bytes.Equal(futureGo, actualGo) || inputgen.Check(future, futureGo, actualCRD) == nil { + return fmt.Errorf("mutually matching unsupported generated contract was accepted") + } + } + return nil +} diff --git a/pkg/framework/inputgen/testdata/consumer/consumer_test.go b/pkg/framework/inputgen/testdata/consumer/consumer_test.go new file mode 100644 index 00000000..6a110640 --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/consumer_test.go @@ -0,0 +1,389 @@ +package consumer_test + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "example.com/framework-consumer/generated/presence" + "example.com/framework-consumer/generated/trino" + framework "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +const consumerNamespace = "external-consumer" + +func TestStrictDecodeAndVersionedBinding(t *testing.T) { + binding := trino.Binding() + if binding.Version != input.ContractVersion || trino.InputContractVersion != input.ContractVersion || + presence.InputContractVersion != input.ContractVersion || input.CheckVersion(binding.Version) != nil || + input.CheckVersion(binding.Version+1) == nil || + !reflect.DeepEqual(binding.Roles, []string{"coordinators", "workers"}) { + t.Fatalf("binding lost its generated version or complete roles: %+v", binding) + } + binding.Roles[0] = "caller-mutation" + if trino.Binding().Roles[0] != "coordinators" { + t.Fatal("generated role inventory aliases a previous binding") + } + if binding.NewObject() == nil || binding.AddToScheme(runtime.NewScheme()) != nil { + t.Fatal("generated construction or scheme binding is unusable") + } + for _, data := range []string{ + `{}`, `{"spec":null}`, `{"spec":{"workers":{"config":null}}}`, + `{"spec":{"workers":{"config":{"httpPort":null}}}}`, + `{"spec":{"workers":{"config":{"httpPort":1,"httpPort":2}}}}`, + `{"spec":{"workers":{"config":{"httpport":1}}}}`, + `{"spec":{"workers":{"config":{"unknown":1}}}}`, + `{"spec":{"workers":{"config":{"resources":{"memory":{"limit":42}}}}}}`, + `{"spec":{"clusterConfig":{"nodeEnvironment":null}}}`, + `{"spec":{"clusterConfig":{"stopped":false,"stopped":true}}}`, + } { + if _, err := trino.Decode([]byte(data)); err == nil { + t.Fatalf("ambiguous local input accepted: %s", data) + } + } + for _, data := range []string{ + `{"spec":{"workers":{"config":{"args":[null]}}}}`, + `{"spec":{"workers":{"config":{"backends":{"db":null}}}}}`, + } { + if _, err := presence.Decode([]byte(data)); err == nil { + t.Fatalf("null collection child accepted: %s", data) + } + } + cr, err := presence.Decode([]byte(`{"metadata":{"creationTimestamp":null},"spec":{ + "clusterConfig":{"stopped":true,"reconciliationPaused":true},"workers":{"config":{"args":[]}}}}`)) + if err != nil { + t.Fatal(err) + } + var invalidArgs []string + cr.Spec.Workers.Config.Args = &invalidArgs + if _, err := presence.Binding().Project(cr); err == nil { + t.Fatal("manually constructed null collection unexpectedly projected") + } + operation := presence.Binding().Operation(cr) + if !operation.Stopped || !operation.ReconciliationPaused { + t.Fatal("early operation reading depended on the complete invalid projection") + } +} + +func TestProjectionAndStatusOwnTheirData(t *testing.T) { + cr, err := presence.Decode(objectJSON(t, "PresenceCluster", "copy", presenceSpec())) + if err != nil { + t.Fatal(err) + } + binding := presence.Binding() + status := binding.Status(cr) + status.ObservedGeneration = 3 + status.Groups = []framework.GroupReconcileStatus{{Role: "workers", Name: "default", + Facts: &framework.FactDiagnostic{State: framework.FactsResolved, + Observed: []framework.FactObject{{Name: "source", UID: "source-uid"}}}}} + copy := cr.DeepCopy() + copy.Status.Groups[0].Facts.Observed[0].UID = "mutated" + if cr.Status.Groups[0].Facts.Observed[0].UID != "source-uid" { + t.Fatal("generated status DeepCopy shares nested observations") + } + projection, err := binding.Project(cr) + if err != nil { + t.Fatal(err) + } + assertPresenceProjection(t, projection) + projection.Roles[0].Config[0] = '!' + *projection.Roles[0].Replicas = 9 + projection.Roles[0].Groups[0].Config[0] = '!' + if *cr.Spec.Workers.Replicas != 2 { + t.Fatal("Projection shares replica pointers with the CR") + } + fresh, err := binding.Project(cr) + if err != nil { + t.Fatal(err) + } + assertPresenceProjection(t, fresh) +} + +func TestPersistedInputProjection(t *testing.T) { + assets := consumerAssets(t) + useExisting := false + environment := &envtest.Environment{BinaryAssetsDirectory: assets, UseExistingCluster: &useExisting, + CRDDirectoryPaths: []string{filepath.Join("generated", "trino", "crd.yaml"), + filepath.Join("generated", "presence", "crd.yaml")}, ErrorIfCRDPathMissing: true} + configuration, err := environment.Start() + if err != nil { + _ = environment.Stop() + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + scheme := runtime.NewScheme() + for _, register := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, trino.Binding().AddToScheme, presence.Binding().AddToScheme, + } { + if err := register(scheme); err != nil { + t.Fatal(err) + } + } + c, err := client.New(configuration, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: consumerNamespace}} + if err := c.Create(t.Context(), namespace); err != nil { + t.Fatal(err) + } + d, err := dynamic.NewForConfig(configuration) + if err != nil { + t.Fatal(err) + } + t.Run("trino-empty-role-and-native-fields", func(t *testing.T) { + spec := trinoSpec() + cr, err := trino.Decode(objectJSON(t, "TrinoCluster", "trino", spec)) + if err != nil { + t.Fatal(err) + } + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + stored := trino.Binding().NewObject() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), stored); err != nil { + t.Fatal(err) + } + assertSpec(t, stored, spec) + projection, err := trino.Project(stored) + if err != nil || len(projection.Roles) != 2 || projection.Roles[0].Name != "coordinators" || + projection.Roles[0].Replicas != nil || len(projection.Roles[0].Groups) != 0 { + t.Fatalf("empty role inventory or presence changed: %+v %v", projection, err) + } + assertJSON(t, projection.Image, spec["image"]) + assertJSON(t, projection.ClusterConfig, map[string]any{"nodeEnvironment": "consumer"}) + assertJSON(t, projection.Roles[1].RoleConfig, spec["workers"].(map[string]any)["roleConfig"]) + if projection.Roles[1].Groups[0].Replicas == nil || *projection.Roles[1].Groups[0].Replicas != 0 { + t.Fatal("explicit group zero disappeared") + } + }) + t.Run("presence-typed-update-and-status", func(t *testing.T) { + spec := presenceSpec() + cr, err := presence.Decode(objectJSON(t, "PresenceCluster", "presence", spec)) + if err != nil { + t.Fatal(err) + } + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + stored := presence.Binding().NewObject() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), stored); err != nil { + t.Fatal(err) + } + stored.Annotations = map[string]string{"test.example.com/update": "typed"} + if err := c.Update(t.Context(), stored); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), stored); err != nil { + t.Fatal(err) + } + assertSpec(t, stored, spec) + projection, err := presence.Project(stored) + if err != nil { + t.Fatal(err) + } + assertPresenceProjection(t, projection) + status := presence.Binding().Status(stored) + status.ObservedGeneration = stored.Generation + status.Roles = []framework.RoleReconcileStatus{{Name: "workers", Applied: false}} + status.Conditions = []metav1.Condition{{Type: "Built", Status: metav1.ConditionFalse, + Reason: "Fixture", Message: "observation only", LastTransitionTime: metav1.Now(), + ObservedGeneration: stored.Generation}} + if err := c.Status().Update(t.Context(), stored); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(cr), stored); err != nil { + t.Fatal(err) + } + assertSpec(t, stored, spec) + if stored.Status.ObservedGeneration != stored.Generation || len(stored.Status.Roles) != 1 { + t.Fatal("status binding did not survive API persistence") + } + }) + t.Run("admission-and-merge-patch", func(t *testing.T) { + resource := d.Resource(schema.GroupVersionResource{Group: "consumer.example.com", Version: "v1alpha1", + Resource: "trinoclusters"}).Namespace(consumerNamespace) + admissionChecks(t, c, resource) + }) + t.Run("generated-registration-and-refresh", func(t *testing.T) { testGeneratedRegistration(t, configuration) }) +} + +func consumerAssets(t *testing.T) string { + t.Helper() + assets, explicit := os.LookupEnv("KUBEBUILDER_ASSETS") + if assets == "" { + assets = os.Getenv("FRAMEWORK_ENVTEST_ASSETS") + } + for _, name := range []string{"kube-apiserver", "etcd"} { + if _, err := os.Stat(filepath.Join(assets, name)); err != nil { + if explicit { + t.Fatalf("explicit envtest assets are unavailable: %v", err) + } + t.Skipf("envtest assets unavailable; generation/compilation still tested; set KUBEBUILDER_ASSETS: %v", err) + } + } + return assets +} + +func presenceSpec() map[string]any { + return map[string]any{ + "clusterConfig": map[string]any{"enabled": false, "count": 0, "labels": map[string]any{}, "args": []any{}}, + "workers": map[string]any{"replicas": 2, + "config": map[string]any{"label": "role", "enabled": true, "args": []any{"role"}, + "backends": map[string]any{"db.internal": map[string]any{"host": "role", "enabled": true}}}, + "envOverrides": map[string]any{"EMPTY": ""}, "cliOverrides": []any{}, + "podOverrides": map[string]any{"spec": map[string]any{"containers": []any{ + map[string]any{"name": "trino", "env": nil, "$patch": "merge"}}}}, + "roleGroups": map[string]any{"default": map[string]any{"replicas": 0, + "config": map[string]any{"label": "", "enabled": false, "args": []any{}, "backends": map[string]any{}}}, + "inherited": map[string]any{}}, + }, + } +} + +func trinoSpec() map[string]any { + return map[string]any{ + "image": map[string]any{"custom": "", "repo": "registry.example", "productVersion": "476", + "kubedoopVersion": "", "pullPolicy": "Never", "pullSecretName": ""}, + "clusterConfig": map[string]any{"nodeEnvironment": "consumer", "stopped": false, "reconciliationPaused": false}, + "coordinators": map[string]any{}, + "workers": map[string]any{"replicas": 2, "config": map[string]any{ + "httpPort": 0, "catalogConfigMapName": "", "gracefulShutdownTimeout": "0s", + "affinity": map[string]any{"nodeAffinity": map[string]any{}}, + "resources": map[string]any{"cpu": map[string]any{"min": "100m", "max": "1"}, + "memory": map[string]any{"limit": "1Gi"}}, + "logging": map[string]any{"enableVectorAgent": false, "containers": map[string]any{"trino": map[string]any{ + "console": map[string]any{"level": "OFF"}, "file": map[string]any{"level": "TRACE"}, + "loggers": map[string]any{"ROOT": map[string]any{"level": "INFO"}}}}}}, + "roleConfig": map[string]any{"podDisruptionBudget": map[string]any{"enabled": false, "maxUnavailable": 0}}, + "roleGroups": map[string]any{"default": map[string]any{"replicas": 0}}}, + } +} + +func assertPresenceProjection(t *testing.T, projection input.Projection) { + t.Helper() + if len(projection.Roles) != 1 || projection.Roles[0].Name != "workers" { + t.Fatalf("unexpected roles: %+v", projection.Roles) + } + role := projection.Roles[0] + if role.Replicas == nil || *role.Replicas != 2 || len(role.Groups) != 2 || + role.Groups[0].Name != "default" || role.Groups[0].Replicas == nil || *role.Groups[0].Replicas != 0 || + role.Groups[1].Name != "inherited" || role.Groups[1].Replicas != nil || len(role.Groups[1].Config) != 0 { + t.Fatalf("projection folded or erased declared presence: %+v", role) + } + spec := presenceSpec() + worker := spec["workers"].(map[string]any) + assertJSON(t, projection.ClusterConfig, spec["clusterConfig"]) + assertJSON(t, role.Config, worker["config"]) + assertJSON(t, role.Groups[0].Config, + worker["roleGroups"].(map[string]any)["default"].(map[string]any)["config"]) + if role.Overrides == nil || role.Overrides.CLIOverrides == nil || len(*role.Overrides.CLIOverrides) != 0 || + len(role.Overrides.EnvOverrides) != 1 || role.Overrides.EnvOverrides["EMPTY"] != "" { + t.Fatal("override presence was lost") + } + if _, present := role.Overrides.EnvOverrides["EMPTY"]; !present { + t.Fatal("explicit empty environment value disappeared") + } + assertJSON(t, role.Overrides.PodOverrides, worker["podOverrides"]) +} + +func admissionChecks(t *testing.T, c client.Client, resource dynamic.ResourceInterface) { + t.Helper() + for index, config := range []any{nil, map[string]any{"httpPort": nil}, map[string]any{"httpPort": "wrong"}} { + spec := map[string]any{"workers": map[string]any{"config": config}} + object := rawObject(t, "TrinoCluster", []string{"null-config", "null-field", "wrong-type"}[index], spec) + if _, err := resource.Create(t.Context(), object, metav1.CreateOptions{}); !apierrors.IsInvalid(err) { + t.Fatalf("API accepted invalid typed config %v: %v", config, err) + } + } + spec := map[string]any{"workers": map[string]any{"config": map[string]any{"httpPort": 8080, "unknown": "prune"}}} + object := rawObject(t, "TrinoCluster", "unknown-strict", spec) + if _, err := resource.Create(t.Context(), object, + metav1.CreateOptions{FieldValidation: metav1.FieldValidationStrict}); err == nil { + t.Fatal("strict API request accepted an unknown field") + } + object.SetName("unknown-ignore") + createOptions := metav1.CreateOptions{FieldValidation: metav1.FieldValidationIgnore} + created, err := resource.Create(t.Context(), object, createOptions) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"workers": map[string]any{"config": map[string]any{"httpPort": 8080}}} + assertSpec(t, created, want) + stored := trino.Binding().NewObject() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(created), stored); err != nil { + t.Fatal(err) + } + assertSpec(t, stored, want) + patch := []byte(`{"spec":{"workers":{"config":{"httpPort":null}}}}`) + patched, err := resource.Patch(t.Context(), created.GetName(), types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + t.Fatal(err) + } + assertSpec(t, patched, map[string]any{"workers": map[string]any{"config": map[string]any{}}}) + t.Log("API Ignore prunes unknown fields; strict requests reject them; merge-patch null removes input") +} + +func rawObject(t *testing.T, kind, name string, spec any) *unstructured.Unstructured { + t.Helper() + object := &unstructured.Unstructured{} + if err := json.Unmarshal(objectJSON(t, kind, name, spec), &object.Object); err != nil { + t.Fatal(err) + } + return object +} + +func objectJSON(t *testing.T, kind, name string, spec any) []byte { + t.Helper() + return marshal(t, map[string]any{"apiVersion": "consumer.example.com/v1alpha1", "kind": kind, + "metadata": map[string]any{"name": name, "namespace": consumerNamespace}, "spec": spec}) +} + +func marshal(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func assertSpec(t *testing.T, value, want any) { + t.Helper() + var object map[string]json.RawMessage + if err := json.Unmarshal(marshal(t, value), &object); err != nil { + t.Fatal(err) + } + assertJSON(t, object["spec"], want) +} + +func assertJSON(t *testing.T, actual []byte, want any) { + t.Helper() + var a, b any + if err := json.Unmarshal(actual, &a); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(marshal(t, want), &b); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(a, b) { + t.Fatalf("raw presence changed: actual=%s expected=%s", actual, marshal(t, want)) + } +} diff --git a/pkg/framework/inputgen/testdata/consumer/doc.go b/pkg/framework/inputgen/testdata/consumer/doc.go new file mode 100644 index 00000000..f0f195d2 --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/doc.go @@ -0,0 +1,2 @@ +// Package consumer tests the published SDK from an unrelated Go module path. +package consumer diff --git a/pkg/framework/inputgen/testdata/consumer/product/config.go b/pkg/framework/inputgen/testdata/consumer/product/config.go new file mode 100644 index 00000000..1080f80d --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/product/config.go @@ -0,0 +1,31 @@ +// Package product contains the existing Trino and presence fixture data shapes. +// It deliberately has no dependency on the prototype or generated input API. +package product + +type TrinoConfig struct { + HTTPPort int32 `json:"httpPort"` + CatalogConfigMapName string `json:"catalogConfigMapName"` +} + +type TrinoClusterConfig struct { + NodeEnvironment string `json:"nodeEnvironment"` +} + +type PresenceConfig struct { + Label string `json:"label"` + Enabled bool `json:"enabled"` + Args []string `json:"args"` + Backends map[string]Backend `json:"backends"` +} + +type PresenceClusterConfig struct { + Enabled bool `json:"enabled"` + Count int32 `json:"count"` + Labels map[string]string `json:"labels"` + Args []string `json:"args"` +} + +type Backend struct { + Host string `json:"host"` + Enabled bool `json:"enabled"` +} diff --git a/pkg/framework/inputgen/testdata/consumer/product/definition.go b/pkg/framework/inputgen/testdata/consumer/product/definition.go new file mode 100644 index 00000000..d90f8133 --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/product/definition.go @@ -0,0 +1,102 @@ +package product + +import ( + "context" + "encoding/json" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/types" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +type TrinoFacts struct { + Catalogs map[string]map[string]string `json:"catalogs"` +} + +// Definition exercises the existing Trino declaration and ConfigMap catalog +// reference through the formal external registration. Envtest has no Trino JVM. +func Definition() framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts] { + role := framework.RoleDefinition[TrinoConfig]{ + Config: framework.Config[TrinoConfig]{Product: TrinoConfig{HTTPPort: 8080}, + Common: framework.CommonConfig{Resources: framework.Resources{ + CPU: framework.CPU{Min: resource.MustParse("100m"), Max: resource.MustParse("1")}, + Memory: framework.Memory{Limit: resource.MustParse("1Gi")}, + }}, + }, + } + return framework.ProductDefinition[TrinoConfig, TrinoClusterConfig, TrinoFacts]{ + Name: "trino", ClusterConfigDefaults: TrinoClusterConfig{NodeEnvironment: "consumer"}, + ImageDefaults: framework.ImageConfig{Custom: "trinodb/trino:476", PullPolicy: corev1.PullIfNotPresent}, + Roles: map[string]framework.RoleDefinition[TrinoConfig]{"coordinators": role, "workers": role}, + GenerateGroup: generate, + } +} + +func generate(in framework.EffectiveInput[TrinoConfig, TrinoClusterConfig, TrinoFacts]) ( + framework.RuntimeDescription, error, +) { + r := framework.RuntimeDescription{ + ConfigDirectory: "config", Directories: []framework.Directory{{Name: "config"}}, + Main: framework.Process{Name: "trino", Image: in.Image.Reference, Command: []string{"launcher", "run"}, + Access: []framework.DirectoryAccess{{Directory: "config", MountPath: "/etc/trino", ReadOnly: true}}}, + Endpoints: []framework.Endpoint{{Name: "http", Port: in.Config.Product.HTTPPort}}, + } + names := make([]string, 0, len(in.Facts.Catalogs)) + for name := range in.Facts.Catalogs { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + values := make(map[string]framework.PropertyValue, len(in.Facts.Catalogs[name])) + for key, value := range in.Facts.Catalogs[name] { + values[key] = framework.Literal(value) + } + r.Files = append(r.Files, framework.File{Directory: "config", Path: "catalog/" + name + ".properties", + Content: framework.KeyValues{Codec: framework.PropertiesCodec{}, Values: values}}) + } + return r, nil +} + +// ResolveCatalogs is a bounded test adapter for the fixed same-namespace reference. +// The framework supplies read provenance and refresh; no writable client is held. +func ResolveCatalogs(ctx context.Context, reader framework.FactsReader, + in framework.FactInput[TrinoConfig, TrinoClusterConfig, TrinoFacts], +) (framework.FactResult[TrinoFacts], error) { + facts := in.Shared + if name := in.Config.Product.CatalogConfigMapName; name != "" { + cm := &corev1.ConfigMap{} + if err := reader.Get(ctx, types.NamespacedName{Namespace: in.Group.Namespace, Name: name}, cm); err != nil { + if apierrors.IsNotFound(err) { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsPending, Reason: "CatalogMissing"}}, nil + } + return framework.FactResult[TrinoFacts]{}, err + } + if !cm.DeletionTimestamp.IsZero() { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsPending, Reason: "CatalogDeleting"}}, nil + } + var catalogs map[string]map[string]string + if err := json.Unmarshal([]byte(cm.Data["catalogs.json"]), &catalogs); err != nil || len(catalogs) == 0 { + return invalidCatalog(), nil + } + facts.Catalogs = catalogs + for name, entries := range facts.Catalogs { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") || + strings.TrimSpace(entries["connector.name"]) == "" { + return invalidCatalog(), nil + } + } + } + return framework.FactResult[TrinoFacts]{Value: &facts, + Diagnostic: framework.FactDiagnostic{State: framework.FactsResolved}}, nil +} +func invalidCatalog() framework.FactResult[TrinoFacts] { + return framework.FactResult[TrinoFacts]{Diagnostic: framework.FactDiagnostic{ + State: framework.FactsInvalid, Reason: "CatalogInvalid"}} +} diff --git a/pkg/framework/inputgen/testdata/consumer/registration_test.go b/pkg/framework/inputgen/testdata/consumer/registration_test.go new file mode 100644 index 00000000..1abdd03c --- /dev/null +++ b/pkg/framework/inputgen/testdata/consumer/registration_test.go @@ -0,0 +1,223 @@ +package consumer_test + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "example.com/framework-consumer/generated/trino" + registration "example.com/framework-consumer/generated/trino/registration" + "example.com/framework-consumer/product" + "github.com/zncdatadev/operator-go/pkg/framework" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +const registrationNamespace = "registered-consumer" + +func testGeneratedRegistration(t *testing.T, configuration *rest.Config) { + manager, err := ctrl.NewManager(configuration, ctrl.Options{Scheme: runtime.NewScheme(), + Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0", + Cache: cache.Options{DefaultNamespaces: map[string]cache.Config{registrationNamespace: {}}}, + }) + if err != nil { + t.Fatal(err) + } + options := registration.Options[product.TrinoFacts]{ResolveFacts: product.ResolveCatalogs, + FactRefreshInterval: 30 * time.Millisecond, + Assembly: framework.AssemblyOptions{MaterializerImage: "example.invalid/materializer:fixture"}, + } + if err := registration.Register(manager, product.Definition(), options); err != nil { + t.Fatal(err) + } + c, err := client.New(configuration, client.Options{Scheme: manager.GetScheme()}) + if err != nil { + t.Fatal(err) + } + if err := c.Create(t.Context(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: registrationNamespace}}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + finished := make(chan error, 1) + go func() { finished <- manager.Start(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-finished: + if err != nil { + t.Errorf("manager shutdown: %v", err) + } + case <-time.After(10 * time.Second): + t.Error("registered manager did not terminate") + } + }) + if !manager.GetCache().WaitForCacheSync(ctx) { + t.Fatal("registered manager cache never synchronized") + } + spec := map[string]any{ + "coordinators": map[string]any{"roleGroups": map[string]any{"default": map[string]any{}}}, + "workers": map[string]any{ + "config": map[string]any{"catalogConfigMapName": "external-catalog"}, + "configOverrides": map[string]any{"catalog/tpch.properties": map[string]any{ + "properties": map[string]any{"set": map[string]any{"fixture": "overridden"}}}}, + "roleGroups": map[string]any{"default": map[string]any{}}, + }, + } + cr, err := trino.Decode(objectJSON(t, "TrinoCluster", "registered", spec)) + if err != nil { + t.Fatal(err) + } + cr.Namespace = registrationNamespace + if err := c.Create(t.Context(), cr); err != nil { + t.Fatal(err) + } + key, generation := client.ObjectKeyFromObject(cr), cr.Generation + awaitFactState(t, c, key, framework.FactsPending, nil, generation) + awaitRegistration(t, func() error { + return c.Get(t.Context(), types.NamespacedName{Namespace: key.Namespace, Name: "registered-coordinators-default"}, &appsv1.StatefulSet{}) + }) + if err := c.Get(t.Context(), types.NamespacedName{Namespace: key.Namespace, Name: "registered-workers-default"}, + &appsv1.StatefulSet{}); !apierrors.IsNotFound(err) { + t.Fatalf("pending facts allowed a worker workload: %v", err) + } + dependency := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "external-catalog", Namespace: key.Namespace}, + Data: map[string]string{"catalogs.json": `{"tpch":{"connector.name":"tpch"}}`}} + if err := c.Create(t.Context(), dependency); err != nil { + t.Fatal(err) + } + awaitFactState(t, c, key, framework.FactsResolved, dependency, generation) + first := awaitCatalogOutput(t, c, key.Namespace, "connector.name=tpch\nfixture=overridden\n") + // Invalid contents withhold this group, retaining its previous owned resources. + dependency.Data["catalogs.json"] = "invalid JSON" + if err := c.Update(t.Context(), dependency); err != nil { + t.Fatal(err) + } + awaitFactState(t, c, key, framework.FactsInvalid, dependency, generation) + assertCatalogRetained(t, c, first) + dependency.Data["catalogs.json"] = `{"tpch":{"connector.name":"blackhole"}}` + if err := c.Update(t.Context(), dependency); err != nil { + t.Fatal(err) + } + awaitFactState(t, c, key, framework.FactsResolved, dependency, generation) + updated := awaitCatalogOutput(t, c, key.Namespace, "connector.name=blackhole\nfixture=overridden\n") + if updated.UID != first.UID { + t.Fatal("dependency update recreated the generated ConfigMap") + } + oldUID := dependency.UID + if err := c.Delete(t.Context(), dependency); err != nil { + t.Fatal(err) + } + awaitFactState(t, c, key, framework.FactsPending, nil, generation) + assertCatalogRetained(t, c, updated) + dependency = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "external-catalog", Namespace: key.Namespace}, + Data: map[string]string{"catalogs.json": `{"tpch":{"connector.name":"tpch"}}`}} + if err := c.Create(t.Context(), dependency); err != nil { + t.Fatal(err) + } + if dependency.UID == oldUID { + t.Fatal("dependency recreation did not exercise a new UID") + } + awaitFactState(t, c, key, framework.FactsResolved, dependency, generation) + awaitCatalogOutput(t, c, key.Namespace, "connector.name=tpch\nfixture=overridden\n") + t.Log("external generated Register started a real manager; dependency create/invalid/update/delete/new UID refreshed without CR edits") + t.Log("catalog bytes and owned resources verified; envtest has no kubelet, Trino process or restarter") +} + +func awaitRegistration(t *testing.T, inspect func() error) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + var last error + for time.Now().Before(deadline) { + if last = inspect(); last == nil { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("registration did not converge: %v", last) +} + +func awaitFactState(t *testing.T, c client.Client, key types.NamespacedName, state framework.FactState, + dependency *corev1.ConfigMap, generation int64, +) { + t.Helper() + awaitRegistration(t, func() error { + stored := &trino.TrinoCluster{} + if err := c.Get(t.Context(), key, stored); err != nil { + return err + } + if stored.Generation != generation { + return fmt.Errorf("CR generation changed: %d", stored.Generation) + } + for _, group := range stored.Status.Groups { + if group.Role != "workers" || group.Name != "default" || group.Facts == nil { + continue + } + if group.Facts.State != state { + return fmt.Errorf("facts state %s, want %s: %s", group.Facts.State, state, group.Message) + } + if dependency == nil { + return nil + } + for _, object := range group.Facts.Observed { + if object.Name == dependency.Name && object.Namespace == dependency.Namespace && + object.UID == string(dependency.UID) && object.ResourceVersion == dependency.ResourceVersion { + return nil + } + } + return fmt.Errorf("dependency provenance has not refreshed: %+v", group.Facts.Observed) + } + return fmt.Errorf("worker facts have not been reported: %+v", stored.Status) + }) +} + +func awaitCatalogOutput(t *testing.T, c client.Client, namespace, want string) *corev1.ConfigMap { + t.Helper() + var found *corev1.ConfigMap + awaitRegistration(t, func() error { + cm := &corev1.ConfigMap{} + if err := c.Get(t.Context(), types.NamespacedName{Namespace: namespace, Name: "registered-workers-default"}, cm); err != nil { + return err + } + // Inspect the actual helper payload without importing the SDK's private plan types. + var plan struct { + Files []struct { + Directory, Path string + Encoded *string + } + } + if err := json.Unmarshal([]byte(cm.Data["materialization.json"]), &plan); err != nil { + return err + } + for _, file := range plan.Files { + if file.Directory == "config" && file.Path == "catalog/tpch.properties" && file.Encoded != nil && *file.Encoded == want { + found = cm + return nil + } + } + return fmt.Errorf("generated catalog has not refreshed: %s", cm.Data["materialization.json"]) + }) + return found +} + +func assertCatalogRetained(t *testing.T, c client.Client, before *corev1.ConfigMap) { + t.Helper() + after := &corev1.ConfigMap{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(before), after); err != nil { + t.Fatal(err) + } + if after.UID != before.UID || !reflect.DeepEqual(after.Data, before.Data) { + t.Fatal("unresolved facts replaced the last ConfigMap") + } +} diff --git a/pkg/framework/inputgen/typegen.go b/pkg/framework/inputgen/typegen.go new file mode 100644 index 00000000..f306d5a9 --- /dev/null +++ b/pkg/framework/inputgen/typegen.go @@ -0,0 +1,155 @@ +package inputgen + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +const inputConfigPath = "config" + +// emitConfigTypes emits declarations only; its caller supplies the package and +// resource import. Generated pointers belong to input field presence, never to +// collection entries. The effective product type is not reused as a partial CR. +func emitConfigTypes[C any]() (string, error) { + productType := reflect.TypeFor[C]() + if productType.Kind() != reflect.Struct || productType == quantityType || productType == durationType { + return "", fmt.Errorf("product config must be a struct") + } + if err := input.ValidateProductType(productType); err != nil { + return "", fmt.Errorf("product config: %w", err) + } + sources := []reflect.Type{reflect.TypeFor[framework.CommonConfig](), productType} + if err := checkFlatFields(sources); err != nil { + return "", err + } + return emitInputTypes("ConfigInput", inputConfigPath, sources) +} + +// The cluster wire object flattens framework operations and the independent +// product object. Common workload fields and role/group inheritance stay absent. +func emitClusterConfigTypes[S any]() (string, error) { + clusterType := reflect.TypeFor[S]() + if err := checkClusterConfigType(clusterType); err != nil { + return "", err + } + return emitInputTypes("ClusterConfigInput", clusterConfigInputField, + []reflect.Type{reflect.TypeFor[framework.ClusterOperation](), reflect.TypeFor[framework.ClusterConfig](), clusterType}) +} + +func emitInputTypes(typeName, path string, sources []reflect.Type) (string, error) { + emitter := configTypeEmitter{ + active: make(map[reflect.Type]bool), + names: map[string]string{typeName: path}, + } + var fields strings.Builder + for _, source := range sources { + emitter.active[source] = true + part, err := emitter.fields(source, typeName, path) + delete(emitter.active, source) + if err != nil { + return "", err + } + fields.WriteString(part) + } + emitter.declarations = append(emitter.declarations, "type "+typeName+" struct {\n"+fields.String()+"}\n") + return strings.Join(emitter.declarations, "\n"), nil +} + +// Common and product fields share one struct and one JSON object, so both Go +// member names and JSON names must be unique at that boundary. +func checkFlatFields(sources []reflect.Type) error { + goNames, jsonNames := make(map[string]bool), make(map[string]bool) + for _, source := range sources { + for index := 0; index < source.NumField(); index++ { + field := source.Field(index) + name := inputJSONName(field) + if goNames[field.Name] || jsonNames[name] { + return fmt.Errorf("common/product config field %s (%q) collides", field.Name, name) + } + goNames[field.Name], jsonNames[name] = true, true + } + } + return nil +} + +type configTypeEmitter struct { + active map[reflect.Type]bool + names map[string]string + declarations []string +} + +func (e *configTypeEmitter) fields(typ reflect.Type, typeName, path string) (string, error) { + var result strings.Builder + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + name := inputJSONName(field) + valueType, err := e.valueType(field.Type, typeName+field.Name, path+"."+name) + if err != nil { + return "", err + } + tag := "json:" + strconv.Quote(name+",omitempty") + quotedTag := strconv.Quote(tag) + if strconv.CanBackquote(tag) { + quotedTag = "`" + tag + "`" + } + fmt.Fprintf(&result, "\t%s *%s %s\n", field.Name, valueType, quotedTag) + } + return result.String(), nil +} + +func (e *configTypeEmitter) valueType(typ reflect.Type, typeName, path string) (string, error) { + if typ == quantityType { + return "resource.Quantity", nil + } + if typ == durationType { + return "metav1.Duration", nil + } + if typ == affinityType { + return "corev1.Affinity", nil + } + if e.active[typ] { + return "", fmt.Errorf("%s: recursive config type %s is unsupported", path, typ) + } + e.active[typ] = true + defer delete(e.active, typ) + switch typ.Kind() { + case reflect.Struct: + return e.structType(typ, typeName, path) + case reflect.Map: + value, err := e.valueType(typ.Elem(), typeName+"Value", path+"[value]") + return "map[string]" + value, err + case reflect.Slice: + value, err := e.valueType(typ.Elem(), typeName+"Item", path+"[item]") + return "[]" + value, err + default: + // Product profile validation excludes codecs and unsupported kinds. The kind + // spelling erases named scalar aliases without importing product code. + return typ.Kind().String(), nil + } +} + +func (e *configTypeEmitter) structType(typ reflect.Type, typeName, path string) (string, error) { + if previous, exists := e.names[typeName]; exists { + return "", fmt.Errorf("generated type name %s collides between %s and %s", typeName, previous, path) + } + e.names[typeName] = path + fields, err := e.fields(typ, typeName, path) + if err != nil { + return "", err + } + e.declarations = append(e.declarations, "type "+typeName+" struct {\n"+fields+"}\n") + return typeName, nil +} + +func inputJSONName(field reflect.StructField) string { + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "" { + return field.Name + } + return name +} diff --git a/pkg/framework/inputgen/typegen_test.go b/pkg/framework/inputgen/typegen_test.go new file mode 100644 index 00000000..e5b6718c --- /dev/null +++ b/pkg/framework/inputgen/typegen_test.go @@ -0,0 +1,173 @@ +package inputgen + +import ( + "go/ast" + "go/format" + "go/parser" + "go/token" + "reflect" + "strconv" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/resource" +) + +type inputTypePort int32 +type inputTypeKey string + +type inputTypeFixture struct { + Port inputTypePort `json:"port"` + Enabled bool `json:"enabled"` + Args []string `json:"args"` + Servers map[inputTypeKey]inputTypeServer `json:"servers"` + Peers []inputTypeServer `json:"peers"` + Limits map[string]resource.Quantity `json:"limits"` + Groups map[string][]map[string]inputTypeServer `json:"groups"` +} + +type inputTypeServer struct { + Host string `json:"host"` + Labels map[string]string `json:"labels"` +} + +func TestEmitConfigTypesPreservesFieldPresence(t *testing.T) { + declarations, err := emitConfigTypes[inputTypeFixture]() + if err != nil { + t.Fatal(err) + } + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", "package fixture\n"+declarations, 0) + if err != nil { + t.Fatalf("invalid Go declarations: %v\n%s", err, declarations) + } + fields := emittedFields(t, file) + want := map[string]string{ + "ConfigInput.Port": "*int32", + "ConfigInput.Enabled": "*bool", + "ConfigInput.Args": "*[]string", + "ConfigInput.Servers": "*map[string]ConfigInputServersValue", + "ConfigInput.Peers": "*[]ConfigInputPeersItem", + "ConfigInput.Limits": "*map[string]resource.Quantity", + "ConfigInput.Groups": "*map[string][]map[string]ConfigInputGroupsValueItemValue", + "ConfigInputServersValue.Host": "*string", + "ConfigInputServersValue.Labels": "*map[string]string", + "ConfigInputPeersItem.Host": "*string", + "ConfigInputGroupsValueItemValue.Host": "*string", + "ConfigInputResourcesCPU.Min": "*resource.Quantity", + "ConfigInputLogging.EnableVectorAgent": "*bool", + "ConfigInputLoggingContainersValueLoggersValue.Level": "*string", + } + for field, expected := range want { + if got := fields[field]; got != expected { + t.Errorf("%s: want %s, got %s", field, expected, got) + } + } + second, err := emitConfigTypes[inputTypeFixture]() + if err != nil || declarations != second { + t.Fatalf("emission is not deterministic: %v", err) + } +} + +func TestEmitClusterConfigTypesPreservesIndependentPresence(t *testing.T) { + declarations, err := emitClusterConfigTypes[inputTypeFixture]() + if err != nil { + t.Fatal(err) + } + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", "package fixture\n"+declarations, 0) + if err != nil { + t.Fatal(err) + } + fields := emittedFields(t, file) + for field, want := range map[string]string{ + "ClusterConfigInput.Stopped": "*bool", + "ClusterConfigInput.ReconciliationPaused": "*bool", + "ClusterConfigInput.Port": "*int32", "ClusterConfigInput.Enabled": "*bool", + "ClusterConfigInput.Args": "*[]string", + "ClusterConfigInput.Servers": "*map[string]ClusterConfigInputServersValue", + "ClusterConfigInput.Limits": "*map[string]resource.Quantity", + } { + if fields[field] != want { + t.Errorf("%s: got %s, want %s", field, fields[field], want) + } + } + if fields["ClusterConfigInput.Resources"] != "" || fields["ClusterConfigInput.Logging"] != "" { + t.Fatal("workload common fields leaked into the cluster input") + } + if _, err := emitClusterConfigTypes[struct{ Resources string }](); err != nil { + t.Fatalf("cluster field was incorrectly checked against CommonConfig: %v", err) + } + second, err := emitClusterConfigTypes[inputTypeFixture]() + if err != nil || declarations != second { + t.Fatalf("cluster type emission is not deterministic: %v", err) + } +} + +func TestEmitClusterConfigTypesRejectsUnsupportedRoots(t *testing.T) { + for _, emit := range []func() (string, error){ + emitClusterConfigTypes[string], emitClusterConfigTypes[resource.Quantity], + emitClusterConfigTypes[struct{ Value *string }], emitClusterConfigTypes[inputTypeRecursive], + } { + if value, err := emit(); err == nil || value != "" { + t.Fatalf("invalid cluster profile produced declarations: %q %v", value, err) + } + } +} + +func emittedFields(t *testing.T, file *ast.File) map[string]string { + t.Helper() + result := make(map[string]string) + for _, declaration := range file.Decls { + for _, spec := range declaration.(*ast.GenDecl).Specs { + typeSpec := spec.(*ast.TypeSpec) + for _, field := range typeSpec.Type.(*ast.StructType).Fields.List { + var value strings.Builder + if err := format.Node(&value, token.NewFileSet(), field.Type); err != nil { + t.Fatal(err) + } + if _, ok := field.Type.(*ast.StarExpr); !ok { + t.Errorf("%s.%s does not preserve field presence", typeSpec.Name, field.Names[0]) + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil || !strings.HasSuffix(reflect.StructTag(tag).Get("json"), ",omitempty") { + t.Errorf("invalid optional input tag %s: %v", field.Tag.Value, err) + } + result[typeSpec.Name.Name+"."+field.Names[0].Name] = value.String() + } + } + } + return result +} + +type inputTypeRecursive struct { + Children map[string]inputTypeRecursive `json:"children"` +} + +func TestEmitConfigTypesRejectsAmbiguousOrUnsupportedProfiles(t *testing.T) { + cases := []struct { + name string + emit func() (string, error) + want string + }{ + {"Go field collision", emitConfigTypes[struct { + Resources string `json:"productResources"` + }], "collides"}, + {"JSON field collision", emitConfigTypes[struct { + ProductResources string `json:"resources"` + }], "collides"}, + {"generated path collision", emitConfigTypes[struct { + ResourcesCPU struct{ Other bool } `json:"resourcesCPU"` + }], "generated type name"}, + {"recursive map", emitConfigTypes[inputTypeRecursive], "recursive"}, + {"pointer", emitConfigTypes[struct{ Value *string }], "unsupported"}, + {"non-struct", emitConfigTypes[string], "must be a struct"}, + {"quantity root", emitConfigTypes[resource.Quantity], "must be a struct"}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + value, err := item.emit() + if value != "" || err == nil || !strings.Contains(err.Error(), item.want) { + t.Fatalf("want no output and %q, got output %q and error %v", item.want, value, err) + } + }) + } +} diff --git a/pkg/framework/lifecycle.go b/pkg/framework/lifecycle.go new file mode 100644 index 00000000..cdaae6e0 --- /dev/null +++ b/pkg/framework/lifecycle.go @@ -0,0 +1,15 @@ +package framework + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// WorkloadCoordination opts into ordered workload transitions. Kubernetes owns +// init execution and one-at-a-time OrderedReady rolling replacement; the framework +// serializes scale-down ordinals and persists progress deadlines. Timeout reports +// failure without forcing deletion. It is not a guarantee of successful product +// shutdown: kubelet may kill a process when its termination budget expires. +type WorkloadCoordination struct { + ProgressDeadline metav1.Duration `json:"progressDeadline"` + // Lower priorities finish stopping before higher priorities begin. Equal + // priorities are independent. This ordering also applies to withdrawn groups. + ShutdownPriority int32 `json:"shutdownPriority"` +} diff --git a/pkg/framework/logging/python.go b/pkg/framework/logging/python.go new file mode 100644 index 00000000..d288f2f7 --- /dev/null +++ b/pkg/framework/logging/python.go @@ -0,0 +1,87 @@ +// Package logging contains pure native logging adapters. Products still declare +// which process reads each configuration and which files it actually produces. +package logging + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +const ( + pythonOffLevel = "OFF" + pythonDebugLevel = "DEBUG" + pythonLevelKey = "level" + pythonHandlersKey = "handlers" + pythonFormatterName = "standard" +) + +// Python returns a stdlib logging.config.dictConfig JSON document. Console and +// file thresholds are independent. ROOT names Python's root logger; other keys +// name native hierarchical loggers. An enabled file sink requires an absolute +// filename whose parent the product declares as a writable runtime directory. +// The product loads this document before emitting its events and declares the +// file as a LogOutput only when File.Level is not OFF. +func Python(config framework.ContainerLogging, filename string) (framework.Text, error) { + console, err := pythonLevel(config.Console.Level) + if err != nil { + return "", fmt.Errorf("console.level: %w", err) + } + file, err := pythonLevel(config.File.Level) + if err != nil { + return "", fmt.Errorf("file.level: %w", err) + } + root, exists := config.Loggers["ROOT"] + if !exists { + return "", fmt.Errorf("loggers.ROOT is required") + } + rootLevel, err := pythonLevel(root.Level) + if err != nil { + return "", fmt.Errorf("loggers.ROOT.level: %w", err) + } + handlers, selected := map[string]any{}, []string{} + if config.Console.Level != pythonOffLevel { + handlers["console"] = map[string]any{"class": "logging.StreamHandler", pythonLevelKey: console, + "formatter": pythonFormatterName, "stream": "ext://sys.stdout"} + selected = append(selected, "console") + } + if config.File.Level != pythonOffLevel { + if !path.IsAbs(filename) || filename == "/" || path.Clean(filename) != filename || strings.ContainsAny(filename, "\x00\r\n") { + return "", fmt.Errorf("enabled file logging requires a clean absolute filename") + } + handlers["file"] = map[string]any{"class": "logging.handlers.RotatingFileHandler", pythonLevelKey: file, + "formatter": pythonFormatterName, "filename": filename, "encoding": "utf-8", "maxBytes": 10 * 1024 * 1024, + "backupCount": 3} + selected = append(selected, "file") + } + loggers := map[string]any{} + for name, logger := range config.Loggers { + if name == "ROOT" { + continue + } + if strings.TrimSpace(name) != name || name == "" || strings.ContainsAny(name, "\x00\r\n") { + return "", fmt.Errorf("logger name must be nonempty and single-line; use ROOT for the root logger") + } + level, err := pythonLevel(logger.Level) + if err != nil { + return "", fmt.Errorf("logger %q: %w", name, err) + } + loggers[name] = map[string]any{pythonLevelKey: level, pythonHandlersKey: []string{}, "propagate": true} + } + data, err := json.MarshalIndent(map[string]any{"version": 1, "disable_existing_loggers": false, + "formatters": map[string]any{pythonFormatterName: map[string]any{"format": "%(asctime)s %(levelname)s %(name)s %(message)s"}}, + pythonHandlersKey: handlers, "loggers": loggers, "root": map[string]any{pythonLevelKey: rootLevel, pythonHandlersKey: selected}}, "", " ") + return framework.Text(data), err +} + +func pythonLevel(level string) (int, error) { + levels := map[string]int{"TRACE": 5, pythonDebugLevel: 10, "INFO": 20, "WARN": 30, "ERROR": 40, "FATAL": 50, pythonOffLevel: 2147483647} + value, ok := levels[level] + if !ok { + return 0, fmt.Errorf("unsupported level %q", level) + } + return value, nil +} diff --git a/pkg/framework/logging/python_test.go b/pkg/framework/logging/python_test.go new file mode 100644 index 00000000..79917c80 --- /dev/null +++ b/pkg/framework/logging/python_test.go @@ -0,0 +1,70 @@ +package logging + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestPythonNativeConsumerThresholds(t *testing.T) { + for _, fileEnabled := range []bool{true, false} { + t.Run(map[bool]string{true: "file-enabled", false: "file-off"}[fileEnabled], func(t *testing.T) { + directory := t.TempDir() + filename := filepath.Join(directory, "server.log") + fileLevel := pythonDebugLevel + if !fileEnabled { + fileLevel = pythonOffLevel + } + content, err := Python(framework.ContainerLogging{ + Console: framework.Logger{Level: "WARN"}, File: framework.Logger{Level: fileLevel}, + Loggers: map[string]framework.Logger{"ROOT": {Level: "INFO"}, "product": {Level: pythonDebugLevel}}}, filename) + if err != nil { + t.Fatal(err) + } + config := filepath.Join(directory, "logging.json") + if err := os.WriteFile(config, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + // This executes the generated document in the actual native consumer, + // not a second Go implementation of Python's filtering behavior. + code := `import json,logging,logging.config,sys +logging.config.dictConfig(json.load(open(sys.argv[1]))) +logging.getLogger('product').debug('product-debug-marker') +logging.getLogger('product').warning('product-warning-marker') +logging.getLogger('other').debug('other-debug-hidden') +logging.getLogger('other').info('other-info-marker') +logging.shutdown() +` + output, err := exec.Command("python3", "-c", code, config).CombinedOutput() + if err != nil { + t.Fatalf("native Python logging failed: %v\n%s", err, output) + } + if !strings.Contains(string(output), "product-warning-marker") || strings.Contains(string(output), "debug-marker") || + strings.Contains(string(output), "other-info-marker") { + t.Fatalf("console did not consume its own threshold: %s", output) + } + data, err := os.ReadFile(filename) + if !fileEnabled { + if !os.IsNotExist(err) { + t.Fatalf("OFF file sink created a file: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + for _, marker := range []string{"product-debug-marker", "product-warning-marker", "other-info-marker"} { + if !strings.Contains(string(data), marker) { + t.Fatalf("file did not contain %q: %s", marker, data) + } + } + if strings.Contains(string(data), "other-debug-hidden") { + t.Fatalf("root logger threshold ignored: %s", data) + } + }) + } +} diff --git a/pkg/framework/operator/AGENTS.md b/pkg/framework/operator/AGENTS.md new file mode 100644 index 00000000..f224005b --- /dev/null +++ b/pkg/framework/operator/AGENTS.md @@ -0,0 +1,32 @@ +# Framework registration + +Parent: [../AGENTS.md](../AGENTS.md). Design: [architecture](../../../docs/architecture.md#framework-design), sections 7–8. + +This package implements the public deployment seam used by generated registration +companions. `Options[C,S,F]` contains base `Facts`, optional read-only `ResolveFacts`, +`Assembly` and `FactRefreshInterval`. Zero refresh selects the controller default; +negative intervals are rejected. + +`Register(manager, definition, options, input.Binding[CR]) error` validates the +explicit generated contract version, complete binding, config/facts type profiles +and exact role inventory before touching the manager. It copies mutable defaults, +base facts, role names and helper identity. Callback identities remain unchanged; +authors remain responsible for state captured by closures. + +Registration adds core/apps/policy/storage/data ledger and generated API types to the manager +scheme, constructs an independent direct API client using its configuration, +HTTP client and REST mapper, then installs the internal controller. It does not +return a reconciler, read CRs, evaluate business defaults, resolve dependencies, +install CRD/RBAC or start the manager. Register before `manager.Start`. + +Ordinary products call their generated `registration.Register`, which fixes C/S +and infers F. The public input binding is generated-code plumbing. No public +Source/Prepared/Plan or configurable execution stages are added here. + +Tests: `go test ./pkg/framework/operator`; actual external-module registration, +manager startup and dependency refresh are verified by +`TestExternalConsumerGenerationAndAPIRoundtrip` in `../inputgen`. + +DataAsset scheme registration supports automatic retained-data identity records. +It does not start the independent DataOperation executor or grant destructive +permissions; deployment of that executor is a separate composition decision. diff --git a/pkg/framework/operator/registration.go b/pkg/framework/operator/registration.go new file mode 100644 index 00000000..d393d6d7 --- /dev/null +++ b/pkg/framework/operator/registration.go @@ -0,0 +1,116 @@ +// Package operator connects generated input bindings to the internal controller. +// Products use their generated registration companion, not a mutable reconciler. +package operator + +import ( + "context" + "fmt" + "slices" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/zncdatadev/operator-go/internal/framework/controller" + "github.com/zncdatadev/operator-go/internal/framework/pipeline" + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/dataops" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +// Options supplies deployment facts, optional read-only resolution and platform +// assembly settings. Zero FactRefreshInterval selects the controller default. +type Options[C, S, F any] struct { + Facts F + ResolveFacts func(context.Context, framework.FactsReader, + framework.FactInput[C, S, F]) (framework.FactResult[F], error) + Assembly framework.AssemblyOptions + FactRefreshInterval time.Duration +} + +// Register is the generated companion's implementation seam. Registration adds +// types and a controller, but reads no CR, installs no CRD/RBAC and never starts +// the manager. Call it before manager.Start; registration is not a hot update. +func Register[CR input.Object, C, S, F any](manager ctrl.Manager, + definition framework.ProductDefinition[C, S, F], options Options[C, S, F], binding input.Binding[CR], +) error { + if manager == nil { + return fmt.Errorf("registration requires a manager") + } + reconciler, err := prepareRegistration(definition, options, binding) + if err != nil { + return err + } + configuration, scheme := manager.GetConfig(), manager.GetScheme() + if configuration == nil || scheme == nil { + return fmt.Errorf("registration requires the manager configuration and scheme") + } + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, appsv1.AddToScheme, policyv1.AddToScheme, storagev1.AddToScheme, dataops.AddToScheme, binding.AddToScheme, + } { + if err := add(scheme); err != nil { + return fmt.Errorf("register resource types: %w", err) + } + } + // The cache only schedules observations. Intent, ownership and conflict + // retries must use current API reads through this independent client. + direct, err := client.New(configuration, client.Options{ + Scheme: scheme, HTTPClient: manager.GetHTTPClient(), Mapper: manager.GetRESTMapper(), + }) + if err != nil { + return fmt.Errorf("create direct controller client: %w", err) + } + reconciler.Client, reconciler.Scheme = direct, scheme + if err := reconciler.SetupWithManager(manager); err != nil { + return fmt.Errorf("register controller: %w", err) + } + return nil +} + +func prepareRegistration[CR input.Object, C, S, F any](definition framework.ProductDefinition[C, S, F], + options Options[C, S, F], binding input.Binding[CR], +) (*controller.Reconciler[CR, C, S, F], error) { + if err := input.CheckVersion(binding.Version); err != nil { + return nil, err + } + if binding.AddToScheme == nil || len(binding.Roles) == 0 || binding.NewObject == nil || + binding.Status == nil || binding.Operation == nil || binding.Project == nil { + return nil, fmt.Errorf("complete generated input binding is required") + } + if err := pipeline.ValidateDefinition(definition); err != nil { + return nil, fmt.Errorf("register product: %w", err) + } + if options.FactRefreshInterval < 0 { + return nil, fmt.Errorf("fact refresh interval must be zero (default) or positive") + } + roles := make([]string, 0, len(definition.Roles)) + for role := range definition.Roles { + roles = append(roles, role) + } + slices.Sort(roles) + expected := slices.Sorted(slices.Values(binding.Roles)) + if !slices.Equal(roles, expected) { + return nil, fmt.Errorf("product roles %v differ from generated input roles %v; regenerate input artifacts", + roles, expected) + } + // Copy data only. Callback closures retain their identity and the author + // remains responsible for any state captured outside these owned values. + owned := definition + owned.ClusterConfigDefaults = input.Clone(definition.ClusterConfigDefaults) + owned.Roles = make(map[string]framework.RoleDefinition[C], len(definition.Roles)) + for name, role := range definition.Roles { + owned.Roles[name] = input.Clone(role) + } + assembly := options.Assembly + assembly.HelperIdentity = assembly.HelperIdentity.DeepCopy() + assembly.VectorDestination = input.Clone(assembly.VectorDestination) + binding.Roles = slices.Clone(binding.Roles) + return &controller.Reconciler[CR, C, S, F]{Binding: binding, Definition: owned, + Assembly: assembly, Facts: input.Clone(options.Facts), ResolveFacts: options.ResolveFacts, + FactRefreshInterval: options.FactRefreshInterval}, nil +} diff --git a/pkg/framework/operator/registration_test.go b/pkg/framework/operator/registration_test.go new file mode 100644 index 00000000..52791775 --- /dev/null +++ b/pkg/framework/operator/registration_test.go @@ -0,0 +1,163 @@ +package operator + +import ( + "context" + "strings" + "testing" + "time" + + 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" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/zncdatadev/operator-go/pkg/framework" + "github.com/zncdatadev/operator-go/pkg/framework/input" +) + +type registrationConfig struct { + Labels map[string]string `json:"labels"` +} +type registrationCluster struct { + Names []string `json:"names"` +} +type registrationFacts struct { + Catalogs map[string]string `json:"catalogs"` +} +type registrationCR struct { + metav1.TypeMeta + metav1.ObjectMeta + Status framework.ReconcileStatus +} + +func (cr *registrationCR) DeepCopyObject() runtime.Object { + if cr == nil { + return nil + } + out := *cr + cr.DeepCopyInto(&out.ObjectMeta) + cr.Status.DeepCopyInto(&out.Status) + return &out +} +func registrationBinding() input.Binding[*registrationCR] { + return input.Binding[*registrationCR]{Version: input.ContractVersion, Roles: []string{"workers"}, + AddToScheme: func(*runtime.Scheme) error { return nil }, + NewObject: func() *registrationCR { return ®istrationCR{} }, + Operation: func(*registrationCR) framework.ClusterOperation { return framework.ClusterOperation{} }, + Project: func(*registrationCR) (input.Projection, error) { return input.Projection{}, nil }, + Status: func(cr *registrationCR) *framework.ReconcileStatus { return &cr.Status }, + } +} +func registrationDefinition() framework.ProductDefinition[registrationConfig, registrationCluster, registrationFacts] { + return framework.ProductDefinition[registrationConfig, registrationCluster, registrationFacts]{Name: "registration", + ClusterConfigDefaults: registrationCluster{Names: []string{"default"}}, + Roles: map[string]framework.RoleDefinition[registrationConfig]{"workers": { + Config: framework.Config[registrationConfig]{Product: registrationConfig{Labels: map[string]string{"key": "value"}}, + Common: framework.CommonConfig{Resources: framework.Resources{Memory: framework.Memory{Limit: resource.MustParse("1Gi")}}}}, + }}, + GenerateGroup: func(framework.EffectiveInput[registrationConfig, registrationCluster, registrationFacts]) ( + framework.RuntimeDescription, error) { + return framework.RuntimeDescription{}, nil + }, + } +} + +type inaccessibleManager struct{ ctrl.Manager } + +func TestRegistrationRejectsStaticErrorsBeforeManagerAccess(t *testing.T) { + for _, scenario := range []string{"version", "missing-role", "extra-role", "duplicate-role", "missing-generator", + "negative-refresh", "missing-project", "missing-operation", "missing-scheme", "missing-constructor", "missing-status"} { + t.Run(scenario, func(t *testing.T) { + definition, binding := registrationDefinition(), registrationBinding() + options := Options[registrationConfig, registrationCluster, registrationFacts]{} + switch scenario { + case "version": + binding.Version++ + case "missing-role": + delete(definition.Roles, "workers") + case "extra-role": + definition.Roles["another"] = definition.Roles["workers"] + case "duplicate-role": + binding.Roles = append(binding.Roles, "workers") + case "missing-generator": + definition.GenerateGroup = nil + case "negative-refresh": + options.FactRefreshInterval = -time.Second + case "missing-project": + binding.Project = nil + case "missing-operation": + binding.Operation = nil + case "missing-scheme": + binding.AddToScheme = nil + case "missing-constructor": + binding.NewObject = nil + case "missing-status": + binding.Status = nil + } + if err := Register(inaccessibleManager{}, definition, options, binding); err == nil { + t.Fatal("invalid registration accepted") + } + }) + } +} + +func TestRegistrationOwnsDataWithoutCallingProducts(t *testing.T) { + definition, binding := registrationDefinition(), registrationBinding() + definition.ImageDefaults.Custom = "invalid but reparable by user input" + definition.GenerateGroup = func(framework.EffectiveInput[registrationConfig, registrationCluster, registrationFacts]) ( + framework.RuntimeDescription, error) { + t.Fatal("registration generated product resources") + return framework.RuntimeDescription{}, nil + } + definition.ValidateInput = func(framework.EffectiveInput[registrationConfig, registrationCluster, registrationFacts]) error { + t.Fatal("registration validated business defaults") + return nil + } + uid := int64(1001) + options := Options[registrationConfig, registrationCluster, registrationFacts]{ + Facts: registrationFacts{Catalogs: map[string]string{"catalog": "original"}}, + Assembly: framework.AssemblyOptions{HelperIdentity: &corev1.SecurityContext{RunAsUser: &uid}}, + ResolveFacts: func(context.Context, framework.FactsReader, + framework.FactInput[registrationConfig, registrationCluster, registrationFacts]) (framework.FactResult[registrationFacts], error) { + t.Fatal("registration read external facts") + return framework.FactResult[registrationFacts]{}, nil + }, + } + r, err := prepareRegistration(definition, options, binding) + if err != nil { + t.Fatal(err) + } + definition.ClusterConfigDefaults.Names[0] = "changed" + role := definition.Roles["workers"] + role.Config.Product.Labels["key"] = "changed" + role.Config.Common.Resources.Memory.Limit.Add(resource.MustParse("1Gi")) + definition.Roles["workers"] = role + options.Facts.Catalogs["catalog"] = "changed" + binding.Roles[0] = "changed" + uid = 0 + owned := r.Definition.Roles["workers"] + if r.Definition.ClusterConfigDefaults.Names[0] != "default" || owned.Config.Product.Labels["key"] != "value" || + owned.Config.Common.Resources.Memory.Limit.String() != "1Gi" || *r.Assembly.HelperIdentity.RunAsUser != 1001 || + r.Facts.Catalogs["catalog"] != "original" || r.Binding.Roles[0] != "workers" { + t.Fatal("registration retained caller-owned mutable data") + } + if err := Register(nil, definition, options, binding); err == nil { + t.Fatal("nil manager accepted") + } +} + +func TestRegistrationRejectsOpaqueFactsBeforeCopy(t *testing.T) { + type opaque struct{ Unsupported chan string } + definition := framework.ProductDefinition[registrationConfig, registrationCluster, opaque]{Name: "opaque", + Roles: registrationDefinition().Roles, + GenerateGroup: func(framework.EffectiveInput[registrationConfig, registrationCluster, opaque]) ( + framework.RuntimeDescription, error) { + return framework.RuntimeDescription{}, nil + }, + } + err := Register(inaccessibleManager{}, definition, Options[registrationConfig, registrationCluster, opaque]{}, registrationBinding()) + if err == nil || !strings.Contains(err.Error(), "shared facts") { + t.Fatalf("opaque facts accepted: %v", err) + } +} diff --git a/pkg/framework/platform.go b/pkg/framework/platform.go new file mode 100644 index 00000000..415f19e3 --- /dev/null +++ b/pkg/framework/platform.go @@ -0,0 +1,56 @@ +package framework + +// ClusterConfig is the framework-owned part of the flat clusterConfig object. +// Product-specific S remains separate in Go and shares this object on the wire. +type ClusterConfig struct { + VectorAgentConfigMap string `json:"vectorAgentConfigMap"` + Authentication []Authentication `json:"authentication,omitempty"` +} + +// Authentication references a platform AuthenticationClass; OIDC client identity +// is application-specific and does not belong to the shared provider definition. +type Authentication struct { + AuthenticationClass string `json:"authenticationClass"` + OIDC OIDCClient `json:"oidc"` +} +type OIDCClient struct { + ClientCredentialsSecret string `json:"clientCredentialsSecret"` + ExtraScopes []string `json:"extraScopes,omitempty"` +} + +// SecretVolume declares a native Secret or a SecretClass CSI directory. Exactly +// one source is set. Secret bytes never enter generated ConfigMaps or status. +type SecretVolume struct { + SecretName string + SecretClass string + Format string + Scope []string + KerberosServiceNames []string +} + +// ListenerVolume declares the producer of a listener result. A class creates a +// per-Pod Listener; Name references an existing one. The CSI driver owns its files. +type ListenerVolume struct { + Class string + Name string +} + +type ListenerAddress struct { + Pod string `json:"pod"` + Directory string `json:"directory"` + Address string `json:"address"` + Ports map[string]int32 `json:"ports"` +} + +// PlatformObservation is obtained after workload application. Pending does not +// withhold the Pod that produces this result. Addresses are observed, not guessed. +type PlatformObservation struct { + Phase string `json:"phase"` + Diagnostic FactDiagnostic `json:"diagnostic"` + Listeners []ListenerAddress `json:"listeners,omitempty"` +} + +const ( + SecretScopePod = "pod" + SecretScopeNode = "node" +) diff --git a/pkg/framework/properties.go b/pkg/framework/properties.go new file mode 100644 index 00000000..5f57eaf2 --- /dev/null +++ b/pkg/framework/properties.go @@ -0,0 +1,48 @@ +package framework + +import ( + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" +) + +// PropertiesCodec writes deterministic UTF-8 Java properties syntax. Properties +// are encoded as data: line breaks, separators and edge spaces cannot introduce +// another entry. Runtime support is provided by the matching materialization helper. +type PropertiesCodec struct{} + +func (PropertiesCodec) Encode(values map[string]string) (string, error) { + var output strings.Builder + for _, key := range slices.Sorted(maps.Keys(values)) { + value := values[key] + if !utf8.ValidString(key) || !utf8.ValidString(value) { + return "", fmt.Errorf("properties keys and values must be valid UTF-8") + } + output.WriteString(propertyKeyEscapes.Replace(key)) + output.WriteByte('=') + output.WriteString(escapePropertyValue(value)) + output.WriteByte('\n') + } + return output.String(), nil +} + +var propertyKeyEscapes = strings.NewReplacer( + "\\", `\\`, "=", `\=`, ":", `\:`, " ", `\ `, "#", `\#`, "!", `\!`, + "\n", `\n`, "\r", `\r`, "\t", `\t`, "\f", `\f`, +) + +var propertyValueEscapes = strings.NewReplacer("\\", `\\`, "\n", `\n`, "\r", `\r`, "\t", `\t`, "\f", `\f`) + +func escapePropertyValue(value string) string { + escaped := propertyValueEscapes.Replace(value) + first, last := 0, len(escaped) + for first < last && escaped[first] == ' ' { + first++ + } + for last > first && escaped[last-1] == ' ' { + last-- + } + return strings.Repeat(`\ `, first) + escaped[first:last] + strings.Repeat(`\ `, len(escaped)-last) +} diff --git a/pkg/framework/properties_test.go b/pkg/framework/properties_test.go new file mode 100644 index 00000000..d54febed --- /dev/null +++ b/pkg/framework/properties_test.go @@ -0,0 +1,63 @@ +package framework_test + +import ( + "reflect" + "testing" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestPropertiesCodecEncodesEntriesDeterministically(t *testing.T) { + values := map[string]string{ + "z": "$(touch NEVER); `exit 42` ${POD_NAME}", + "": "WARN", + "a": "日志🦆", + } + want := "=WARN\na=日志🦆\nz=$(touch NEVER); `exit 42` ${POD_NAME}\n" + var codec framework.PropertyCodec = framework.PropertiesCodec{} + for range 3 { + got, err := codec.Encode(values) + if err != nil || got != want { + t.Fatalf("encoded=%q, want=%q, error=%v", got, want, err) + } + } + if !reflect.DeepEqual(values, map[string]string{ + "z": "$(touch NEVER); `exit 42` ${POD_NAME}", "": "WARN", "a": "日志🦆", + }) { + t.Fatal("codec mutated product property values") + } +} + +func TestPropertiesCodecEscapesSyntaxAndPreservesWhitespace(t *testing.T) { + values := map[string]string{ + " #!=:\\\n\r\t\f": " leading \nnext\r\t\f\\ trailing ", + } + want := `\ \#\!\=\:\\\n\r\t\f=\ leading \nnext\r\t\f\\ trailing\ ` + "\n" + got, err := (framework.PropertiesCodec{}).Encode(values) + if err != nil || got != want { + t.Fatalf("encoded=%q, want=%q, error=%v", got, want, err) + } + for _, test := range []struct{ value, want string }{ + {"", "key=\n"}, {" ", "key=\\ \n"}, {" ", "key=\\ \\ \n"}, {"one two", "key=one two\n"}, + } { + got, err := (framework.PropertiesCodec{}).Encode(map[string]string{"key": test.value}) + if err != nil || got != test.want { + t.Fatalf("value=%q encoded=%q, want=%q, error=%v", test.value, got, test.want, err) + } + } +} + +func TestPropertiesCodecRejectsInvalidUTF8WithoutPartialOutput(t *testing.T) { + for _, values := range []map[string]string{ + {"ok": "value", "z": string([]byte{0xff})}, {string([]byte{0xff}): "value"}, + } { + if got, err := (framework.PropertiesCodec{}).Encode(values); err == nil || got != "" { + t.Fatalf("invalid UTF-8 produced output %q or no error: %v", got, err) + } + } + for _, values := range []map[string]string{nil, {}} { + if got, err := (framework.PropertiesCodec{}).Encode(values); err != nil || got != "" { + t.Fatalf("empty properties produced %q, %v", got, err) + } + } +} diff --git a/pkg/framework/runtime.go b/pkg/framework/runtime.go new file mode 100644 index 00000000..29099af8 --- /dev/null +++ b/pkg/framework/runtime.go @@ -0,0 +1,104 @@ +package framework + +import ( + corev1 "k8s.io/api/core/v1" +) + +type RuntimeDescription struct { + ConfigDirectory string + Main Process + Files []File + Directories []Directory + SharedGroup *int64 + Endpoints []Endpoint + LogOutputs []LogOutput + // Initializers run in declaration order after file materialization and before Main. + Initializers []Process + Coordination *WorkloadCoordination +} + +type Process struct { + Name, Image string + Command, Args []string + Env []corev1.EnvVar + Identity *corev1.SecurityContext + Access []DirectoryAccess + Lifecycle *corev1.Lifecycle + StartupProbe, ReadinessProbe, LivenessProbe *corev1.Probe +} + +// Directory names a runtime directory. Data binds the one supported product +// data directory to effective config.resources.storage. All other directories +// are ephemeral. Configuration files and logs require separate directories. +type Directory struct { + Secret *SecretVolume + Listener *ListenerVolume + Name string + Data bool +} + +type DirectoryAccess struct { + Directory, MountPath string + ReadOnly bool +} + +type File struct { + Directory, Path string + Content FileContent +} + +// FileContent is the closed set of supported declaration forms. Use variant +// values, not pointers; the framework preserves structure until overrides finish. +type FileContent interface{ fileContent() } + +type KeyValues struct { + Codec PropertyCodec + Values map[string]PropertyValue +} + +type Lines []string +type Text string + +func (KeyValues) fileContent() {} +func (Lines) fileContent() {} +func (Text) fileContent() {} + +// PropertyCodec encodes resolved property values as product data. A custom Go +// codec does not imply that the runtime materialization helper can execute it. +type PropertyCodec interface { + Encode(map[string]string) (string, error) +} + +// PropertyValue keeps a literal distinct from a deferred per-Pod value. +type PropertyValue interface{ propertyValue() } + +type Literal string +type PodNameBinding struct{} + +func (Literal) propertyValue() {} +func (PodNameBinding) propertyValue() {} + +type Endpoint struct { + Name string + Port int32 +} + +// LogOutput declares a file the product actually produces. Collection is chosen +// once by the framework from effective Logging.EnableVectorAgent, not per output. +type LogOutput struct { + Container, Directory, RelativePath string +} + +type CheckState string + +const ( + Consistent CheckState = "consistent" + Conflict CheckState = "conflict" + Unknown CheckState = "unknown" +) + +type Check struct { + Subject string `json:"subject"` + State CheckState `json:"state"` + Reason string `json:"reason,omitempty"` +} diff --git a/pkg/framework/s3.go b/pkg/framework/s3.go new file mode 100644 index 00000000..e60b168b --- /dev/null +++ b/pkg/framework/s3.go @@ -0,0 +1,242 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + "net" + "reflect" + "strconv" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +type S3ConnectionType string + +const ( + S3Disabled S3ConnectionType = "disabled" + S3Inline S3ConnectionType = "inline" + S3Reference S3ConnectionType = "reference" +) + +// S3Connection is a domain value, not a generic tagged union. A zero default is +// disabled. Changing Type discards the inherited connection branch; omitting it +// or preserving it inherits fields within that branch. +type S3Connection struct { + Type S3ConnectionType `json:"type"` + Inline S3Endpoint `json:"inline"` + Reference string `json:"reference"` +} + +type S3Endpoint struct { + Host string `json:"host"` + Port int32 `json:"port"` + TLS bool `json:"tls"` + Region string `json:"region"` + PathStyle bool `json:"pathStyle"` + Credentials S3Credentials `json:"credentials"` +} + +// S3Credentials declares where runtime files ACCESS_KEY and SECRET_KEY come +// from. Values never enter effective configuration, facts or generated files. +type S3Credentials struct { + SecretName string `json:"secretName"` + SecretClass string `json:"secretClass"` + Scope []string `json:"scope,omitempty"` +} + +// ResolvedS3Connection contains only public endpoint data and credential +// references. Authentication and bucket permissions are observed by the product. +type ResolvedS3Connection struct { + Endpoint string `json:"endpoint"` + Region string `json:"region"` + PathStyle bool `json:"pathStyle"` + Credentials S3Credentials `json:"credentials"` +} + +// Validate checks a fully folded value; individual role/group layers can be +// partial. No implicit credential provider or insecure TLS downgrade is selected. +func (s S3Connection) Validate() error { + switch s.Type { + case "", S3Disabled: + if s.Reference != "" || !reflect.DeepEqual(s.Inline, S3Endpoint{}) { + return fmt.Errorf("disabled S3 cannot carry an inline connection or reference") + } + case S3Reference: + if s.Reference == "" || len(validation.IsDNS1123Subdomain(s.Reference)) != 0 || + !reflect.DeepEqual(s.Inline, S3Endpoint{}) { + return fmt.Errorf("reference S3 requires a DNS resource name and no inline fields") + } + case S3Inline: + if s.Reference != "" { + return fmt.Errorf("inline S3 cannot carry a reference") + } + _, err := resolveS3Endpoint(s.Inline) + return err + default: + return fmt.Errorf("S3 type must be disabled, inline or reference") + } + return nil +} + +func resolveS3Endpoint(endpoint S3Endpoint) (ResolvedS3Connection, error) { + if net.ParseIP(endpoint.Host) == nil && len(validation.IsDNS1123Subdomain(endpoint.Host)) != 0 { + return ResolvedS3Connection{}, fmt.Errorf("S3 host must be a DNS name or IP address") + } + if endpoint.Port < 0 || endpoint.Port > 65535 { + return ResolvedS3Connection{}, fmt.Errorf("S3 port must be between 1 and 65535, or absent") + } + protocol := "http" + if endpoint.TLS { + protocol = "https" + } + if endpoint.Port == 0 { + endpoint.Port = 80 + if endpoint.TLS { + endpoint.Port = 443 + } + } + if endpoint.Region == "" { + endpoint.Region = "us-east-1" + } + if strings.TrimSpace(endpoint.Region) != endpoint.Region || strings.ContainsAny(endpoint.Region, "\r\n\x00") { + return ResolvedS3Connection{}, fmt.Errorf("S3 region must be a single nonempty value") + } + credentials := endpoint.Credentials + if (credentials.SecretName == "") == (credentials.SecretClass == "") { + return ResolvedS3Connection{}, fmt.Errorf("S3 credentials require exactly one SecretName or SecretClass") + } + for _, name := range []string{credentials.SecretName, credentials.SecretClass} { + if name != "" && len(validation.IsDNS1123Subdomain(name)) != 0 { + return ResolvedS3Connection{}, fmt.Errorf("S3 credential reference is invalid") + } + } + if credentials.SecretName != "" && len(credentials.Scope) != 0 { + return ResolvedS3Connection{}, fmt.Errorf("S3 credential scope applies only to SecretClass") + } + seen := map[string]bool{} + for _, scope := range credentials.Scope { + kind, name, qualified := strings.Cut(scope, "=") + valid := !qualified && (kind == SecretScopePod || kind == SecretScopeNode) + if qualified && (kind == "service" || kind == "listener-volume") { + valid = name != "" && len(validation.IsDNS1123Label(name)) == 0 + } + if !valid || seen[scope] { + return ResolvedS3Connection{}, fmt.Errorf("S3 credential scope is invalid or duplicated") + } + seen[scope] = true + } + return ResolvedS3Connection{Endpoint: protocol + "://" + net.JoinHostPort(endpoint.Host, strconv.Itoa(int(endpoint.Port))), + Region: endpoint.Region, PathStyle: endpoint.PathStyle, Credentials: credentials}, nil +} + +// ResolveS3Connection consumes inline input or the organization's S3Connection +// resource in the workload namespace. SecretClass materialization remains a +// runtime platform dependency, never a prerequisite requiring generated bytes. +func ResolveS3Connection(ctx context.Context, reader FactsReader, namespace string, connection S3Connection) ( + FactResult[ResolvedS3Connection], error, +) { + invalid := func(message string) (FactResult[ResolvedS3Connection], error) { + return FactResult[ResolvedS3Connection]{Diagnostic: FactDiagnostic{State: FactsInvalid, + Reason: "InvalidS3Connection", Message: message}}, nil + } + if err := connection.Validate(); err != nil { + return invalid(err.Error()) + } + if connection.Type == "" || connection.Type == S3Disabled { + return FactResult[ResolvedS3Connection]{Value: &ResolvedS3Connection{}, + Diagnostic: FactDiagnostic{State: FactsResolved, Reason: "S3Disabled"}}, nil + } + endpoint := connection.Inline + if connection.Type == S3Reference { + object := &unstructured.Unstructured{} + object.SetGroupVersionKind(schema.GroupVersionKind{Group: "s3.kubedoop.dev", Version: "v1alpha1", Kind: "S3Connection"}) + if err := reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: connection.Reference}, object); err != nil { + if apierrors.IsNotFound(err) { + return FactResult[ResolvedS3Connection]{Diagnostic: FactDiagnostic{State: FactsPending, + Reason: "S3ConnectionMissing", Message: "Waiting for referenced S3Connection"}}, nil + } + return FactResult[ResolvedS3Connection]{}, err + } + if !object.GetDeletionTimestamp().IsZero() { + return FactResult[ResolvedS3Connection]{Diagnostic: FactDiagnostic{State: FactsPending, + Reason: "S3ConnectionDeleting", Message: "Referenced S3Connection is deleting"}}, nil + } + var err error + endpoint, err = decodeS3Resource(object.Object["spec"]) + if err != nil { + return invalid("Referenced S3Connection is outside the supported endpoint/credential contract") + } + } + resolved, err := resolveS3Endpoint(endpoint) + if err != nil { + return invalid(err.Error()) + } + return FactResult[ResolvedS3Connection]{Value: &resolved, + Diagnostic: FactDiagnostic{State: FactsResolved, Reason: "S3ConnectionResolved"}}, nil +} + +func decodeS3Resource(value any) (S3Endpoint, error) { + var spec struct { + Host, Region string + Port int32 + PathStyle bool + TLS json.RawMessage + Credentials struct { + SecretClass string + Scope struct { + Node, Pod bool + Services, ListenerVolumes []string + } + } + } + data, err := json.Marshal(value) + if err != nil { + return S3Endpoint{}, err + } + if err := json.Unmarshal(data, &spec); err != nil { + return S3Endpoint{}, err + } + endpoint := S3Endpoint{Host: spec.Host, Port: spec.Port, Region: spec.Region, PathStyle: spec.PathStyle, + Credentials: S3Credentials{SecretClass: spec.Credentials.SecretClass}} + if len(spec.TLS) != 0 && string(spec.TLS) != "null" { + var tls struct { + Verification *struct { + None json.RawMessage + Server *struct { + CACert *struct { + WebPKI *struct{} + SecretClass string + } + } + } + } + if err := json.Unmarshal(spec.TLS, &tls); err != nil { + return S3Endpoint{}, err + } + if tls.Verification != nil && (len(tls.Verification.None) != 0 || tls.Verification.Server == nil || + tls.Verification.Server.CACert == nil || tls.Verification.Server.CACert.SecretClass != "" || + tls.Verification.Server.CACert.WebPKI == nil) { + return S3Endpoint{}, fmt.Errorf("only verified system-CA TLS is supported") + } + endpoint.TLS = true + } + if spec.Credentials.Scope.Node { + endpoint.Credentials.Scope = append(endpoint.Credentials.Scope, "node") + } + if spec.Credentials.Scope.Pod { + endpoint.Credentials.Scope = append(endpoint.Credentials.Scope, "pod") + } + for _, name := range spec.Credentials.Scope.Services { + endpoint.Credentials.Scope = append(endpoint.Credentials.Scope, "service="+name) + } + for _, name := range spec.Credentials.Scope.ListenerVolumes { + endpoint.Credentials.Scope = append(endpoint.Credentials.Scope, "listener-volume="+name) + } + return endpoint, nil +} diff --git a/pkg/framework/s3_test.go b/pkg/framework/s3_test.go new file mode 100644 index 00000000..63f7bc3a --- /dev/null +++ b/pkg/framework/s3_test.go @@ -0,0 +1,68 @@ +package framework + +import ( + "context" + "encoding/json" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +type s3TestReader struct { + spec map[string]any + err error + reads int + key types.NamespacedName +} + +func (r *s3TestReader) Get(_ context.Context, key types.NamespacedName, object FactResource) error { + r.reads++ + r.key = key + object.(*unstructured.Unstructured).Object["spec"] = r.spec + return r.err +} + +func TestS3ConnectionReferencesAndCredentialIdentity(t *testing.T) { + reader := &s3TestReader{spec: map[string]any{"host": "minio.test.svc", "port": int64(9000), "pathStyle": true, + "credentials": map[string]any{"secretClass": "s3-credentials", "scope": map[string]any{"pod": true}}}} + connection := S3Connection{Type: S3Reference, Reference: "warehouse"} + result, err := ResolveS3Connection(context.Background(), reader, "test", connection) + if err != nil || result.Diagnostic.State != FactsResolved || result.Value == nil { + t.Fatalf("reference resolution: %+v %v", result, err) + } + if result.Value.Endpoint != "http://minio.test.svc:9000" || !result.Value.PathStyle || + result.Value.Region != "us-east-1" || result.Value.Credentials.SecretClass != "s3-credentials" || + len(result.Value.Credentials.Scope) != 1 || result.Value.Credentials.Scope[0] != "pod" || + reader.key != (types.NamespacedName{Namespace: "test", Name: "warehouse"}) { + t.Fatalf("resolved resource lost its endpoint or credential identity: %+v", result.Value) + } + // JSON facts contain only references. No Secret was read to resolve the + // endpoint, and no secret bytes become config-generation data. + if _, err := json.Marshal(result.Value); err != nil || reader.reads != 1 { + t.Fatal("S3 resolution crossed the endpoint/credential-materialization boundary") + } + reader.err = apierrors.NewNotFound(schema.GroupResource{Group: "s3.kubedoop.dev", Resource: "s3connections"}, "warehouse") + result, err = ResolveS3Connection(context.Background(), reader, "test", connection) + if err != nil || result.Diagnostic.State != FactsPending || result.Value != nil { + t.Fatalf("missing S3 reference did not wait: %+v %v", result, err) + } + reader.err = nil + reader.spec["tls"] = map[string]any{"verification": map[string]any{"none": map[string]any{}}} + result, err = ResolveS3Connection(context.Background(), reader, "test", connection) + if err != nil || result.Diagnostic.State != FactsInvalid || result.Value != nil { + t.Fatal("unsupported TLS verification was silently downgraded") + } +} + +func TestS3InlineDoesNotReadExternalConnection(t *testing.T) { + reader := &s3TestReader{} + result, err := ResolveS3Connection(context.Background(), reader, "test", S3Connection{Type: S3Inline, + Inline: S3Endpoint{Host: "s3.example.com", TLS: true, Credentials: S3Credentials{SecretName: "s3"}}}) + if err != nil || result.Diagnostic.State != FactsResolved || result.Value.Endpoint != "https://s3.example.com:443" || + reader.reads != 0 { + t.Fatalf("inline endpoint resolution: %+v %v", result, err) + } +} diff --git a/pkg/framework/status.go b/pkg/framework/status.go new file mode 100644 index 00000000..09101ef9 --- /dev/null +++ b/pkg/framework/status.go @@ -0,0 +1,84 @@ +package framework + +import ( + "maps" + "slices" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ReconcileStatus reports controller observations separately from configuration +// inputs. Workload readiness does not assert application health or data durability. +type ReconcileStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + Groups []GroupReconcileStatus `json:"groups,omitempty"` + Roles []RoleReconcileStatus `json:"roles,omitempty"` +} + +type RoleReconcileStatus struct { + Name string `json:"name"` + Applied bool `json:"applied"` + Message string `json:"message,omitempty"` +} + +type GroupReconcileStatus struct { + Platform *PlatformObservation `json:"platform,omitempty"` + Role string `json:"role"` + Name string `json:"name"` + ExecutionReplicas *int32 `json:"executionReplicas,omitempty"` + DesiredReplicas int32 `json:"desiredReplicas"` + ReadyReplicas int32 `json:"readyReplicas"` + Applied bool `json:"applied"` + Message string `json:"message,omitempty"` + Checks []Check `json:"checks,omitempty"` + Facts *FactDiagnostic `json:"facts,omitempty"` +} + +// DeepCopyInto copies only the fixed status data model. It does not depend on +// the generated-input copier or introduce an inverse package dependency. +func (in *ReconcileStatus) DeepCopyInto(out *ReconcileStatus) { + *out = *in + out.Roles = slices.Clone(in.Roles) + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + for index := range in.Conditions { + in.Conditions[index].DeepCopyInto(&out.Conditions[index]) + } + } + if in.Groups != nil { + out.Groups = make([]GroupReconcileStatus, len(in.Groups)) + for index := range in.Groups { + group := in.Groups[index] + out.Groups[index] = group + out.Groups[index].Checks = slices.Clone(group.Checks) + if group.ExecutionReplicas != nil { + value := *group.ExecutionReplicas + out.Groups[index].ExecutionReplicas = &value + } + if group.Platform != nil { + p := *group.Platform + p.Diagnostic.Observed = slices.Clone(group.Platform.Diagnostic.Observed) + p.Listeners = slices.Clone(group.Platform.Listeners) + for i := range p.Listeners { + p.Listeners[i].Ports = maps.Clone(p.Listeners[i].Ports) + } + out.Groups[index].Platform = &p + } + if group.Facts != nil { + value := *group.Facts + value.Observed = slices.Clone(group.Facts.Observed) + out.Groups[index].Facts = &value + } + } + } +} + +func (in *ReconcileStatus) DeepCopy() *ReconcileStatus { + if in == nil { + return nil + } + out := new(ReconcileStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/framework/status_test.go b/pkg/framework/status_test.go new file mode 100644 index 00000000..d2c88cd4 --- /dev/null +++ b/pkg/framework/status_test.go @@ -0,0 +1,72 @@ +package framework_test + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/zncdatadev/operator-go/pkg/framework" +) + +func TestStatusDeepCopyIsolatesFixedMutableFields(t *testing.T) { + count := int32(0) + original := framework.ReconcileStatus{ + ObservedGeneration: 7, + Conditions: []metav1.Condition{{Type: "Stopped", Status: metav1.ConditionTrue, ObservedGeneration: 7, + LastTransitionTime: metav1.NewTime(time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC))}}, + Roles: []framework.RoleReconcileStatus{{Name: "workers", Applied: true}}, + Groups: []framework.GroupReconcileStatus{{Name: "default", Role: "workers", ExecutionReplicas: &count, + Checks: []framework.Check{{Subject: "files", State: framework.Unknown}}, + Facts: &framework.FactDiagnostic{State: framework.FactsResolved, Observed: []framework.FactObject{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "catalogs", UID: "source-uid", ResourceVersion: "8"}}}, + }}, + } + before, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + copy := original.DeepCopy() + copy.Conditions[0].ObservedGeneration = 99 + copy.Conditions[0].LastTransitionTime = metav1.NewTime(time.Now()) + copy.Roles[0].Name = "changed" + copy.Groups[0].Name = "changed" + *copy.Groups[0].ExecutionReplicas = 4 + copy.Groups[0].Checks[0].Reason = "changed" + copy.Groups[0].Facts.State = framework.FactsReadError + copy.Groups[0].Facts.Observed[0].UID = "another-object" + after, err := json.Marshal(original) + if err != nil || string(before) != string(after) { + t.Fatalf("copy mutation changed original status: %s, %v", after, err) + } + for _, fragment := range []string{`"executionReplicas":0`, `"desiredReplicas":0`, `"readyReplicas":0`, + `"applied":false`, `"observedGeneration":7`, `"state":"unknown"`, `"resourceVersion":"8"`} { + if !strings.Contains(string(before), fragment) { + t.Fatalf("status wire contract lost %s", fragment) + } + } +} + +func TestStatusDeepCopyPreservesNilAndEmptyCollections(t *testing.T) { + if (*framework.ReconcileStatus)(nil).DeepCopy() != nil { + t.Fatal("nil status became a non-nil value") + } + for _, original := range []framework.ReconcileStatus{ + {}, {Conditions: []metav1.Condition{}, Roles: []framework.RoleReconcileStatus{}, + Groups: []framework.GroupReconcileStatus{}}, + {Groups: []framework.GroupReconcileStatus{{Checks: []framework.Check{}, + Facts: &framework.FactDiagnostic{Observed: []framework.FactObject{}}}}}, + } { + if !reflect.DeepEqual(original, *original.DeepCopy()) { + t.Fatalf("status copy changed collection presence: %+v", original) + } + } +} + +// The public facts constraint accepts ordinary Kubernetes API objects without +// requiring a write client or an import of the framework's controller package. +var _ framework.FactResource = (*corev1.ConfigMap)(nil) diff --git a/pkg/framework/vector.go b/pkg/framework/vector.go new file mode 100644 index 00000000..5cbb97bc --- /dev/null +++ b/pkg/framework/vector.go @@ -0,0 +1,63 @@ +package framework + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +// VectorDestination is a resolved, non-secret Vector protocol endpoint. It is +// platform data, not an arbitrary fragment of Vector configuration. +type VectorDestination struct { + Address string +} + +// Validate checks the deliberately bounded ADDRESS discovery contract. URLs, +// credentials, paths and environment substitutions are not endpoint addresses. +func (d VectorDestination) Validate() error { + host, port, err := net.SplitHostPort(d.Address) + if err != nil || strings.TrimSpace(d.Address) != d.Address || host == "" { + return fmt.Errorf("ADDRESS must contain a host and port") + } + number, err := strconv.Atoi(port) + if err != nil || strings.Trim(port, "0123456789") != "" || number < 1 || number > 65535 { + return fmt.Errorf("ADDRESS port must be between 1 and 65535") + } + if net.ParseIP(host) == nil && len(validation.IsDNS1123Subdomain(host)) != 0 { + return fmt.Errorf("ADDRESS host must be a DNS name or IP address") + } + return nil +} + +// ResolveVectorDestination reads the standard same-namespace ConfigMap's +// ADDRESS. NotFound means Pending; invalid data means Invalid; failed API reads +// remain errors. The controller's tracked reader supplies observed UID/RV. +func ResolveVectorDestination(ctx context.Context, reader FactsReader, namespace, name string) ( + FactResult[VectorDestination], error, +) { + if len(validation.IsDNS1123Subdomain(name)) != 0 { + return FactResult[VectorDestination]{Diagnostic: FactDiagnostic{State: FactsInvalid, + Reason: "InvalidVectorReference", Message: "vectorAgentConfigMap must name a ConfigMap"}}, nil + } + var config corev1.ConfigMap + if err := reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &config); err != nil { + if apierrors.IsNotFound(err) { + return FactResult[VectorDestination]{Diagnostic: FactDiagnostic{State: FactsPending, + Reason: "VectorDestinationMissing", Message: "Waiting for vectorAgentConfigMap " + namespace + "/" + name}}, nil + } + return FactResult[VectorDestination]{}, err + } + destination := VectorDestination{Address: config.Data["ADDRESS"]} + if err := destination.Validate(); err != nil { + return FactResult[VectorDestination]{Diagnostic: FactDiagnostic{State: FactsInvalid, + Reason: "InvalidVectorDestination", Message: "vectorAgentConfigMap " + namespace + "/" + name + ": " + err.Error()}}, nil + } + return FactResult[VectorDestination]{Value: &destination, Diagnostic: FactDiagnostic{State: FactsResolved}}, nil +} diff --git a/pkg/framework/vector_test.go b/pkg/framework/vector_test.go new file mode 100644 index 00000000..764036e4 --- /dev/null +++ b/pkg/framework/vector_test.go @@ -0,0 +1,58 @@ +package framework + +import ( + "context" + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +type vectorReader struct { + address string + err error + key types.NamespacedName +} + +func (r *vectorReader) Get(_ context.Context, key types.NamespacedName, object FactResource) error { + r.key = key + object.(*corev1.ConfigMap).Data = map[string]string{"ADDRESS": r.address} + return r.err +} + +func TestResolveVectorDestination(t *testing.T) { + readError := errors.New("api unavailable") + for _, tc := range []struct { + name, address string + err error + state FactState + }{ + {name: "resolved", address: "aggregator.test.svc:6000", state: FactsResolved}, + {name: "missing", err: apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "destination"), state: FactsPending}, + {name: "empty", state: FactsInvalid}, + {name: "invalid-port", address: "aggregator.test.svc:0", state: FactsInvalid}, + {name: "url", address: "https://aggregator.test.svc:6000", state: FactsInvalid}, + {name: "substitution", address: "${DESTINATION}:6000", state: FactsInvalid}, + {name: "api-error", err: readError}, + } { + t.Run(tc.name, func(t *testing.T) { + reader := &vectorReader{address: tc.address, err: tc.err} + result, err := ResolveVectorDestination(context.Background(), reader, "test", "destination") + if tc.name == "api-error" { + if !errors.Is(err, readError) { + t.Fatal("failed API read was hidden") + } + return + } + if err != nil || result.Diagnostic.State != tc.state || (result.Value != nil) != (tc.state == FactsResolved) { + t.Fatalf("resolution: %+v %v", result, err) + } + if reader.key != (types.NamespacedName{Namespace: "test", Name: "destination"}) { + t.Fatal("reference escaped its namespace") + } + }) + } +}