diff --git a/.github/actions/checks/action.yml b/.github/actions/checks/action.yml index 03d88694..d392ec65 100644 --- a/.github/actions/checks/action.yml +++ b/.github/actions/checks/action.yml @@ -12,32 +12,22 @@ runs: - name: pre_env uses: ./.github/actions/dev_env - - name: check_license_header - uses: apache/skywalking-eyes/header@v0.6.0 - env: - GITHUB_TOKEN: ${{ inputs.github_token }} - with: - log: info - config: .licenserc.yml - - - name: setup go version - uses: actions/setup-go@v3 - with: - check-latest: true - go-version: - ${{ env.golang-version }} - - name: verify shell: bash run: make verify - # disable action cache due to https://github.com/golangci/golangci-lint-action/issues/244 - # upgrade due to https://github.com/golangci/golangci-lint/issues/2374 - # skip cache due to https://github.com/golangci/golangci-lint-action/issues/23 - name: golangci_lint - uses: golangci/golangci-lint-action@v3.1.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # tag=v9.3.0 with: - version: v1.50.0 + version: v2.1.6 args: --timeout 10m0s - skip-pkg-cache: true - skip-build-cache: true + + # The header action installs its own Go toolchain while building license-eye, + # so keep it last to avoid changing the toolchain used by repository checks. + - name: check_license_header + uses: apache/skywalking-eyes/header@a196742f472feaffafea537ce5a2a4c3c53a8de4 # tag=v0.9.0 + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + with: + log: info + config: .licenserc.yml diff --git a/.github/actions/dev_env/action.yml b/.github/actions/dev_env/action.yml index 5405cd95..19c627a9 100644 --- a/.github/actions/dev_env/action.yml +++ b/.github/actions/dev_env/action.yml @@ -6,12 +6,14 @@ runs: steps: - name: import env variables + id: versions shell: bash - run: cat ".github/env" >> $GITHUB_ENV + run: | + cat ".github/env" >> "$GITHUB_ENV" + sed -n 's/^golang-version=/go_version=/p' ".github/env" >> "$GITHUB_OUTPUT" - name: setup go version - uses: actions/setup-go@v3 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # tag=v7.0.0 with: - check-latest: true - go-version: - ${{ env.golang-version }} + check-latest: false + go-version: ${{ steps.versions.outputs.go_version }} diff --git a/.github/actions/e2e/action.yml b/.github/actions/e2e/action.yml index a55f96bc..2e3a7cf1 100644 --- a/.github/actions/e2e/action.yml +++ b/.github/actions/e2e/action.yml @@ -13,14 +13,14 @@ runs: uses: ./.github/actions/dev_env - name: setup helm - uses: azure/setup-helm@v1 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # tag=v5.0.1 with: version: '${{ env.helm-version }}' - name: setup kind - uses: engineerd/setup-kind@v0.5.0 + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # tag=v1.14.0 with: - skipClusterCreation: true + install_only: true version: v0.15.0 - name: Run kind test diff --git a/.github/env b/.github/env index 63ed3569..66fd36b7 100644 --- a/.github/env +++ b/.github/env @@ -1,4 +1,4 @@ -golang-version=1.22 +golang-version=1.23.1 kind-version=v0.11.1 kind-image=kindest/node:v1.23.0 helm-version=v3.8.1 diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index abf8c64a..99a341bb 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -10,7 +10,7 @@ on: default: '["0.8.0", "1.0.0-rc1"]' concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event-name }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: @@ -21,7 +21,7 @@ jobs: matrix: moVersion: ${{ fromJSON(github.event.inputs.moVersions)}} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: run uses: ./.github/actions/e2e with: diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index c4e0a193..0522ab51 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -8,10 +8,10 @@ jobs: add_labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions-ecosystem/action-add-labels@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 + - uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # tag=v1.1.3 if: ${{ startsWith(github.event.comment.body, '/merge') }} with: github_token: ${{ secrets.GITHUB_TOKEN }} labels: | - can-merge \ No newline at end of file + can-merge diff --git a/.github/workflows/release_chart.yml b/.github/workflows/release_chart.yml index 6475f4c7..693850da 100644 --- a/.github/workflows/release_chart.yml +++ b/.github/workflows/release_chart.yml @@ -18,12 +18,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 with: fetch-depth: 0 - name: Install Helm - uses: azure/setup-helm@f382f75448129b3be48f8121b9857be18d815a82 # tag=v3.4 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # tag=v5.0.1 - name: Configure Git run: | @@ -32,13 +32,10 @@ jobs: - name: Package charts run: | - mkdir -p charts/matrixone-operator/charts .cr-release-packages .cr-index + mkdir -p .cr-release-packages .cr-index helm package charts/kruise \ - --destination charts/matrixone-operator/charts - helm package charts/kruise \ - --destination .cr-release-packages - helm package charts/matrixone-operator \ --destination .cr-release-packages + ./hack/package-chart.sh .cr-release-packages - name: Run Artifact Hub lint run: | @@ -47,7 +44,7 @@ jobs: rm -f ./ah - name: Install chart-releaser - uses: helm/chart-releaser-action@98bccfd32b0f76149d188912ac8e45ddd3f8695f # tag=v1.4.1 + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # tag=v1.7.0 with: install_only: true env: diff --git a/.github/workflows/release_image.yml b/.github/workflows/release_image.yml index a757d986..ec6da0ab 100644 --- a/.github/workflows/release_image.yml +++ b/.github/workflows/release_image.yml @@ -14,12 +14,12 @@ jobs: name: push_image steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 with: fetch-depth: 0 - name: Docker meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # tag=v6.2.0 id: meta with: images: | @@ -31,42 +31,42 @@ jobs: type=semver,pattern={{version}} - name: Set up QEMU - uses: docker/setup-qemu-action@master + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # tag=v4.3.0 with: image: tonistiigi/binfmt:latest platforms: linux/amd64,linux/arm64 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # tag=v4.3.0 - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # tag=v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Alicloud Container Registry id: login_alicr - uses: docker/login-action@v2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # tag=v4.6.0 with: registry: registry.cn-hangzhou.aliyuncs.com username: ${{ secrets.ACR_USERNAME }} password: ${{ secrets.ACR_TOKEN }} - name: Go Build Cache for Docker - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # tag=v4.3.0 with: path: go-build-cache key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: inject go-build-cache into docker - uses: reproducible-containers/buildkit-cache-dance@v3.1.0 + uses: reproducible-containers/buildkit-cache-dance@5b81f4d29dc8397a7d341dba3aeecc7ec54d6361 # tag=v3.3.0 with: cache-source: go-build-cache - name: Build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # tag=v7.3.0 env: # Alicloud ACR rejects the provenance attestation manifest that buildx # attaches by default since BuildKit v0.11 ("unknown manifest class for diff --git a/.github/workflows/test_workflows.yml b/.github/workflows/test_workflows.yml index d6244cbe..57101c1c 100644 --- a/.github/workflows/test_workflows.yml +++ b/.github/workflows/test_workflows.yml @@ -6,7 +6,7 @@ on: - main concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event-name }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: @@ -14,7 +14,7 @@ jobs: name: checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - uses: ./.github/actions/checks with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -23,7 +23,7 @@ jobs: name: e2e runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: run uses: ./.github/actions/e2e with: diff --git a/.gitignore b/.gitignore index 50e2ac5c..521c3670 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,10 @@ charts/matrixone-operator/charts/ *.tgz e2e*.xml e2e.test -docs/troubleshooting/ -docs/draft \ No newline at end of file +# Operational troubleshooting documents are versioned as reviewed, top-level +# Markdown files. Ignore evidence, logs, tools, and other generated subtrees by +# default so environment data cannot be committed accidentally. +docs/troubleshooting/* +!docs/troubleshooting/*.md +docs/troubleshooting/*.draft.md +docs/draft/ diff --git a/.golangci.yml b/.golangci.yml index 93bcf414..772c08ac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,42 +1,47 @@ +version: "2" run: - timeout: 30m - go: '1.21' - skip-files: - - "^zz_generated.*" - -output: - sort-results: true - + go: "1.23" linters: - disable-all: true + default: none enable: - revive - -issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck - -linters-settings: - staticcheck: - go: "1.19" - checks: [ - "all", - "-S1*", - "-ST1*", - "-SA5011", - "-SA1019", - "-SA2002" - ] - - revive: + settings: + revive: + rules: + - name: unused-parameter + arguments: + - allowRegex: ^_ + severity: warning + disabled: false + staticcheck: + checks: + - all + - -S1* + - -SA1019 + - -SA2002 + - -SA5011 + - -ST1* + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling rules: - - name: unused-parameter - severity: warning - disabled: false - arguments: - - allowRegex: "^_" - - unused: - go: "1.19" + - linters: + - errcheck + path: _test\.go + paths: + - ^zz_generated.* + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - ^zz_generated.* + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index 90f5c1c5..282447f4 100644 --- a/Makefile +++ b/Makefile @@ -55,9 +55,12 @@ generate-mockgen: mockgen ## General gomock(https://github.com/golang/mock) file $(MOCKGEN) -source=./runtime/pkg/reconciler/event.go -package fake > ./runtime/pkg/fake/event.go # helm package -helm-pkg: manifests generate helm-lint - helm dependency build charts/matrixone-operator - helm package -u charts/matrixone-operator -d charts/ +helm-pkg: manifests generate verify-chart + ./hack/package-chart.sh charts/ + +.PHONY: verify-chart +verify-chart: + ./hack/verify-chart.sh # Generated artifacts that must be committed whenever their sources or generators change. @@ -81,14 +84,23 @@ verify-generated: exit 1; \ fi -# Make sure the generated files are up to date before open PR -reviewable: ci-reviewable go-lint check-license test +# Make sure the generated files are up to date before open PR. Keep the steps in +# the recipe so `make -j reviewable` cannot run generators and their checks at +# the same time. +reviewable: ci-reviewable + $(MAKE) verify-generated + $(MAKE) verify-chart + $(MAKE) go-lint + $(MAKE) check-license ci-reviewable: generate manifests docs test go mod tidy -# Check whether the pull request is reviewable in CI, go-lint is delibrately excluded since we already have golangci-lint action +# Check whether the pull request is reviewable in CI. go-lint is deliberately +# excluded since the workflow runs golangci-lint as a separate action. verify: ci-reviewable + $(MAKE) verify-generated + $(MAKE) verify-chart echo "checking that branch is clean" test -z "$$(git status --porcelain)" || (echo "unclean working tree, did you forget to run make reviewable?" && exit 1) echo "branch is clean" @@ -118,18 +130,24 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) ENVTEST ?= $(LOCALBIN)/setup-envtest +SETUP_ENVTEST_MODULE = sigs.k8s.io/controller-runtime/tools/setup-envtest +SETUP_ENVTEST_VERSION = v0.0.0-20250517180713-32e5e9e948a5 .PHONY: envtest -envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. -$(ENVTEST): $(LOCALBIN) - test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest +envtest: $(LOCALBIN) ## Install the pinned setup-envtest version if necessary. + @actual_version="$$(go version -m "$(ENVTEST)" 2>/dev/null | awk -v module="$(SETUP_ENVTEST_MODULE)" '$$1 == "mod" && $$2 == module { print $$3 }')"; \ + if [ "$$actual_version" != "$(SETUP_ENVTEST_VERSION)" ]; then \ + echo "Installing $(SETUP_ENVTEST_MODULE)@$(SETUP_ENVTEST_VERSION) (found: $${actual_version:-none})"; \ + GOBIN=$(LOCALBIN) go install $(SETUP_ENVTEST_MODULE)@$(SETUP_ENVTEST_VERSION); \ + fi # TODO: include E2E test: api-test unit # Run unit tests unit: generate fmt vet manifests envtest - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" CGO_ENABLED=0 go test ./pkg/... -coverprofile cover.out + @assets="$$( $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" && \ + KUBEBUILDER_ASSETS="$$assets" CGO_ENABLED=0 go test ./pkg/... -coverprofile cover.out api-test: cd api && make test @@ -177,8 +195,14 @@ run-pgd: build-pgd docker run --privileged --name playground -p 6001:6001 --rm -it matrixorigin/operator-playground:latest GINKGO = $(shell pwd)/bin/ginkgo -ginkgo: - $(call go-get-tool,$(GINKGO),github.com/onsi/ginkgo/v2/ginkgo@v2.9.2) +GINKGO_MODULE = github.com/onsi/ginkgo/v2 +GINKGO_VERSION = v2.9.5 +ginkgo: $(LOCALBIN) + @actual_version="$$(go version -m "$(GINKGO)" 2>/dev/null | awk -v module="$(GINKGO_MODULE)" '$$1 == "mod" && $$2 == module { print $$3 }')"; \ + if [ "$$actual_version" != "$(GINKGO_VERSION)" ]; then \ + echo "Installing $(GINKGO_MODULE)/ginkgo@$(GINKGO_VERSION) (found: $${actual_version:-none})"; \ + GOBIN=$(PROJECT_DIR)/bin go install $(GINKGO_MODULE)/ginkgo@$(GINKGO_VERSION); \ + fi MOCKGEN = $(shell pwd)/bin/mockgen mockgen: ## Download mockgen locally if necessary @@ -189,8 +213,14 @@ license-eye: ## Download license-eye locally if necessary $(call go-get-tool,$(LICENSE_EYE),github.com/apache/skywalking-eyes/cmd/license-eye@v0.4.0) GOLANGCI_LINT = $(shell pwd)/bin/golangci-lint -golangci-lint: - $(call go-get-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2) +GOLANGCI_LINT_MODULE = github.com/golangci/golangci-lint/v2 +GOLANGCI_LINT_VERSION = v2.1.6 +golangci-lint: $(LOCALBIN) + @actual_version="$$(go version -m "$(GOLANGCI_LINT)" 2>/dev/null | awk -v module="$(GOLANGCI_LINT_MODULE)" '$$1 == "mod" && $$2 == module { print $$3 }')"; \ + if [ "$$actual_version" != "$(GOLANGCI_LINT_VERSION)" ]; then \ + echo "Installing $(GOLANGCI_LINT_MODULE)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) (found: $${actual_version:-none})"; \ + GOBIN=$(PROJECT_DIR)/bin go install $(GOLANGCI_LINT_MODULE)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION); \ + fi # go-get-tool will 'go get' any package $2 and install it to $1. PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) diff --git a/api/Makefile b/api/Makefile index dcbd18d2..3834fd6b 100644 --- a/api/Makefile +++ b/api/Makefile @@ -15,7 +15,8 @@ generate: controller-gen .PHONY: test test: manifests generate envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out + @assets="$$( $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" && \ + KUBEBUILDER_ASSETS="$$assets" go test ./... -coverprofile cover.out .PHONY: docs docs: crd-ref-docs @@ -28,11 +29,16 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) ENVTEST ?= $(LOCALBIN)/setup-envtest +SETUP_ENVTEST_MODULE = sigs.k8s.io/controller-runtime/tools/setup-envtest +SETUP_ENVTEST_VERSION = v0.0.0-20250517180713-32e5e9e948a5 .PHONY: envtest -envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. -$(ENVTEST): $(LOCALBIN) - test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest +envtest: $(LOCALBIN) ## Install the pinned setup-envtest version if necessary. + @actual_version="$$(go version -m "$(ENVTEST)" 2>/dev/null | awk -v module="$(SETUP_ENVTEST_MODULE)" '$$1 == "mod" && $$2 == module { print $$3 }')"; \ + if [ "$$actual_version" != "$(SETUP_ENVTEST_VERSION)" ]; then \ + echo "Installing $(SETUP_ENVTEST_MODULE)@$(SETUP_ENVTEST_VERSION) (found: $${actual_version:-none})"; \ + GOBIN=$(LOCALBIN) go install $(SETUP_ENVTEST_MODULE)@$(SETUP_ENVTEST_VERSION); \ + fi CONTROLLER_GEN = $(LOCALBIN)/controller-gen CONTROLLER_GEN_MODULE = sigs.k8s.io/controller-tools diff --git a/docs/dev/dev_guide.md b/docs/dev/dev_guide.md index a88063dc..72d87b19 100644 --- a/docs/dev/dev_guide.md +++ b/docs/dev/dev_guide.md @@ -216,3 +216,17 @@ import ( ## Package management You can use [go workspace](https://golang.google.cn/doc/tutorial/workspaces) for using new dependency. + +## Verify a change + +Run the same generated-artifact, unit, Chart, and lint checks used by CI before +opening a pull request: + +```shell +make reviewable +``` + +CI runs `make verify` from a clean checkout and rejects any generated files or +module metadata that the pinned tools would change. Use `make verify-chart` for +the faster, Chart-only policy and deterministic-packaging checks. The Kind E2E +workflow additionally covers Kruise webhook outage, Helm upgrade, and recovery. diff --git a/docs/troubleshooting/kruise-webhook-availability.md b/docs/troubleshooting/kruise-webhook-availability.md new file mode 100644 index 00000000..74f356a2 --- /dev/null +++ b/docs/troubleshooting/kruise-webhook-availability.md @@ -0,0 +1,40 @@ +# Kruise webhook availability policy + +The bundled Kruise chart deliberately uses different failure policies based on +the scope of the admitted resource. + +## Pod admission + +The `mpod.kb.io`, `vpod.kb.io`, and `vpodeviction.kb.io` webhooks use +`failurePolicy: Fail`. A Kruise webhook outage therefore blocks Pod creation, +update, deletion, and eviction requests covered by those rules. + +This preserves the contract of the enabled `PodUnavailableBudgetDeleteGate` and +`PodUnavailableBudgetUpdateGate` features. It also prevents Pods from being +created without SidecarSet, WorkloadSpread, PersistentPodState, or other Kruise +mutations that cannot be applied retroactively after the webhook recovers. + +Other bundled webhooks for built-in Deployments, ReplicaSets, StatefulSets, +Namespaces, Services, Ingresses, and CustomResourceDefinitions use +`failurePolicy: Ignore`. API resources not selected by any Kruise webhook remain +available during the outage. + +## Kruise custom resources + +Webhooks whose rules only target `apps.kruise.io` or `policy.kruise.io` resources +use `failurePolicy: Fail`. Their defaulting and validation are part of the +Kruise resource contract, and an outage therefore blocks changes only to the +affected Kruise APIs instead of blocking general Kubernetes workloads. + +The chart regression checks can be run with: + +```sh +make verify-chart +``` + +The Kind E2E workflow also disconnects the Kruise webhook Service temporarily. +It verifies that unrelated core API operations remain available, Pod and Kruise +custom-resource admission remain fail-closed, and a Helm upgrade restores the +Service and Pod admission path. The outage scenario runs after the established +operator E2E suite so it cannot alter that suite's initial state. Run the +integration coverage with `make e2e-kind`. diff --git a/hack/lib.sh b/hack/lib.sh index ce51c8f2..4f613b64 100644 --- a/hack/lib.sh +++ b/hack/lib.sh @@ -129,12 +129,10 @@ function e2e::run() { function e2e::install() { local chart_root + local operator_chart chart_root=$(mktemp -d) - mkdir -p "${chart_root}/matrixone-operator/charts" - cp charts/matrixone-operator/Chart.yaml charts/matrixone-operator/values.yaml "${chart_root}/matrixone-operator/" - cp -R charts/matrixone-operator/templates "${chart_root}/matrixone-operator/" - if ! helm package charts/kruise --destination "${chart_root}/matrixone-operator/charts"; then + if ! operator_chart=$(./hack/package-chart.sh "${chart_root}"); then rm -rf -- "${chart_root}" return 1 fi @@ -142,13 +140,30 @@ function e2e::install() { echo "> Create operator namespace" kubectl create ns "${OPNAMESPACE}" echo "> Install mo operator" - if ! helm install mo "${chart_root}/matrixone-operator" --set image.repository="${REPO}" --set image.tag="${TAG}" -n "${OPNAMESPACE}"; then + if ! helm install mo "${operator_chart}" --set image.repository="${REPO}" --set image.tag="${TAG}" -n "${OPNAMESPACE}"; then + rm -rf -- "${chart_root}" + return 1 + fi + if ! e2e::wait-webhook-ready; then rm -rf -- "${chart_root}" return 1 fi rm -rf -- "${chart_root}" +} + +function e2e::test-kruise-webhook-outage() { + local chart_root + local operator_chart + local status=0 + chart_root=$(mktemp -d) - e2e::wait-webhook-ready + if ! operator_chart=$(./hack/package-chart.sh "${chart_root}"); then + rm -rf -- "${chart_root}" + return 1 + fi + ./hack/test-kruise-webhook-outage.sh "${operator_chart}" mo "${OPNAMESPACE}" || status=$? + rm -rf -- "${chart_root}" + return "${status}" } function e2e::wait-webhook-ready() { @@ -215,7 +230,15 @@ function e2e::workflow() { trap "e2e::cleanup" EXIT e2e::install || return 1 local run_status=0 + local outage_status=0 e2e::run || run_status=$? + # Run the disruptive outage/upgrade scenario after the established E2E suite + # so it cannot change the suite's initial cluster state. Preserve an earlier + # suite failure while still collecting the outage-test result. + e2e::test-kruise-webhook-outage || outage_status=$? + if [[ "${run_status}" -eq 0 && "${outage_status}" -ne 0 ]]; then + run_status=${outage_status} + fi trap - EXIT e2e::cleanup || return 1 return "${run_status}" diff --git a/hack/package-chart.sh b/hack/package-chart.sh new file mode 100755 index 00000000..c237fe14 --- /dev/null +++ b/hack/package-chart.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Copyright 2026 Matrix Origin +# +# 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. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd) +SOURCE_ROOT=${SOURCE_ROOT:-${REPO_ROOT}} +OUTPUT_DIR=${1:-"${REPO_ROOT}/charts"} +STAGING_ROOT=$(mktemp -d) +trap 'rm -rf -- "${STAGING_ROOT}"' EXIT + +OPERATOR_SOURCE="${SOURCE_ROOT}/charts/matrixone-operator" +KRUISE_SOURCE="${SOURCE_ROOT}/charts/kruise" +OPERATOR_STAGING="${STAGING_ROOT}/matrixone-operator" + +mkdir -p "${OPERATOR_STAGING}/charts" "${OUTPUT_DIR}" +cp "${OPERATOR_SOURCE}/Chart.yaml" "${OPERATOR_SOURCE}/values.yaml" "${OPERATOR_STAGING}/" +cp -R "${OPERATOR_SOURCE}/templates" "${OPERATOR_STAGING}/" +if [[ -f "${OPERATOR_SOURCE}/.helmignore" ]]; then + cp "${OPERATOR_SOURCE}/.helmignore" "${OPERATOR_STAGING}/" +fi + +# Package only the dependency declared by this repository. In particular, do +# not copy or inspect OPERATOR_SOURCE/charts, which may contain ignored archives +# left by an earlier local build. +helm package "${KRUISE_SOURCE}" --destination "${OPERATOR_STAGING}/charts" >/dev/null +helm package "${OPERATOR_STAGING}" --destination "${OUTPUT_DIR}" | awk '{print $NF}' diff --git a/hack/test-kruise-webhook-outage.sh b/hack/test-kruise-webhook-outage.sh new file mode 100755 index 00000000..1acb998b --- /dev/null +++ b/hack/test-kruise-webhook-outage.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash + +# Copyright 2026 Matrix Origin +# +# 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. + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 OPERATOR_CHART RELEASE RELEASE_NAMESPACE" >&2 + exit 2 +fi + +operator_chart=$1 +release=$2 +release_namespace=$3 +kruise_namespace=kruise-system +test_namespace=kruise-webhook-outage-test +webhook_service=kruise-webhook-service +manager_deployment=kruise-controller-manager +needs_recovery=true + +recover() { + local status=$? + kubectl delete namespace "${test_namespace}" --ignore-not-found --wait=false >/dev/null 2>&1 || true + if [[ "${needs_recovery}" == true ]]; then + echo "> Restore Kruise webhook after outage test" + if ! helm upgrade "${release}" "${operator_chart}" -n "${release_namespace}" --reuse-values \ + --wait --timeout=5m >/dev/null; then + echo "error: failed to restore Kruise webhook after outage test" >&2 + fi + fi + exit "${status}" +} +trap recover EXIT + +echo "> Simulate Kruise webhook service outage" +# Change a chart-managed selector to remove all endpoints. Recreate the manager +# Pods as well so the API server cannot reuse an admission connection that was +# established before the Service was isolated. Helm upgrade must restore the +# declared selector value. +kubectl -n "${kruise_namespace}" patch service "${webhook_service}" --type=merge \ + -p '{"spec":{"selector":{"control-plane":"reliability-outage"}}}' >/dev/null +kubectl -n "${kruise_namespace}" delete pod \ + -l control-plane=controller-manager --wait=true --timeout=2m >/dev/null + +for _ in $(seq 1 30); do + endpoints=$(kubectl -n "${kruise_namespace}" get endpoints "${webhook_service}" \ + -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null || true) + [[ -z "${endpoints}" ]] && break + sleep 1 +done +if [[ -n "${endpoints:-}" ]]; then + echo "Kruise webhook endpoints did not become empty" >&2 + exit 1 +fi + +kubectl create namespace "${test_namespace}" >/dev/null + +# Resources outside the Kruise webhook rules remain available. +kubectl -n "${test_namespace}" create configmap admitted-during-outage \ + --from-literal=status=ok >/dev/null + +# Pod admission remains fail-closed because Kruise mutation and PUB validation +# cannot be repaired retroactively after an outage. +set +e +pod_failure_output=$(kubectl -n "${test_namespace}" apply -f - 2>&1 <<'EOF' +apiVersion: v1 +kind: Pod +metadata: + name: rejected-during-outage +spec: + restartPolicy: Never + containers: + - name: main + image: busybox:1.36 + command: ["sh", "-c", "exit 0"] +EOF +) +pod_failure_status=$? +set -e +if [[ ${pod_failure_status} -eq 0 ]]; then + echo "Pod unexpectedly passed fail-closed Kruise admission during webhook outage" >&2 + exit 1 +fi +if ! grep -Eq 'failed calling webhook|no endpoints available|context deadline exceeded' <<<"${pod_failure_output}"; then + echo "Pod failed for an unexpected reason: ${pod_failure_output}" >&2 + exit 1 +fi + +set +e +failure_output=$(kubectl -n "${test_namespace}" apply -f - 2>&1 <<'EOF' +apiVersion: apps.kruise.io/v1alpha1 +kind: CloneSet +metadata: + name: rejected-during-outage +spec: + replicas: 0 + selector: + matchLabels: + app: rejected-during-outage + template: + metadata: + labels: + app: rejected-during-outage + spec: + containers: + - name: main + image: busybox:1.36 +EOF +) +failure_status=$? +set -e +if [[ ${failure_status} -eq 0 ]]; then + echo "Kruise custom resource unexpectedly passed admission during webhook outage" >&2 + exit 1 +fi +if ! grep -Eq 'failed calling webhook|no endpoints available|context deadline exceeded' <<<"${failure_output}"; then + echo "Kruise custom resource failed for an unexpected reason: ${failure_output}" >&2 + exit 1 +fi + +echo "> Upgrade release and recover Kruise webhook service" +helm upgrade "${release}" "${operator_chart}" -n "${release_namespace}" --reuse-values \ + --wait --timeout=5m >/dev/null +kubectl -n "${kruise_namespace}" rollout status deployment "${manager_deployment}" --timeout=5m >/dev/null + +for _ in $(seq 1 60); do + endpoints=$(kubectl -n "${kruise_namespace}" get endpoints "${webhook_service}" \ + -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null || true) + [[ -n "${endpoints}" ]] && break + sleep 1 +done +if [[ -z "${endpoints:-}" ]]; then + echo "Kruise webhook endpoints did not recover after upgrade" >&2 + exit 1 +fi +needs_recovery=false + +kubectl -n "${test_namespace}" apply -f - >/dev/null <<'EOF' +apiVersion: v1 +kind: Pod +metadata: + name: admitted-after-recovery +spec: + restartPolicy: Never + containers: + - name: main + image: busybox:1.36 + command: ["sh", "-c", "exit 0"] +EOF + +echo "> Kruise webhook outage and recovery behavior verified" diff --git a/hack/verify-chart.sh b/hack/verify-chart.sh new file mode 100755 index 00000000..18bdf273 --- /dev/null +++ b/hack/verify-chart.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# Copyright 2026 Matrix Origin +# +# 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. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd) +TEST_ROOT=$(mktemp -d) +trap 'rm -rf -- "${TEST_ROOT}"' EXIT + +bash -n "${REPO_ROOT}/hack/package-chart.sh" \ + "${REPO_ROOT}/hack/test-kruise-webhook-outage.sh" \ + "${REPO_ROOT}/hack/lib.sh" + +helm template test "${REPO_ROOT}/charts/kruise" >"${TEST_ROOT}/kruise.yaml" + +# Pod admission and Kruise custom resources remain fail-closed because their +# mutation/validation contracts cannot be repaired retroactively. Other built-in +# validators remain fail-open. The rendered chart currently emits failurePolicy +# before name inside each webhook block, which this check intentionally parses. +awk ' + /^[[:space:]]+failurePolicy:/ { policy = $2 } + /^[[:space:]]+name: [a-z].*\.kb\.io$/ { + name = $2 + expected = "Fail" + if (name ~ /^vbuiltin/ || name == "vcustomresourcedefinition.kb.io" || + name == "vnamespace.kb.io" || name == "vingress.kb.io" || name == "vservice.kb.io") { + expected = "Ignore" + } + if (policy != expected) { + printf "webhook %s has failurePolicy %s, want %s\n", name, policy, expected > "/dev/stderr" + failed = 1 + } + } + END { exit failed } +' "${TEST_ROOT}/kruise.yaml" + +# Reproduce a dirty developer workspace in a temporary source tree. The stale +# archive must not be copied into the operator package. +mkdir -p "${TEST_ROOT}/source/charts" "${TEST_ROOT}/packages" +cp -R "${REPO_ROOT}/charts/matrixone-operator" "${TEST_ROOT}/source/charts/" +cp -R "${REPO_ROOT}/charts/kruise" "${TEST_ROOT}/source/charts/" +mkdir -p "${TEST_ROOT}/source/charts/matrixone-operator/charts" +printf 'stale archive\n' >"${TEST_ROOT}/source/charts/matrixone-operator/charts/stale-9.9.9.tgz" + +SOURCE_ROOT="${TEST_ROOT}/source" "${REPO_ROOT}/hack/package-chart.sh" "${TEST_ROOT}/packages" >/dev/null +operator_package=$(find "${TEST_ROOT}/packages" -maxdepth 1 -name 'matrixone-operator-*.tgz' -print -quit) +if [[ -z "${operator_package}" ]]; then + echo "operator package was not created" >&2 + exit 1 +fi + +dependencies=$(tar -tzf "${operator_package}" | awk -F/ '$1 == "matrixone-operator" && $2 == "charts" && $3 != "" { print $3 }' | sort -u) +if [[ "${dependencies}" != "kruise" ]]; then + echo "unexpected packaged dependencies:" >&2 + printf '%s\n' "${dependencies}" >&2 + exit 1 +fi + +# Chart.lock is intentionally ignored by this repository. A developer-local +# lock file must not leak into the deterministic package. +if tar -tzf "${operator_package}" | awk '/\/Chart.lock$/ { found = 1 } END { exit !found }'; then + echo "ignored Chart.lock leaked into the operator package" >&2 + exit 1 +fi diff --git a/pkg/controllers/cnpool/controller.go b/pkg/controllers/cnpool/controller.go index 62830dfb..49cc6bdb 100644 --- a/pkg/controllers/cnpool/controller.go +++ b/pkg/controllers/cnpool/controller.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -460,7 +460,7 @@ func deletionCost(pod *corev1.Pod) int { func (r *Actor) Start(mgr manager.Manager) error { return recon.Setup[*v1alpha1.CNPool](&v1alpha1.CNPool{}, "cn-pool-manager", mgr, r, recon.WithBuildFn(func(b *builder.Builder) { - b.Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, object client.Object) []reconcile.Request { + b.Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, object client.Object) []reconcile.Request { pod, ok := object.(*corev1.Pod) if !ok { return nil @@ -476,7 +476,7 @@ func (r *Actor) Start(mgr manager.Manager) error { }, }} })) - b.Watches(&v1alpha1.CNClaim{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, object client.Object) []reconcile.Request { + b.Watches(&v1alpha1.CNClaim{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, object client.Object) []reconcile.Request { claim, ok := object.(*v1alpha1.CNClaim) if !ok { return nil diff --git a/pkg/controllers/cnset/controller.go b/pkg/controllers/cnset/controller.go index b36965b8..07e1e924 100644 --- a/pkg/controllers/cnset/controller.go +++ b/pkg/controllers/cnset/controller.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ package cnset import ( + "context" "fmt" "strconv" "time" @@ -40,7 +41,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) // reconcile configuration @@ -94,7 +99,7 @@ func (c *Actor) Observe(ctx *recon.Context[*v1alpha1.CNSet]) (recon.Action[*v1al return nil, errors.WrapPrefix(err, "sync service", 0) } - if err := c.syncMetricService(ctx); err != nil { + if err := c.syncMetricService(ctx, cs.Spec.Template.Labels); err != nil { return nil, errors.WrapPrefix(err, "sync metric service", 0) } @@ -192,24 +197,49 @@ func (c *Actor) Observe(ctx *recon.Context[*v1alpha1.CNSet]) (recon.Action[*v1al // syncMetricService reconciles a dedicated ClusterIP Service exposing the CN metrics port, // so that Service-based Prometheus discovery (e.g. ServiceMonitor matching on port name // "metric") can find CN targets the same way it already does for DN/Log (issue #600). -func (c *Actor) syncMetricService(ctx *recon.Context[*v1alpha1.CNSet]) error { +func (c *Actor) syncMetricService(ctx *recon.Context[*v1alpha1.CNSet], podSelector map[string]string) error { cn := ctx.Obj + labels := common.SubResourceLabels(cn) + // ServiceMonitor selects the Service by component, while the Service itself + // must select the labels of the existing CloneSet Pods. Keep these concerns + // separate so an incomplete TypeMeta cannot disconnect metrics endpoints. + labels[common.ComponentLabelKey] = "CNSet" svc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: cn.Namespace, Name: metricSvcName(cn), - Labels: common.SubResourceLabels(cn), + Labels: labels, }, Spec: corev1.ServiceSpec{ - Selector: common.SubResourceLabels(cn), + Selector: podSelector, }, } return recon.CreateOwnedOrUpdate(ctx, svc, func() error { + if owner := metav1.GetControllerOf(svc); owner != nil && !metav1.IsControlledBy(svc, cn) { + // A metrics-only name collision must not block CN scale, rollout, or + // configuration reconciliation. Do not mutate or steal a Service that + // is controlled by another object. + ctx.Log.Info("skip CN metrics Service owned by another controller", + "service", client.ObjectKeyFromObject(svc), "owner", owner) + return nil + } + // The Service spec is controller-managed; unrelated labels and + // annotations remain user-managed and are preserved. + if svc.Labels == nil { + svc.Labels = map[string]string{} + } + for key, value := range labels { + svc.Labels[key] = value + } + svc.Spec.Selector = podSelector svc.Spec.Type = corev1.ServiceTypeClusterIP svc.Spec.Ports = []corev1.ServicePort{{ Name: "metric", Port: int32(common.MetricsPort), }} + if err := controllerutil.SetControllerReference(cn, svc, ctx.Client.Scheme()); err != nil { + return err + } if cn.Spec.PromDiscoveredByService() { if svc.Annotations == nil { svc.Annotations = map[string]string{} @@ -335,7 +365,9 @@ func (c *Actor) Reconcile(mgr manager.Manager) error { err := recon.Setup[*v1alpha1.CNSet](&v1alpha1.CNSet{}, "cnset", mgr, c, recon.WithBuildFn(func(b *builder.Builder) { b.Owns(&kruisev1alpha1.CloneSet{}). - Owns(&corev1.Service{}) + Owns(&corev1.Service{}). + Watches(&kruise.StatefulSet{}, handler.EnqueueRequestsFromMapFunc(requestsForLogSetStatefulSet(mgr.GetClient())), + builder.WithPredicates(common.LogSetReserveOrdinalsChangedPredicate())) })) if err != nil { return err @@ -343,6 +375,30 @@ func (c *Actor) Reconcile(mgr manager.Manager) error { return nil } + +func requestsForLogSetStatefulSet(reader client.Reader) handler.MapFunc { + return func(ctx context.Context, object client.Object) []reconcile.Request { + owner, ok := common.LogSetStatefulSetOwner(object) + if !ok { + return nil + } + + sets := &v1alpha1.CNSetList{} + if err := reader.List(ctx, sets); err != nil { + log.FromContext(ctx).Error(err, "list CNSets for LogSet StatefulSet", "statefulset", client.ObjectKeyFromObject(object)) + return nil + } + + requests := make([]reconcile.Request, 0, len(sets.Items)) + for i := range sets.Items { + set := &sets.Items[i] + if common.ReferencesLogSet(set.Deps.LogSetRef, set.Namespace, owner) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(set)}) + } + } + return requests + } +} func syncCloneSet(ctx *recon.Context[*v1alpha1.CNSet], cs *kruisev1alpha1.CloneSet) error { cn := ctx.Obj pooling := cn.Spec.PodManagementPolicy != nil && *cn.Spec.PodManagementPolicy == v1alpha1.PodManagementPolicyPooling diff --git a/pkg/controllers/cnset/controller_test.go b/pkg/controllers/cnset/controller_test.go index 3ddf3fdf..23303eac 100644 --- a/pkg/controllers/cnset/controller_test.go +++ b/pkg/controllers/cnset/controller_test.go @@ -37,10 +37,15 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/pointer" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) func baseCNSetForMetricSvcTest() *v1alpha1.CNSet { return &v1alpha1.CNSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "CNSet", + }, ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "test", @@ -68,6 +73,7 @@ func enableCNPromServiceDiscovery(cn *v1alpha1.CNSet) { func Test_syncMetricService(t *testing.T) { s := newScheme() labels := common.SubResourceLabels(baseCNSetForMetricSvcTest()) + labels[common.ComponentLabelKey] = "CNSet" tests := []struct { name string @@ -123,6 +129,49 @@ func Test_syncMetricService(t *testing.T) { g.Expect(svc.Annotations).NotTo(HaveKey(common.PrometheusPortAnno)) }, }, + { + name: "repairs drift while preserving user metadata", + cnset: baseCNSetForMetricSvcTest(), + client: &fake.Client{ + Client: fake.KubeClientBuilder().WithScheme(s).WithObjects( + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: metricSvcName(baseCNSetForMetricSvcTest()), + Labels: map[string]string{"drifted": "true"}, + Annotations: map[string]string{"user.example.com/keep": "yes"}, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"wrong": "selector"}, + Ports: []corev1.ServicePort{{ + Name: "wrong", + Port: 1234, + }}, + }, + }, + ).Build(), + }, + setup: enableCNPromServiceDiscovery, + expect: func(g *WithT, cn *v1alpha1.CNSet, cli client.Client, err error) { + g.Expect(err).To(BeNil()) + svc := &corev1.Service{} + g.Expect(cli.Get(context.Background(), client.ObjectKeyFromObject(&corev1.Service{ObjectMeta: metav1.ObjectMeta{ + Namespace: cn.Namespace, + Name: metricSvcName(cn), + }}), svc)).To(Succeed()) + g.Expect(svc.Labels).To(HaveKeyWithValue("drifted", "true")) + for key, value := range labels { + g.Expect(svc.Labels).To(HaveKeyWithValue(key, value)) + } + g.Expect(svc.Spec.Selector).To(Equal(labels)) + g.Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeClusterIP)) + g.Expect(svc.Spec.Ports).To(Equal([]corev1.ServicePort{{Name: "metric", Port: int32(common.MetricsPort)}})) + g.Expect(svc.Annotations).To(HaveKeyWithValue("user.example.com/keep", "yes")) + g.Expect(svc.Annotations).To(HaveKeyWithValue(common.PrometheusScrapeAnno, "true")) + g.Expect(metav1.IsControlledBy(svc, cn)).To(BeTrue()) + }, + }, { name: "removes stale prom annotations when export disabled", cnset: baseCNSetForMetricSvcTest(), @@ -161,6 +210,41 @@ func Test_syncMetricService(t *testing.T) { g.Expect(svc.Annotations).NotTo(HaveKey(common.PrometheusPortAnno)) }, }, + { + name: "does not mutate service controlled by another owner", + cnset: baseCNSetForMetricSvcTest(), + client: &fake.Client{ + Client: fake.KubeClientBuilder().WithScheme(s).WithObjects( + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: metricSvcName(baseCNSetForMetricSvcTest()), + Labels: map[string]string{"foreign": "owner"}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "example.com/v1", + Kind: "Example", + Name: "foreign", + UID: "foreign-uid", + Controller: pointer.Bool(true), + }}, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"foreign": "selector"}, + Ports: []corev1.ServicePort{{Name: "foreign", Port: 1234}}, + }, + }, + ).Build(), + }, + expect: func(g *WithT, cn *v1alpha1.CNSet, cli client.Client, err error) { + g.Expect(err).To(BeNil()) + svc := &corev1.Service{} + g.Expect(cli.Get(context.Background(), client.ObjectKey{Namespace: cn.Namespace, Name: metricSvcName(cn)}, svc)).To(Succeed()) + g.Expect(svc.Labels).To(Equal(map[string]string{"foreign": "owner"})) + g.Expect(svc.Spec.Selector).To(Equal(map[string]string{"foreign": "selector"})) + g.Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeNodePort)) + }, + }, } for _, tt := range tests { @@ -175,12 +259,69 @@ func Test_syncMetricService(t *testing.T) { eventEmitter := fake.NewMockEventEmitter(mockCtrl) ctx := fake.NewContext(cn, tt.client, eventEmitter) - err := (&Actor{}).syncMetricService(ctx) + err := (&Actor{}).syncMetricService(ctx, common.SubResourceLabels(cn)) tt.expect(g, cn, tt.client, err) }) } } +func TestMetricServiceSelectorMatchesCNPodLabels(t *testing.T) { + for _, tc := range []struct { + name string + withTypeMeta bool + }{ + {name: "without TypeMeta", withTypeMeta: false}, + {name: "with TypeMeta", withTypeMeta: true}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + cn := baseCNSetForMetricSvcTest() + if !tc.withTypeMeta { + cn.TypeMeta = metav1.TypeMeta{} + } + cli := &fake.Client{Client: fake.KubeClientBuilder().WithScheme(newScheme()).Build()} + ctx := fake.NewContext(cn, cli, fake.NewMockEventEmitter(gomock.NewController(t))) + + cnPods := buildCNSet(cn, &corev1.Service{}).Spec.Template.Labels + g.Expect((&Actor{}).syncMetricService(ctx, cnPods)).To(Succeed()) + svc := &corev1.Service{} + g.Expect(cli.Get(context.Background(), client.ObjectKey{ + Namespace: cn.Namespace, + Name: metricSvcName(cn), + }, svc)).To(Succeed()) + + g.Expect(svc.Spec.Selector).To(Equal(cnPods)) + g.Expect(svc.Labels).To(HaveKeyWithValue(common.ComponentLabelKey, "CNSet")) + }) + } +} + +func TestRequestsForLogSetStatefulSet(t *testing.T) { + s := newScheme() + logSet := &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{Namespace: "provider", Name: "log", UID: "log-uid"}} + matching := &v1alpha1.CNSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "consumer", Name: "matching"}, + Deps: v1alpha1.CNSetDeps{LogSetRef: logSet.AsDependency()}, + } + unrelated := &v1alpha1.CNSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "consumer", Name: "unrelated"}, + Deps: v1alpha1.CNSetDeps{LogSetRef: v1alpha1.LogSetRef{LogSet: &v1alpha1.LogSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "other", Name: "log"}, + }}}, + } + cli := fake.KubeClientBuilder().WithScheme(s).WithObjects(matching, unrelated).Build() + sts := &kruisev1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Namespace: "provider", + Name: "log-log", + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(logSet, + v1alpha1.GroupVersion.WithKind("LogSet"))}, + }} + + requests := requestsForLogSetStatefulSet(cli)(context.Background(), sts) + g := NewGomegaWithT(t) + g.Expect(requests).To(Equal([]reconcile.Request{{NamespacedName: client.ObjectKeyFromObject(matching)}})) +} + func TestCNSetActor_Observe(t *testing.T) { s := newScheme() tpl := &v1alpha1.CNSet{ @@ -231,7 +372,7 @@ func TestCNSetActor_Observe(t *testing.T) { client: &fake.Client{ Client: fake.KubeClientBuilder().WithScheme(s).Build(), }, - expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], cli client.Client, err error) { + expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], _ client.Client, err error) { g.Expect(err).To(BeNil()) g.Expect(action.String()).To(ContainSubstring("Create")) }, @@ -242,7 +383,7 @@ func TestCNSetActor_Observe(t *testing.T) { client: &fake.Client{ Client: fake.KubeClientBuilder().WithScheme(s).Build(), }, - expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], cli client.Client, err error) { + expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], _ client.Client, err error) { g.Expect(err).To(BeNil()) g.Expect(action.String()).To(ContainSubstring("Create")) }, @@ -309,7 +450,7 @@ func TestCNSetActor_Observe(t *testing.T) { }, ).Build(), }, - expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], cli client.Client, err error) { + expect: func(g *WithT, action recon.Action[*v1alpha1.CNSet], _ client.Client, err error) { g.Expect(err).To(BeNil()) g.Expect(action.String()).To(ContainSubstring("Update")) }, diff --git a/pkg/controllers/cnstore/controller.go b/pkg/controllers/cnstore/controller.go index 9cea25d4..8a46461f 100644 --- a/pkg/controllers/cnstore/controller.go +++ b/pkg/controllers/cnstore/controller.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -506,7 +506,7 @@ func (c *withCNSet) syncStats(ctx *recon.Context[*corev1.Pod]) error { uid := v1alpha1.GetCNPodUUID(pod) moVersion := common.GetSemanticVersion(&pod.ObjectMeta) var queryAddress string - if err := c.withMOClientSet(ctx, func(ctx context.Context, handler *mocli.ClientSet) error { + if err := c.withMOClientSet(ctx, func(_ context.Context, handler *mocli.ClientSet) error { cn, ok := handler.StoreCache.GetCN(uid) if !ok { return gerrors.Errorf("CN with uuid %s not found", uid) diff --git a/pkg/controllers/cnstore/pooling.go b/pkg/controllers/cnstore/pooling.go index 8758d1ac..e9e770d6 100644 --- a/pkg/controllers/cnstore/pooling.go +++ b/pkg/controllers/cnstore/pooling.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,7 +32,7 @@ func (c *withCNSet) poolingCNReconcile(ctx *recon.Context[*corev1.Pod]) error { uid := v1alpha1.GetCNPodUUID(pod) var ready bool - if err := c.withMOClientSet(ctx, func(ctx context.Context, h *mocli.ClientSet) error { + if err := c.withMOClientSet(ctx, func(_ context.Context, h *mocli.ClientSet) error { _, ready = h.StoreCache.GetCN(uid) return nil }); err != nil { diff --git a/pkg/controllers/common/statefulset_predicate.go b/pkg/controllers/common/statefulset_predicate.go new file mode 100644 index 00000000..f9db2f7a --- /dev/null +++ b/pkg/controllers/common/statefulset_predicate.go @@ -0,0 +1,79 @@ +// Copyright 2026 Matrix Origin +// +// 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 common + +import ( + "github.com/matrixorigin/matrixone-operator/api/core/v1alpha1" + "github.com/openkruise/kruise-api/apps/v1beta1" + "k8s.io/apimachinery/pkg/api/equality" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// LogSetStatefulSetOwner returns the LogSet controller key for a Kruise +// StatefulSet. It rejects similarly named resources owned by another API. +func LogSetStatefulSetOwner(object client.Object) (client.ObjectKey, bool) { + sts, ok := object.(*v1beta1.StatefulSet) + if !ok { + return client.ObjectKey{}, false + } + owner := sts.GetOwnerReferences() + for i := range owner { + if owner[i].Controller != nil && *owner[i].Controller && + owner[i].APIVersion == v1alpha1.GroupVersion.String() && owner[i].Kind == "LogSet" { + return client.ObjectKey{Namespace: sts.Namespace, Name: owner[i].Name}, true + } + } + return client.ObjectKey{}, false +} + +// ReferencesLogSet reports whether an internal LogSet reference points to key. +// Older embedded references may omit namespace; those are local to the +// dependent object for compatibility with the established API semantics. +func ReferencesLogSet(ref v1alpha1.LogSetRef, dependentNamespace string, key client.ObjectKey) bool { + if ref.LogSet == nil { + return false + } + namespace := ref.LogSet.Namespace + if namespace == "" { + namespace = dependentNamespace + } + return namespace == key.Namespace && ref.LogSet.Name == key.Name +} + +// LogSetReserveOrdinalsChangedPredicate selects updates that change the LogSet +// service-addresses consumed by CNSet and DNSet configuration. Create and delete +// events are intentionally ignored: the owning LogSet already drives dependency +// creation and teardown, and enqueueing dependants from those events would alter +// their existing lifecycle ordering. +func LogSetReserveOrdinalsChangedPredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { return false }, + DeleteFunc: func(event.DeleteEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldSts, oldOK := e.ObjectOld.(*v1beta1.StatefulSet) + newSts, newOK := e.ObjectNew.(*v1beta1.StatefulSet) + if !oldOK || !newOK { + return false + } + if _, ok := LogSetStatefulSetOwner(newSts); !ok { + return false + } + return !equality.Semantic.DeepEqual(oldSts.Spec.ReserveOrdinals, newSts.Spec.ReserveOrdinals) + }, + GenericFunc: func(event.GenericEvent) bool { return false }, + } +} diff --git a/pkg/controllers/common/statefulset_predicate_test.go b/pkg/controllers/common/statefulset_predicate_test.go new file mode 100644 index 00000000..71fa80eb --- /dev/null +++ b/pkg/controllers/common/statefulset_predicate_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Matrix Origin +// +// 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 common + +import ( + "testing" + + "github.com/matrixorigin/matrixone-operator/api/core/v1alpha1" + kruisev1 "github.com/openkruise/kruise-api/apps/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestLogSetReserveOrdinalsChangedPredicate(t *testing.T) { + p := LogSetReserveOrdinalsChangedPredicate() + logSet := &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{Namespace: "provider", Name: "log", UID: "log-uid"}} + oldSts := &kruisev1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Namespace: "provider", + Name: "log-log", + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(logSet, + v1alpha1.GroupVersion.WithKind("LogSet"))}, + }} + newSts := oldSts.DeepCopy() + newSts.Spec.ReserveOrdinals = []int{1} + + if p.Create(event.CreateEvent{Object: oldSts}) { + t.Fatal("StatefulSet creation must not change dependent lifecycle ordering") + } + if p.Delete(event.DeleteEvent{Object: oldSts}) { + t.Fatal("StatefulSet deletion must not change dependent lifecycle ordering") + } + + if !p.Update(event.UpdateEvent{ObjectOld: oldSts, ObjectNew: newSts}) { + t.Fatal("reserveOrdinals change must trigger reconciliation") + } + + statusOnly := newSts.DeepCopy() + statusOnly.Status.ReadyReplicas = 1 + if p.Update(event.UpdateEvent{ObjectOld: newSts, ObjectNew: statusOnly}) { + t.Fatal("status-only change must not trigger dependent reconciliation") + } + + notLogSet := newSts.DeepCopy() + notLogSet.OwnerReferences[0].Kind = "DNSet" + if p.Update(event.UpdateEvent{ObjectOld: oldSts, ObjectNew: notLogSet}) { + t.Fatal("StatefulSets not controlled by a LogSet must be ignored") + } +} + +func TestReferencesLogSet(t *testing.T) { + key := client.ObjectKey{Namespace: "provider", Name: "log"} + if !ReferencesLogSet(v1alpha1.LogSetRef{LogSet: &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{ + Namespace: "provider", Name: "log", + }}}, "consumer", key) { + t.Fatal("explicit cross-namespace reference must match") + } + if !ReferencesLogSet(v1alpha1.LogSetRef{LogSet: &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{ + Name: "log", + }}}, "provider", key) { + t.Fatal("empty namespace must fall back to the dependent namespace") + } + if ReferencesLogSet(v1alpha1.LogSetRef{LogSet: &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{ + Namespace: "other", Name: "log", + }}}, "consumer", key) { + t.Fatal("same-name LogSet in another namespace must not match") + } +} diff --git a/pkg/controllers/dnset/controller.go b/pkg/controllers/dnset/controller.go index 927ed308..a89a4169 100644 --- a/pkg/controllers/dnset/controller.go +++ b/pkg/controllers/dnset/controller.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ package dnset import ( + "context" "strconv" "time" @@ -36,7 +37,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( @@ -301,7 +305,9 @@ func (d *Actor) Reconcile(mgr manager.Manager) error { err := recon.Setup[*v1alpha1.DNSet](&v1alpha1.DNSet{}, "dnset", mgr, d, recon.WithBuildFn(func(b *builder.Builder) { b.Owns(&kruise.StatefulSet{}). - Owns(&corev1.Service{}) + Owns(&corev1.Service{}). + Watches(&kruise.StatefulSet{}, handler.EnqueueRequestsFromMapFunc(requestsForLogSetStatefulSet(mgr.GetClient())), + builder.WithPredicates(common.LogSetReserveOrdinalsChangedPredicate())) })) if err != nil { return err @@ -309,3 +315,27 @@ func (d *Actor) Reconcile(mgr manager.Manager) error { return nil } + +func requestsForLogSetStatefulSet(reader client.Reader) handler.MapFunc { + return func(ctx context.Context, object client.Object) []reconcile.Request { + owner, ok := common.LogSetStatefulSetOwner(object) + if !ok { + return nil + } + + sets := &v1alpha1.DNSetList{} + if err := reader.List(ctx, sets); err != nil { + log.FromContext(ctx).Error(err, "list DNSets for LogSet StatefulSet", "statefulset", client.ObjectKeyFromObject(object)) + return nil + } + + requests := make([]reconcile.Request, 0, len(sets.Items)) + for i := range sets.Items { + set := &sets.Items[i] + if common.ReferencesLogSet(set.Deps.LogSetRef, set.Namespace, owner) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(set)}) + } + } + return requests + } +} diff --git a/pkg/controllers/dnset/controller_test.go b/pkg/controllers/dnset/controller_test.go index de49d56d..0b41438e 100644 --- a/pkg/controllers/dnset/controller_test.go +++ b/pkg/controllers/dnset/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ package dnset import ( + "context" "testing" "k8s.io/apimachinery/pkg/api/resource" @@ -34,8 +35,35 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/pointer" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +func TestRequestsForLogSetStatefulSet(t *testing.T) { + s := newScheme() + logSet := &v1alpha1.LogSet{ObjectMeta: metav1.ObjectMeta{Namespace: "provider", Name: "log", UID: "log-uid"}} + matching := &v1alpha1.DNSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "consumer", Name: "matching"}, + Deps: v1alpha1.DNSetDeps{LogSetRef: logSet.AsDependency()}, + } + unrelated := &v1alpha1.DNSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "consumer", Name: "unrelated"}, + Deps: v1alpha1.DNSetDeps{LogSetRef: v1alpha1.LogSetRef{LogSet: &v1alpha1.LogSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "other", Name: "log"}, + }}}, + } + cli := fake.KubeClientBuilder().WithScheme(s).WithObjects(matching, unrelated).Build() + sts := &kruisev1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Namespace: "provider", + Name: "log-log", + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(logSet, + v1alpha1.GroupVersion.WithKind("LogSet"))}, + }} + + requests := requestsForLogSetStatefulSet(cli)(context.Background(), sts) + g := NewGomegaWithT(t) + g.Expect(requests).To(Equal([]reconcile.Request{{NamespacedName: client.ObjectKeyFromObject(matching)}})) +} + func TestDNSetActor_Observe(t *testing.T) { s := newScheme() tpl := &v1alpha1.DNSet{ diff --git a/pkg/controllers/logset/controller_test.go b/pkg/controllers/logset/controller_test.go index 4d586143..5a5c35e1 100644 --- a/pkg/controllers/logset/controller_test.go +++ b/pkg/controllers/logset/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -117,7 +117,7 @@ func TestLogSetActor_Observe(t *testing.T) { name: "scale out", logset: tpl, client: &fake.Client{ - MockPatch: func(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOption) error { + MockPatch: func(_ context.Context, _ runtime.Object, _ client.Patch, _ ...client.PatchOption) error { return nil }, Client: fake.KubeClientBuilder().WithScheme(s).WithObjects( diff --git a/pkg/controllers/logset/sts_test.go b/pkg/controllers/logset/sts_test.go index affde553..01ee3112 100644 --- a/pkg/controllers/logset/sts_test.go +++ b/pkg/controllers/logset/sts_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -171,7 +171,7 @@ func Test_syncPodSpec(t *testing.T) { }, }} for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(_ *testing.T) { podSpec := tt.args.spec.DeepCopy() syncPodSpec(tt.args.ls, podSpec) if diff := cmp.Diff(podSpec, tt.want); diff != "" { @@ -294,7 +294,7 @@ func Test_syncPersistentVolumeClaim(t *testing.T) { // TODO: Add test cases. } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(_ *testing.T) { syncPersistentVolumeClaim(tt.args.ls, tt.args.sts) }) } diff --git a/pkg/controllers/mocluster/controller_test.go b/pkg/controllers/mocluster/controller_test.go index 7b32f46f..3217ab6d 100644 --- a/pkg/controllers/mocluster/controller_test.go +++ b/pkg/controllers/mocluster/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -126,7 +126,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { }, }, }, - expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, err error, _ client.Client) { g.Expect(recon.IsReady(&mo.Status)).To(BeTrue()) g.Expect(err).To(Succeed()) }, @@ -162,7 +162,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { }, }, }, - expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, _ error, _ client.Client) { g.Expect(recon.IsSynced(&mo.Status)).To(BeFalse()) cond, ok := recon.GetCondition(&mo.Status, recon.ConditionTypeReady) g.Expect(ok).To(BeTrue()) @@ -200,7 +200,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { }, }, }, - expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, mo *v1alpha1.MatrixOneCluster, _ error, _ client.Client) { g.Expect(recon.IsSynced(&mo.Status)).To(BeFalse()) cond, ok := recon.GetCondition(&mo.Status, recon.ConditionTypeSynced) g.Expect(ok).To(BeTrue()) @@ -219,7 +219,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { return m }(), objects: nil, - expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, _ error, c client.Client) { dn := &v1alpha1.DNSet{} g.Expect(c.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test"}, dn)).To(Succeed()) g.Expect(dn.Spec.NodeSelector).To(Equal(map[string]string{"global-label": "global-value"})) @@ -239,7 +239,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { return m }(), objects: nil, - expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, _ error, c client.Client) { dn := &v1alpha1.DNSet{} g.Expect(c.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test"}, dn)).To(Succeed()) g.Expect(*dn.Spec.Overlay.ImagePullPolicy).To(Equal(corev1.PullIfNotPresent)) @@ -259,7 +259,7 @@ func TestMatrixOneClusterActor_Observe(t *testing.T) { return m }(), objects: nil, - expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, err error, c client.Client) { + expect: func(g *WithT, _ *v1alpha1.MatrixOneCluster, _ error, c client.Client) { ls := &v1alpha1.LogSet{} g.Expect(c.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test"}, ls)).To(Succeed()) g.Expect(*ls.Spec.PVCRetentionPolicy).To(Equal(v1alpha1.PVCRetentionPolicyRetain)) diff --git a/pkg/webhook/common.go b/pkg/webhook/common.go index 05740c2e..a02c3826 100644 --- a/pkg/webhook/common.go +++ b/pkg/webhook/common.go @@ -1,4 +1,4 @@ -// Copyright 2025 Matrix Origin +// Copyright 2025-2026 Matrix Origin // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -217,7 +217,7 @@ func validateMainContainerOverlay(overlay *v1alpha1.MainContainerOverlay, path * // validate VolumeMounts errs = append(errs, validateSliceWithConvert(overlay.VolumeMounts, path.Child("volumeMounts"), apiscorev1.Convert_v1_VolumeMount_To_core_VolumeMount, - func(mounts []core.VolumeMount, subPath *field.Path) field.ErrorList { + func(_ []core.VolumeMount, _ *field.Path) field.ErrorList { // TODO: complete params with Container //return corevalidation.ValidateVolumeMounts(mounts, nil, nil, nil, path.Child("volumeMounts")) return nil diff --git a/test/e2e/matrixonecluster_test.go b/test/e2e/matrixonecluster_test.go index 2c8ded83..2e013b27 100644 --- a/test/e2e/matrixonecluster_test.go +++ b/test/e2e/matrixonecluster_test.go @@ -295,7 +295,11 @@ var _ = Describe("MatrixOneCluster test", func() { }, teardownClusterTimeout, pollInterval).Should(Succeed(), "cluster should be teardown") }) - It("Should create all sub-resources properly with maximum cluster name length", func() { + // This spec creates a complete MO cluster and has repeatedly exhausted its + // readiness timeout when competing with three other specs on the single-node + // Kind cluster. Its assertion is about generated resource names, not parallel + // reconciliation, so isolate it from the shared-cluster resource contention. + It("Should create all sub-resources properly with maximum cluster name length", Serial, func() { By("Create cluster with maximum name length") minioSecret := e2eutil.MinioSecret(env.Namespace) minioProvider := e2eutil.MinioShareStorage(minioSecret.Name)