From 324a292be8dc2bffd48f344b5f818c4fce2a23a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Mon, 1 Jun 2026 15:01:09 +0200 Subject: [PATCH 1/7] feat: add OLM bundle support Add operator-sdk based OLM bundle generation. The bundle is auto-generated from existing kustomize manifests via `make bundle`. Only a minimal CSV skeleton with human-curated metadata is maintained manually; deployment spec, RBAC, and CRDs are injected automatically by operator-sdk. --- .dockerignore | 3 + .gitignore | 3 + Makefile | 56 ++++++++++++++++- bundle.Dockerfile | 21 +++++++ ...ecycle-operator.clusterserviceversion.yaml | 62 +++++++++++++++++++ config/manifests/kustomization.yaml | 5 ++ config/scorecard/bases/config.yaml | 7 +++ config/scorecard/kustomization.yaml | 17 +++++ config/scorecard/patches/basic.config.yaml | 10 +++ config/scorecard/patches/olm.config.yaml | 50 +++++++++++++++ 10 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 bundle.Dockerfile create mode 100644 config/manifests/bases/mcp-lifecycle-operator.clusterserviceversion.yaml create mode 100644 config/manifests/kustomization.yaml create mode 100644 config/scorecard/bases/config.yaml create mode 100644 config/scorecard/kustomization.yaml create mode 100644 config/scorecard/patches/basic.config.yaml create mode 100644 config/scorecard/patches/olm.config.yaml diff --git a/.dockerignore b/.dockerignore index 4a507d2a..49c1fb2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,6 @@ !api/ !internal/ !hack/ + +# Re-include bundle for OLM bundle image builds +!bundle/ diff --git a/.gitignore b/.gitignore index efe9203c..6aa47881 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ go.work __pycache__/ *.pyc +# OLM bundle (generated by `make bundle`) +bundle/ + # editor and IDE paraphernalia .idea .vscode diff --git a/Makefile b/Makefile index 0a4ba73c..348e31ab 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,31 @@ # Generated from kubebuilder template: # https://github.com/kubernetes-sigs/kubebuilder/blob/v4.11.1/pkg/plugins/golang/v4/scaffolds/internal/templates/makefile.go +# VERSION defines the project version for the bundle. +VERSION ?= 0.0.1 + +# CHANNELS define the bundle channels used in the bundle. +CHANNELS ?= alpha +BUNDLE_CHANNELS := --channels=$(CHANNELS) + +# DEFAULT_CHANNEL defines the default channel used in the bundle. +DEFAULT_CHANNEL ?= alpha +BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) + +BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) + +# IMAGE_TAG_BASE defines the namespace and part of the image name for remote images. +IMAGE_TAG_BASE ?= controller + # Image URL to use all building/pushing image targets -IMG ?= controller:latest +IMG ?= $(IMAGE_TAG_BASE):latest + +# BUNDLE_IMG defines the image:tag used for the bundle. +BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) + +# BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command +BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) + # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -242,6 +265,24 @@ deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in 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 - +##@ OLM + +.PHONY: bundle +bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files. + "$(OPERATOR_SDK)" generate kustomize manifests -q + cd config/manager && "$(KUSTOMIZE)" edit set image controller=$(IMG) + "$(KUSTOMIZE)" build config/manifests | "$(OPERATOR_SDK)" generate bundle $(BUNDLE_GEN_FLAGS) + "$(OPERATOR_SDK)" bundle validate ./bundle + +.PHONY: bundle-build +bundle-build: ## Build the bundle image. + $(CONTAINER_TOOL) build -f bundle.Dockerfile -t $(BUNDLE_IMG) . + +.PHONY: bundle-push +bundle-push: ## Push the bundle image. + $(CONTAINER_TOOL) push $(BUNDLE_IMG) + + ##@ Dependencies ## Location to install dependencies to @@ -256,10 +297,12 @@ KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen ENVTEST ?= $(LOCALBIN)/setup-envtest GOLANGCI_LINT = $(LOCALBIN)/golangci-lint +OPERATOR_SDK ?= $(LOCALBIN)/operator-sdk ## Tool Versions KUSTOMIZE_VERSION ?= v5.7.1 CONTROLLER_TOOLS_VERSION ?= v0.20.0 +OPERATOR_SDK_VERSION ?= v1.41.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)'; \ @@ -300,6 +343,17 @@ 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)) +.PHONY: operator-sdk +operator-sdk: $(OPERATOR_SDK) ## Download operator-sdk locally if necessary. +$(OPERATOR_SDK): $(LOCALBIN) + @{ \ + set -e ;\ + OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ + curl -sSLo $(OPERATOR_SDK) https://github.com/operator-framework/operator-sdk/releases/download/$(OPERATOR_SDK_VERSION)/operator-sdk_$${OS}_$${ARCH} ;\ + chmod +x $(OPERATOR_SDK) ;\ + } + + # 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 diff --git a/bundle.Dockerfile b/bundle.Dockerfile new file mode 100644 index 00000000..8da4ed75 --- /dev/null +++ b/bundle.Dockerfile @@ -0,0 +1,21 @@ +FROM scratch + +# Core bundle labels. +LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1 +LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ +LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ +LABEL operators.operatorframework.io.bundle.package.v1=mcp-lifecycle-operator +LABEL operators.operatorframework.io.bundle.channels.v1=alpha +LABEL operators.operatorframework.io.bundle.channel.default.v1=alpha +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.41.1 +LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 +LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v4 + +# Labels for testing. +LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1 +LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/ + +# Copy files to locations specified by labels. +COPY bundle/manifests /manifests/ +COPY bundle/metadata /metadata/ +COPY bundle/tests/scorecard /tests/scorecard/ diff --git a/config/manifests/bases/mcp-lifecycle-operator.clusterserviceversion.yaml b/config/manifests/bases/mcp-lifecycle-operator.clusterserviceversion.yaml new file mode 100644 index 00000000..a67f4254 --- /dev/null +++ b/config/manifests/bases/mcp-lifecycle-operator.clusterserviceversion.yaml @@ -0,0 +1,62 @@ +apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + annotations: + alm-examples: '[]' + capabilities: Basic Install + name: mcp-lifecycle-operator.v0.0.0 + namespace: placeholder +spec: + apiservicedefinitions: {} + customresourcedefinitions: + owned: + - description: "MCPServer runs a Model Context Protocol (MCP) server in Kubernetes.\n\nMCPServer + creates and manages a Deployment and Service to run an MCP server from a\ncontainer + image. The MCP server exposes tools, resources, and prompts that AI applications\ncan + use via the Model Context Protocol.\n\nExample:\n\n\tapiVersion: mcp.x-k8s.io/v1alpha1\n\tkind: + MCPServer\n\tmetadata:\n\t name: example\n\tspec:\n\t source:\n\t type: + ContainerImage\n\t containerImage:\n\t ref: example-mcp-image\n\t + \ config:\n\t port: 8080\n\nThe controller manages Deployment and Service + resources with the same name as the MCPServer,\nusing ownerReferences to establish + ownership. The controller will reject updates to resources\nowned by other + controllers or resources with no controller owner (to prevent silent overwrites\nof + manually-created resources), but will adopt orphaned resources from a deleted + MCPServer\nwith the same name to enable seamless recreation." + displayName: MCPServer + kind: MCPServer + name: mcpservers.mcp.x-k8s.io + version: v1alpha1 + description: Kubernetes operator for managing MCP (Model Context Protocol) server + lifecycles. + displayName: MCP Lifecycle Operator + icon: + - base64data: "" + mediatype: "" + install: + spec: + deployments: null + strategy: "" + installModes: + - supported: true + type: OwnNamespace + - supported: true + type: SingleNamespace + - supported: true + type: MultiNamespace + - supported: true + type: AllNamespaces + keywords: + - mcp + - model-context-protocol + - ai + links: + - name: GitHub + url: https://github.com/kubernetes-sigs/mcp-lifecycle-operator + maintainers: + - email: cncf-mcp-lifecycle-operator-maintainers@lists.cncf.io + name: MCP Lifecycle Operator Maintainers + maturity: alpha + minKubeVersion: 1.32.0 + provider: + name: kubernetes-sigs + version: 0.0.0 diff --git a/config/manifests/kustomization.yaml b/config/manifests/kustomization.yaml new file mode 100644 index 00000000..7dcbf16a --- /dev/null +++ b/config/manifests/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- bases/mcp-lifecycle-operator.clusterserviceversion.yaml +- ../default +- ../samples +- ../scorecard diff --git a/config/scorecard/bases/config.yaml b/config/scorecard/bases/config.yaml new file mode 100644 index 00000000..0b4dae6e --- /dev/null +++ b/config/scorecard/bases/config.yaml @@ -0,0 +1,7 @@ +apiVersion: scorecard.operatorframework.io/v1alpha3 +kind: Configuration +metadata: + name: config +stages: +- parallel: true + tests: [] \ No newline at end of file diff --git a/config/scorecard/kustomization.yaml b/config/scorecard/kustomization.yaml new file mode 100644 index 00000000..61ceb4d7 --- /dev/null +++ b/config/scorecard/kustomization.yaml @@ -0,0 +1,17 @@ +resources: +- bases/config.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +patches: +- path: patches/basic.config.yaml + target: + group: scorecard.operatorframework.io + kind: Configuration + name: config + version: v1alpha3 +- path: patches/olm.config.yaml + target: + group: scorecard.operatorframework.io + kind: Configuration + name: config + version: v1alpha3 diff --git a/config/scorecard/patches/basic.config.yaml b/config/scorecard/patches/basic.config.yaml new file mode 100644 index 00000000..8237b70d --- /dev/null +++ b/config/scorecard/patches/basic.config.yaml @@ -0,0 +1,10 @@ +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - basic-check-spec + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: basic + test: basic-check-spec-test diff --git a/config/scorecard/patches/olm.config.yaml b/config/scorecard/patches/olm.config.yaml new file mode 100644 index 00000000..416660a7 --- /dev/null +++ b/config/scorecard/patches/olm.config.yaml @@ -0,0 +1,50 @@ +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-bundle-validation + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: olm + test: olm-bundle-validation-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-crds-have-validation + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: olm + test: olm-crds-have-validation-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-crds-have-resources + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: olm + test: olm-crds-have-resources-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-spec-descriptors + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: olm + test: olm-spec-descriptors-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-status-descriptors + image: quay.io/operator-framework/scorecard-test:v1.41.1 + labels: + suite: olm + test: olm-status-descriptors-test From a9e992d7906dff26b71bedb4dca18d331c3a2573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 2 Jun 2026 11:18:37 +0200 Subject: [PATCH 2/7] feat: add Kind cluster creation script with local registry Add hack/create-kind-cluster.sh that creates a Kind cluster with an HTTP container registry for local development. Supports named params for cluster name, registry port, and container tool (docker/podman). Update Makefile to use the script in setup-test-e2e and derive IMG and BUNDLE_IMG from a configurable REGISTRY variable. --- Makefile | 37 ++++++++-------- hack/create-kind-cluster.sh | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 17 deletions(-) create mode 100755 hack/create-kind-cluster.sh diff --git a/Makefile b/Makefile index 348e31ab..5d10c105 100644 --- a/Makefile +++ b/Makefile @@ -14,8 +14,12 @@ BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) +# REGISTRY defines the container registry for image targets. +REGISTRY_PORT ?= 5001 +REGISTRY ?= localhost:$(REGISTRY_PORT) + # IMAGE_TAG_BASE defines the namespace and part of the image name for remote images. -IMAGE_TAG_BASE ?= controller +IMAGE_TAG_BASE ?= $(REGISTRY)/mcp-lifecycle-operator # Image URL to use all building/pushing image targets IMG ?= $(IMAGE_TAG_BASE):latest @@ -117,31 +121,30 @@ cover-clean: ## Remove cover.out and out/coverage.{txt,html} from test-cover. KIND_CLUSTER ?= mcp-lifecycle-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."; \ - $(KIND) export kubeconfig --name $(KIND_CLUSTER) ;; \ - *) \ - echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ - $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ - esac +setup-test-e2e: ## Set up a Kind cluster with local registry for e2e tests + ./hack/create-kind-cluster.sh --name $(KIND_CLUSTER) --container-tool $(CONTAINER_TOOL) --registry-port $(REGISTRY_PORT) .PHONY: deploy-test-e2e deploy-test-e2e: setup-test-e2e manifests generate ## Build and deploy the operator to the Kind cluster for e2e tests. - $(MAKE) docker-build IMG=example.com/mcp-lifecycle-operator:e2e - $(KIND) load docker-image example.com/mcp-lifecycle-operator:e2e --name $(KIND_CLUSTER) - $(MAKE) install deploy IMG=example.com/mcp-lifecycle-operator:e2e + $(MAKE) docker-build docker-push + $(MAKE) install deploy $(KUBECTL) rollout status deployment/mcp-lifecycle-operator-controller-manager -n mcp-lifecycle-operator-system --timeout=120s .PHONY: test-e2e test-e2e: ## Run the e2e tests (requires operator already deployed, see deploy-test-e2e). go test -tags=e2e ./test/e2e/ -v -count=1 -timeout 1h +.PHONY: deploy-test-e2e-bundle +deploy-test-e2e-bundle: setup-test-e2e manifests generate operator-sdk ## Build images, install OLM, and push bundle to local registry for OLM e2e tests. + $(MAKE) docker-build docker-push + $(MAKE) bundle bundle-build bundle-push + @"$(OPERATOR_SDK)" olm status > /dev/null 2>&1 || "$(OPERATOR_SDK)" olm install + +.PHONY: test-e2e-bundle +test-e2e-bundle: ## Run OLM bundle e2e tests (requires deploy-test-e2e-bundle first). + OPERATOR_SDK="$(OPERATOR_SDK)" BUNDLE_IMG=$(BUNDLE_IMG) \ + go test -tags=e2e ./test/olm/ -v -count=1 -timeout 30m + .PHONY: cleanup-test-e2e cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests @$(KIND) delete cluster --name $(KIND_CLUSTER) diff --git a/hack/create-kind-cluster.sh b/hack/create-kind-cluster.sh new file mode 100755 index 00000000..8ac75bf7 --- /dev/null +++ b/hack/create-kind-cluster.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -euo pipefail + +CLUSTER_NAME="" +CONTAINER_TOOL="docker" +REGISTRY_NAME="kind-registry" +REGISTRY_PORT="5001" + +while [[ $# -gt 0 ]]; do + case $1 in + --name) + CLUSTER_NAME="$2" + shift 2 + ;; + --container-tool) + CONTAINER_TOOL="$2" + shift 2 + ;; + --registry-port) + REGISTRY_PORT="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + echo "Usage: $0 [--name ] [--container-tool ] [--registry-port ]" + exit 1 + ;; + esac +done + +CLUSTER_NAME_ARG="" +if [ -n "${CLUSTER_NAME}" ]; then + CLUSTER_NAME_ARG="--name ${CLUSTER_NAME}" +fi + +# Start a local registry if not already running. +if [ "$(${CONTAINER_TOOL} inspect -f '{{.State.Running}}' "${REGISTRY_NAME}" 2>/dev/null || true)" != 'true' ]; then + echo "Starting local registry on port ${REGISTRY_PORT}..." + ${CONTAINER_TOOL} run -d --restart=always -p "127.0.0.1:${REGISTRY_PORT}:5000" --name "${REGISTRY_NAME}" registry:2 +fi + +# Delete existing cluster if it exists. +kind delete cluster ${CLUSTER_NAME_ARG} 2>/dev/null || true + +# Create Kind cluster with containerd registry config path enabled. +echo "Creating Kind cluster..." +cat < via HTTP on the registry container. +NODES=$(kind get nodes ${CLUSTER_NAME_ARG} 2>/dev/null) +for node in ${NODES}; do + ${CONTAINER_TOOL} exec "${node}" mkdir -p "/etc/containerd/certs.d/localhost:${REGISTRY_PORT}" + cat < Date: Tue, 2 Jun 2026 11:18:57 +0200 Subject: [PATCH 3/7] feat: add OLM bundle e2e tests Add e2e tests that verify the operator installs and works via OLM across all four install modes: OwnNamespace, SingleNamespace, MultiNamespace, and AllNamespaces. Tests use operator-sdk run bundle to install the operator via OLM, then create MCPServer resources and verify reconciliation. --- test/olm/helpers.go | 133 +++++++++++++++++++++++++++++ test/olm/main_test.go | 46 ++++++++++ test/olm/olm_test.go | 193 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 test/olm/helpers.go create mode 100644 test/olm/main_test.go create mode 100644 test/olm/olm_test.go diff --git a/test/olm/helpers.go b/test/olm/helpers.go new file mode 100644 index 00000000..9a92cbc9 --- /dev/null +++ b/test/olm/helpers.go @@ -0,0 +1,133 @@ +//go:build e2e + +/* +Copyright 2026 The Kubernetes Authors + +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 olm + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "sync" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/pkg/envconf" + + mcpv1alpha1 "github.com/kubernetes-sigs/mcp-lifecycle-operator/api/v1alpha1" +) + +var ( + operatorSDKPath string + operatorSDKPathOnce sync.Once +) + +func operatorSDKBinary() string { + operatorSDKPathOnce.Do(func() { + operatorSDKPath = os.Getenv("OPERATOR_SDK") + if operatorSDKPath == "" { + operatorSDKPath = "operator-sdk" + } + }) + return operatorSDKPath +} + +func runOperatorSDK(args ...string) (string, error) { + cmd := exec.Command(operatorSDKBinary(), args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("%s\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + return stdout.String(), nil +} + +func installBundle(t *testing.T, namespace, installMode string) { + t.Helper() + bundleImg := os.Getenv("BUNDLE_IMG") + if bundleImg == "" { + t.Fatal("BUNDLE_IMG environment variable must be set") + } + args := []string{ + "run", "bundle", + "--namespace", namespace, + "--install-mode", installMode, + "--use-http", + "--timeout", "5m", + bundleImg, + } + t.Logf("installing bundle: operator-sdk %v", args) + out, err := runOperatorSDK(args...) + if err != nil { + t.Fatalf("failed to install bundle: %v", err) + } + t.Logf("bundle installed: %s", out) +} + +func cleanupOperator(t *testing.T, namespace string) { + t.Helper() + t.Logf("cleaning up operator in namespace %s", namespace) + out, err := runOperatorSDK("cleanup", "mcp-lifecycle-operator", "--namespace", namespace) + if err != nil { + t.Logf("warning: cleanup failed: %v", err) + return + } + t.Logf("operator cleaned up: %s", out) +} + + +func createNamespace(ctx context.Context, t *testing.T, cfg *envconf.Config, name string) { + t.Helper() + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} + if err := cfg.Client().Resources().Create(ctx, ns); err != nil { + t.Fatalf("failed to create namespace %s: %v", name, err) + } + t.Logf("created namespace %s", name) +} + +func deleteNamespace(ctx context.Context, t *testing.T, cfg *envconf.Config, name string) { + t.Helper() + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} + if err := cfg.Client().Resources().Delete(ctx, ns); err != nil { + t.Logf("warning: failed to delete namespace %s: %v", name, err) + } +} + +// assertNotReconciled verifies that an MCPServer has no status conditions +// after the given duration, indicating the operator did not reconcile it. +func assertNotReconciled(ctx context.Context, t *testing.T, r *resources.Resources, server *mcpv1alpha1.MCPServer, duration time.Duration) { + t.Helper() + deadline := time.Now().Add(duration) + for time.Now().Before(deadline) { + if err := r.Get(ctx, server.Name, server.Namespace, server); err != nil { + t.Fatalf("failed to get MCPServer: %v", err) + } + if len(server.Status.Conditions) > 0 { + t.Fatalf("MCPServer %s/%s was unexpectedly reconciled: conditions=%v", + server.Namespace, server.Name, server.Status.Conditions) + } + time.Sleep(2 * time.Second) + } + t.Logf("MCPServer %s/%s was not reconciled (as expected)", server.Namespace, server.Name) +} diff --git a/test/olm/main_test.go b/test/olm/main_test.go new file mode 100644 index 00000000..b7fc44cb --- /dev/null +++ b/test/olm/main_test.go @@ -0,0 +1,46 @@ +//go:build e2e + +/* +Copyright 2026 The Kubernetes Authors + +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 olm + +import ( + "os" + "testing" + + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + + mcpv1alpha1 "github.com/kubernetes-sigs/mcp-lifecycle-operator/api/v1alpha1" +) + +var testenv env.Environment + +func TestMain(m *testing.M) { + cfg, err := envconf.NewFromFlags() + if err != nil { + panic(err) + } + + testenv = env.NewWithConfig(cfg) + + if err := mcpv1alpha1.AddToScheme(cfg.Client().Resources().GetScheme()); err != nil { + panic(err) + } + + os.Exit(testenv.Run(m)) +} diff --git a/test/olm/olm_test.go b/test/olm/olm_test.go new file mode 100644 index 00000000..307a5180 --- /dev/null +++ b/test/olm/olm_test.go @@ -0,0 +1,193 @@ +//go:build e2e + +/* +Copyright 2026 The Kubernetes Authors + +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 olm + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + f "github.com/kubernetes-sigs/mcp-lifecycle-operator/test/e2e/framework" +) + +func TestOLMInstallOwnNamespace(t *testing.T) { + operatorNs := envconf.RandomName("olm-own", 16) + otherNs := envconf.RandomName("olm-other", 16) + + feature := features.New("OLM OwnNamespace install mode"). + WithLabel("type", "olm"). + WithLabel("install-mode", "OwnNamespace"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + createNamespace(ctx, t, cfg, operatorNs) + createNamespace(ctx, t, cfg, otherNs) + ctx = context.WithValue(ctx, f.NsKey, operatorNs) + installBundle(t, operatorNs, "OwnNamespace") + return ctx + }). + Assess("MCPServer in operator namespace becomes Ready", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + return f.SetupMCPServer(ctx, t, cfg, "test-own-ns", true) + }). + Assess("MCPServer in other namespace is not reconciled", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + r := cfg.Client().Resources() + server := f.NewMCPServer("test-other-ns", otherNs) + if err := r.Create(ctx, server); err != nil { + t.Fatalf("failed to create MCPServer in other namespace: %v", err) + } + t.Log("created MCPServer in other namespace, waiting to confirm it is not reconciled...") + assertNotReconciled(ctx, t, r, server, 30*time.Second) + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cleanupOperator(t, operatorNs) + deleteNamespace(ctx, t, cfg, operatorNs) + deleteNamespace(ctx, t, cfg, otherNs) + return ctx + }). + Feature() + + testenv.Test(t, feature) +} + +func TestOLMInstallSingleNamespace(t *testing.T) { + operatorNs := envconf.RandomName("olm-op", 16) + watchNs := envconf.RandomName("olm-watch", 16) + + feature := features.New("OLM SingleNamespace install mode"). + WithLabel("type", "olm"). + WithLabel("install-mode", "SingleNamespace"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + createNamespace(ctx, t, cfg, operatorNs) + createNamespace(ctx, t, cfg, watchNs) + ctx = context.WithValue(ctx, f.NsKey, watchNs) + installBundle(t, operatorNs, "SingleNamespace="+watchNs) + return ctx + }). + Assess("MCPServer in watched namespace becomes Ready", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + return f.SetupMCPServer(ctx, t, cfg, "test-watched", true) + }). + Assess("MCPServer in operator namespace is not reconciled", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + r := cfg.Client().Resources() + server := f.NewMCPServer("test-operator-ns", operatorNs) + if err := r.Create(ctx, server); err != nil { + t.Fatalf("failed to create MCPServer in operator namespace: %v", err) + } + t.Log("created MCPServer in operator namespace, waiting to confirm it is not reconciled...") + assertNotReconciled(ctx, t, r, server, 30*time.Second) + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cleanupOperator(t, operatorNs) + deleteNamespace(ctx, t, cfg, operatorNs) + deleteNamespace(ctx, t, cfg, watchNs) + return ctx + }). + Feature() + + testenv.Test(t, feature) +} + +func TestOLMInstallMultiNamespace(t *testing.T) { + operatorNs := envconf.RandomName("olm-op", 16) + watchNs1 := envconf.RandomName("olm-w1", 16) + watchNs2 := envconf.RandomName("olm-w2", 16) + excludedNs := envconf.RandomName("olm-excl", 16) + + feature := features.New("OLM MultiNamespace install mode"). + WithLabel("type", "olm"). + WithLabel("install-mode", "MultiNamespace"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + createNamespace(ctx, t, cfg, operatorNs) + createNamespace(ctx, t, cfg, watchNs1) + createNamespace(ctx, t, cfg, watchNs2) + createNamespace(ctx, t, cfg, excludedNs) + installBundle(t, operatorNs, "MultiNamespace="+watchNs1+","+watchNs2) + return ctx + }). + Assess("MCPServer in first watched namespace becomes Ready", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + ctx = context.WithValue(ctx, f.NsKey, watchNs1) + return f.SetupMCPServer(ctx, t, cfg, "test-w1", true) + }). + Assess("MCPServer in second watched namespace becomes Ready", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + r := cfg.Client().Resources() + server := f.NewMCPServer("test-w2", watchNs2) + if err := r.Create(ctx, server); err != nil { + t.Fatalf("failed to create MCPServer: %v", err) + } + f.WaitForMCPServerCondition(ctx, t, r, server, "Ready", metav1.ConditionTrue) + t.Log("MCPServer in second watched namespace is Ready") + return ctx + }). + Assess("MCPServer in excluded namespace is not reconciled", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + r := cfg.Client().Resources() + server := f.NewMCPServer("test-excluded", excludedNs) + if err := r.Create(ctx, server); err != nil { + t.Fatalf("failed to create MCPServer in excluded namespace: %v", err) + } + t.Log("created MCPServer in excluded namespace, waiting to confirm it is not reconciled...") + assertNotReconciled(ctx, t, r, server, 30*time.Second) + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cleanupOperator(t, operatorNs) + deleteNamespace(ctx, t, cfg, operatorNs) + deleteNamespace(ctx, t, cfg, watchNs1) + deleteNamespace(ctx, t, cfg, watchNs2) + deleteNamespace(ctx, t, cfg, excludedNs) + return ctx + }). + Feature() + + testenv.Test(t, feature) +} + +func TestOLMInstallAllNamespaces(t *testing.T) { + operatorNs := envconf.RandomName("olm-op", 16) + otherNs := envconf.RandomName("olm-any", 16) + + feature := features.New("OLM AllNamespaces install mode"). + WithLabel("type", "olm"). + WithLabel("install-mode", "AllNamespaces"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + createNamespace(ctx, t, cfg, operatorNs) + createNamespace(ctx, t, cfg, otherNs) + ctx = context.WithValue(ctx, f.NsKey, otherNs) + installBundle(t, operatorNs, "AllNamespaces") + return ctx + }). + Assess("MCPServer in any namespace becomes Ready", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + return f.SetupMCPServer(ctx, t, cfg, "test-all-ns", true) + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cleanupOperator(t, operatorNs) + deleteNamespace(ctx, t, cfg, operatorNs) + deleteNamespace(ctx, t, cfg, otherNs) + return ctx + }). + Feature() + + testenv.Test(t, feature) +} From 7b294ee113fcb782886af566454f3044a96f1c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 2 Jun 2026 11:35:35 +0200 Subject: [PATCH 4/7] feat: support WATCH_NAMESPACE for namespace-scoped watching Read the WATCH_NAMESPACE environment variable (comma-separated) to restrict the controller's cache to specific namespaces. When installed via OLM, this is populated from the olm.targetNamespaces annotation, enabling OwnNamespace, SingleNamespace, and MultiNamespace install modes. When unset, the operator watches all namespaces. --- cmd/main.go | 30 ++++++++++++++++++++++++++++++ config/manager/manager.yaml | 5 +++++ 2 files changed, 35 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 486b5ea2..bcced1e9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( "crypto/tls" "flag" "os" + "strings" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -32,6 +33,7 @@ import ( 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/cache" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -155,6 +157,15 @@ func main() { metricsServerOptions.KeyName = metricsCertKey } + var cacheOpts cache.Options + if watchNamespaces := getWatchNamespaces(); len(watchNamespaces) > 0 { + setupLog.Info("watching specific namespaces", "namespaces", watchNamespaces) + cacheOpts.DefaultNamespaces = make(map[string]cache.Config, len(watchNamespaces)) + for _, ns := range watchNamespaces { + cacheOpts.DefaultNamespaces[ns] = cache.Config{} + } + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, @@ -162,6 +173,7 @@ func main() { HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "bed7462b.x-k8s.io", + Cache: cacheOpts, // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily // when the Manager ends. This requires the binary to immediately end when the // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly @@ -205,3 +217,21 @@ func main() { os.Exit(1) } } + +// getWatchNamespaces returns the namespaces the operator should watch. +// When WATCH_NAMESPACE is set (comma-separated), the operator restricts its +// cache to those namespaces. OLM populates this via the olm.targetNamespaces +// annotation. An empty value means watch all namespaces. +func getWatchNamespaces() []string { + ns, found := os.LookupEnv("WATCH_NAMESPACE") + if !found || ns == "" { + return nil + } + var namespaces []string + for _, n := range strings.Split(ns, ",") { + if trimmed := strings.TrimSpace(n); trimmed != "" { + namespaces = append(namespaces, trimmed) + } + } + return namespaces +} diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 4b243936..b09890a2 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -63,6 +63,11 @@ spec: # - --zap-devel image: controller:latest name: manager + env: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.annotations['olm.targetNamespaces'] ports: [] securityContext: allowPrivilegeEscalation: false From 8b20c4759626cac2477dfe4678496068e1ee2bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 2 Jun 2026 11:53:23 +0200 Subject: [PATCH 5/7] ci: add OLM bundle e2e test workflow Run OLM bundle e2e tests on pull requests, verifying operator installation via OLM across all four install modes. --- .github/workflows/test-e2e-bundle.yml | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/test-e2e-bundle.yml diff --git a/.github/workflows/test-e2e-bundle.yml b/.github/workflows/test-e2e-bundle.yml new file mode 100644 index 00000000..a5cb549e --- /dev/null +++ b/.github/workflows/test-e2e-bundle.yml @@ -0,0 +1,33 @@ +name: OLM Bundle E2E Tests + +on: + pull_request: + branches: ['main'] + +permissions: + contents: read + +jobs: + test-e2e-bundle: + name: Run on Ubuntu + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + 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: Deploy operator bundle to Kind cluster + run: make deploy-test-e2e-bundle + + - name: Run OLM bundle e2e tests + run: make test-e2e-bundle \ No newline at end of file From 76e5facd1814f85038253e206c38d42bdbb9c6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 2 Jun 2026 12:07:16 +0200 Subject: [PATCH 6/7] Fix lint issues --- .github/workflows/test-e2e-bundle.yml | 2 +- cmd/main.go | 2 +- config/scorecard/bases/config.yaml | 2 +- hack/create-kind-cluster.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-e2e-bundle.yml b/.github/workflows/test-e2e-bundle.yml index a5cb549e..5aa1e310 100644 --- a/.github/workflows/test-e2e-bundle.yml +++ b/.github/workflows/test-e2e-bundle.yml @@ -30,4 +30,4 @@ jobs: run: make deploy-test-e2e-bundle - name: Run OLM bundle e2e tests - run: make test-e2e-bundle \ No newline at end of file + run: make test-e2e-bundle diff --git a/cmd/main.go b/cmd/main.go index bcced1e9..8dcab0e4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -228,7 +228,7 @@ func getWatchNamespaces() []string { return nil } var namespaces []string - for _, n := range strings.Split(ns, ",") { + for n := range strings.SplitSeq(ns, ",") { if trimmed := strings.TrimSpace(n); trimmed != "" { namespaces = append(namespaces, trimmed) } diff --git a/config/scorecard/bases/config.yaml b/config/scorecard/bases/config.yaml index 0b4dae6e..c7704784 100644 --- a/config/scorecard/bases/config.yaml +++ b/config/scorecard/bases/config.yaml @@ -4,4 +4,4 @@ metadata: name: config stages: - parallel: true - tests: [] \ No newline at end of file + tests: [] diff --git a/hack/create-kind-cluster.sh b/hack/create-kind-cluster.sh index 8ac75bf7..88ecd267 100755 --- a/hack/create-kind-cluster.sh +++ b/hack/create-kind-cluster.sh @@ -83,4 +83,4 @@ data: help: "https://kind.sigs.k8s.io/docs/user/local-registry/" EOF -echo "Kind cluster ready with local registry at localhost:${REGISTRY_PORT}" \ No newline at end of file +echo "Kind cluster ready with local registry at localhost:${REGISTRY_PORT}" From 7653a22a0289869767fe00450306fb97c9ab7577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Tue, 2 Jun 2026 12:42:53 +0200 Subject: [PATCH 7/7] docs: document OLM bundle and e2e test workflows Add development workflow documentation for OLM bundle generation, e2e testing targets, and Kind cluster setup with local registry. --- site-src/contributing/index.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/site-src/contributing/index.md b/site-src/contributing/index.md index da59b0c3..c659fdda 100644 --- a/site-src/contributing/index.md +++ b/site-src/contributing/index.md @@ -23,6 +23,26 @@ From the repository root: - **Test coverage:** `make test` writes `cover.out`. Run `make test-cover` to refresh tests and emit `out/coverage.html` and `out/coverage.txt` (`go tool cover`). With `cover.out` present, `make cover-func` prints a per-function summary and `make cover-html` opens the interactive HTML report in a browser (local). Remove generated artifacts with `make cover-clean`. - **Generate manifests:** `make manifests generate` +### E2E tests + +- **Set up Kind cluster:** `make setup-test-e2e` (creates a Kind cluster with a local container registry via `hack/create-kind-cluster.sh`) +- **Deploy operator:** `make deploy-test-e2e` (builds, pushes to local registry, deploys to Kind) +- **Run e2e tests:** `make test-e2e` +- **Tear down:** `make cleanup-test-e2e` + +### OLM bundle + +The operator supports installation via [OLM (Operator Lifecycle Manager)](https://olm.operatorframework.io/). The bundle is auto-generated from existing kustomize manifests — only the CSV skeleton in `config/manifests/bases/` is maintained manually. + +- **Generate bundle:** `make bundle` +- **Build bundle image:** `make bundle-build` +- **Push bundle image:** `make bundle-push` + +To run OLM bundle e2e tests (tests all four install modes: OwnNamespace, SingleNamespace, MultiNamespace, AllNamespaces): + +- **Deploy:** `make deploy-test-e2e-bundle` (creates Kind cluster with registry, builds/pushes images, installs OLM) +- **Run tests:** `make test-e2e-bundle` + After making changes, open a pull request on GitHub. Ensure CI passes and address any review feedback. ## Mentorship