From 30896d396415be62f707ee1813bf59bc9cb4c4a0 Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Tue, 28 Jul 2026 15:31:18 +0200 Subject: [PATCH 1/6] add objectbucketsource-adapter --- Dockerfile | 6 +- Dockerfile.source-objectbucket | 29 ++ Makefile | 75 +++- PROJECT | 9 + api/sources/v1alpha1/groupversion_info.go | 36 ++ .../v1alpha1/objectbucketsource_types.go | 83 ++++ api/sources/v1alpha1/zz_generated.deepcopy.go | 159 ++++++++ cmd/{ => func-operator}/main.go | 0 .../main.go | 354 ++++++++++++++++++ .../func-operator.clusterserviceversion.yaml | 53 +++ .../combined/func-operator/kustomization.yaml | 11 + .../func-operator/manager_metrics_patch.yaml | 7 + .../func-operator/metrics_service.yaml | 18 + config/combined/kustomization.yaml | 11 + config/combined/manifests/kustomization.yaml | 7 + .../source-objectbucket/kustomization.yaml | 17 + .../combined/source-objectbucket/manager.yaml | 84 +++++ .../manager_metrics_patch.yaml | 3 + .../source-objectbucket/metrics_service.yaml | 18 + .../combined/source-objectbucket/service.yaml | 16 + ...ces.functions.dev_objectbucketsources.yaml | 140 +++++++ config/crd/kustomization.yaml | 1 + config/rbac/role.yaml | 34 ++ .../objectbucket/default/kustomization.yaml | 14 + .../default/manager_metrics_patch.yaml | 3 + .../objectbucket/default/metrics_service.yaml | 18 + .../objectbucket/kafka/kafka_env_patch.yaml | 20 + .../objectbucket/kafka/kustomization.yaml | 7 + .../objectbucket/manager/kustomization.yaml | 9 + .../sources/objectbucket/manager/manager.yaml | 93 +++++ .../sources/objectbucket/manager/service.yaml | 16 + .../objectbucket/rbac/kustomization.yaml | 12 + .../rbac/leader_election_role.yaml | 39 ++ .../rbac/leader_election_role_binding.yaml | 15 + .../objectbucket/rbac/metrics_auth_role.yaml | 17 + .../rbac/metrics_auth_role_binding.yaml | 12 + .../rbac/metrics_reader_role.yaml | 9 + .../rbac/objectbucketsource_admin_role.yaml | 20 + .../rbac/objectbucketsource_editor_role.yaml | 26 ++ .../rbac/objectbucketsource_viewer_role.yaml | 22 ++ config/sources/objectbucket/rbac/role.yaml | 49 +++ .../objectbucket/rbac/role_binding.yaml | 15 + .../objectbucket/rbac/service_account.yaml | 8 + .../objectbucket/samples/kustomization.yaml | 2 + .../sources_v1alpha1_objectbucketsource.yaml | 14 + go.mod | 29 +- go.sum | 83 ++++ hack/noobaa-connection-setup.sh | 36 ++ hack/noobaa-kafka-connection-setup.sh | 156 ++++++++ hack/rook-kafka-connection-setup.sh | 253 +++++++++++++ .../cloudevents/dispatch.go | 77 ++++ .../cloudevents/dispatch_test.go | 52 +++ .../objectbucketsource_controller.go | 312 +++++++++++++++ .../objectbucketsource_controller_test.go | 89 +++++ .../controller/suite_test.go | 116 ++++++ .../objectbucketsource/eventmatch/match.go | 16 + internal/objectbucketsource/kafka/config.go | 147 ++++++++ .../objectbucketsource/kafka/config_test.go | 291 ++++++++++++++ .../notificationserver/consumer.go | 38 ++ .../notificationserver/handler.go | 192 ++++++++++ .../notificationserver/server.go | 113 ++++++ .../objectbucketsource/s3client/client.go | 62 +++ .../objectbucket-e2e/e2e_suite_test.go | 89 +++++ test/sources/objectbucket-e2e/e2e_test.go | 329 ++++++++++++++++ test/sources/objectbucket-utils/utils.go | 254 +++++++++++++ 65 files changed, 4333 insertions(+), 12 deletions(-) create mode 100644 Dockerfile.source-objectbucket create mode 100644 api/sources/v1alpha1/groupversion_info.go create mode 100644 api/sources/v1alpha1/objectbucketsource_types.go create mode 100644 api/sources/v1alpha1/zz_generated.deepcopy.go rename cmd/{ => func-operator}/main.go (100%) create mode 100644 cmd/objectbucket-notifications-adapter/main.go create mode 100644 config/combined/bases/func-operator.clusterserviceversion.yaml create mode 100644 config/combined/func-operator/kustomization.yaml create mode 100644 config/combined/func-operator/manager_metrics_patch.yaml create mode 100644 config/combined/func-operator/metrics_service.yaml create mode 100644 config/combined/kustomization.yaml create mode 100644 config/combined/manifests/kustomization.yaml create mode 100644 config/combined/source-objectbucket/kustomization.yaml create mode 100644 config/combined/source-objectbucket/manager.yaml create mode 100644 config/combined/source-objectbucket/manager_metrics_patch.yaml create mode 100644 config/combined/source-objectbucket/metrics_service.yaml create mode 100644 config/combined/source-objectbucket/service.yaml create mode 100644 config/crd/bases/sources.functions.dev_objectbucketsources.yaml create mode 100644 config/sources/objectbucket/default/kustomization.yaml create mode 100644 config/sources/objectbucket/default/manager_metrics_patch.yaml create mode 100644 config/sources/objectbucket/default/metrics_service.yaml create mode 100644 config/sources/objectbucket/kafka/kafka_env_patch.yaml create mode 100644 config/sources/objectbucket/kafka/kustomization.yaml create mode 100644 config/sources/objectbucket/manager/kustomization.yaml create mode 100644 config/sources/objectbucket/manager/manager.yaml create mode 100644 config/sources/objectbucket/manager/service.yaml create mode 100644 config/sources/objectbucket/rbac/kustomization.yaml create mode 100644 config/sources/objectbucket/rbac/leader_election_role.yaml create mode 100644 config/sources/objectbucket/rbac/leader_election_role_binding.yaml create mode 100644 config/sources/objectbucket/rbac/metrics_auth_role.yaml create mode 100644 config/sources/objectbucket/rbac/metrics_auth_role_binding.yaml create mode 100644 config/sources/objectbucket/rbac/metrics_reader_role.yaml create mode 100644 config/sources/objectbucket/rbac/objectbucketsource_admin_role.yaml create mode 100644 config/sources/objectbucket/rbac/objectbucketsource_editor_role.yaml create mode 100644 config/sources/objectbucket/rbac/objectbucketsource_viewer_role.yaml create mode 100644 config/sources/objectbucket/rbac/role.yaml create mode 100644 config/sources/objectbucket/rbac/role_binding.yaml create mode 100644 config/sources/objectbucket/rbac/service_account.yaml create mode 100644 config/sources/objectbucket/samples/kustomization.yaml create mode 100644 config/sources/objectbucket/samples/sources_v1alpha1_objectbucketsource.yaml create mode 100755 hack/noobaa-connection-setup.sh create mode 100755 hack/noobaa-kafka-connection-setup.sh create mode 100755 hack/rook-kafka-connection-setup.sh create mode 100644 internal/objectbucketsource/cloudevents/dispatch.go create mode 100644 internal/objectbucketsource/cloudevents/dispatch_test.go create mode 100644 internal/objectbucketsource/controller/objectbucketsource_controller.go create mode 100644 internal/objectbucketsource/controller/objectbucketsource_controller_test.go create mode 100644 internal/objectbucketsource/controller/suite_test.go create mode 100644 internal/objectbucketsource/eventmatch/match.go create mode 100644 internal/objectbucketsource/kafka/config.go create mode 100644 internal/objectbucketsource/kafka/config_test.go create mode 100644 internal/objectbucketsource/notificationserver/consumer.go create mode 100644 internal/objectbucketsource/notificationserver/handler.go create mode 100644 internal/objectbucketsource/notificationserver/server.go create mode 100644 internal/objectbucketsource/s3client/client.go create mode 100644 test/sources/objectbucket-e2e/e2e_suite_test.go create mode 100644 test/sources/objectbucket-e2e/e2e_test.go create mode 100644 test/sources/objectbucket-utils/utils.go diff --git a/Dockerfile b/Dockerfile index 975e0dd..4dacd97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ COPY go.sum go.sum RUN go mod download # Copy the go source -COPY cmd/main.go cmd/main.go +COPY cmd/func-operator/main.go cmd/func-operator/main.go COPY api/ api/ COPY internal/ internal/ @@ -21,11 +21,11 @@ COPY internal/ internal/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/func-operator/main.go FROM builder AS builder-debug # Build with debug symbols for delve -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -gcflags="all=-N -l" -o manager-debug cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -gcflags="all=-N -l" -o manager-debug cmd/func-operator/main.go # Install Delve RUN go install github.com/go-delve/delve/cmd/dlv@latest diff --git a/Dockerfile.source-objectbucket b/Dockerfile.source-objectbucket new file mode 100644 index 0000000..71c1762 --- /dev/null +++ b/Dockerfile.source-objectbucket @@ -0,0 +1,29 @@ +# Build the manager binary +FROM golang:1.26 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY cmd/objectbucket-notifications-adapter/main.go cmd/objectbucket-notifications-adapter/main.go +COPY api/ api/ +COPY internal/ internal/ + +# Build +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/objectbucket-notifications-adapter/main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/manager . +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile index 7da884d..535ddf0 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both # functions.dev/func-operator-bundle:$VERSION and functions.dev/func-operator-catalog:$VERSION. IMAGE_TAG_BASE ?= localhost:5001/func-operator +IMAGE_TAG_BASE_SOURCE_OBJECTBUCKET ?= localhost:5001/objectbucket-notifications-adapter DEBUG_IMAGE_TAG_BASE ?= $(IMAGE_TAG_BASE)-debug @@ -56,6 +57,7 @@ endif OPERATOR_SDK_VERSION ?= v1.41.1 # Image URL to use all building/pushing image targets IMG ?= $(IMAGE_TAG_BASE):$(VERSION) +IMG_SOURCE_OBJECTBUCKET ?= $(IMAGE_TAG_BASE_SOURCE_OBJECTBUCKET):$(VERSION) DEBUG_IMG ?= $(DEBUG_IMAGE_TAG_BASE):v$(VERSION) # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) @@ -116,7 +118,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet setup-envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v e2e) -coverprofile cover.out .PHONY: test-e2e ## Run e2e tests. test-e2e: ginkgo @@ -145,12 +147,23 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration ##@ Build .PHONY: build -build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go +build: manifests generate fmt vet ## Build func-operator manager binary. + go build -o bin/manager cmd/func-operator/main.go + +.PHONY: build-source-objectbucket +build-source-objectbucket: manifests generate fmt vet ## Build objectbucket-notifications-adapter binary. + go build -o bin/source-objectbucket cmd/objectbucket-notifications-adapter/main.go + +.PHONY: build-sources +build-sources: build-source-objectbucket ## Build all event source binaries. .PHONY: run -run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go +run: manifests generate fmt vet ## Run the func-operator controller from your host. + go run ./cmd/func-operator/main.go + +.PHONY: run-source-objectbucket +run-source-objectbucket: manifests generate fmt vet ## Run the objectbucket-notifications-adapter controller from your host. + go run ./cmd/objectbucket-notifications-adapter/main.go # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. @@ -178,6 +191,20 @@ docker-push-debugger: ## Push debugger docker image with the manager. # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +.PHONY: docker-build-source-objectbucket +docker-build-source-objectbucket: ## Build docker image for the objectbucket-notifications-adapter. + $(CONTAINER_TOOL) build -t ${IMG_SOURCE_OBJECTBUCKET} -f Dockerfile.source-objectbucket . + +.PHONY: docker-push-source-objectbucket +docker-push-source-objectbucket: ## Push docker image for the objectbucket-notifications-adapter. + $(CONTAINER_TOOL) push ${IMG_SOURCE_OBJECTBUCKET} + +.PHONY: docker-build-sources +docker-build-sources: docker-build-source-objectbucket ## Build docker images for all event sources. + +.PHONY: docker-push-sources +docker-push-sources: docker-push-source-objectbucket ## Push docker images for all event sources. + .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile @@ -239,12 +266,31 @@ patch-registry-cert: ## Patch deployment to mount local registry certificate (fo fi .PHONY: deploy -deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. +deploy: manifests kustomize ## Deploy func-operator controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - $(MAKE) patch-registry-cert $(KUBECTL) wait deployment --all --timeout=120s --for=condition=Available -n func-operator-system +.PHONY: deploy-source-objectbucket +deploy-source-objectbucket: manifests kustomize ## Deploy objectbucket-notifications-adapter to the K8s cluster. + cd config/sources/objectbucket/manager && $(KUSTOMIZE) edit set image controller=${IMG_SOURCE_OBJECTBUCKET} + $(KUSTOMIZE) build config/sources/objectbucket/default | $(KUBECTL) apply -f - + $(KUBECTL) wait deployment --all --timeout=120s --for=condition=Available -n objectbucket-notifications-adapter-system + +.PHONY: deploy-source-objectbucket-kafka +deploy-source-objectbucket-kafka: manifests kustomize ## Deploy objectbucket-notifications-adapter in Kafka mode. + cd config/sources/objectbucket/manager && $(KUSTOMIZE) edit set image controller=${IMG_SOURCE_OBJECTBUCKET} + $(KUSTOMIZE) build config/sources/objectbucket/kafka | $(KUBECTL) apply -f - + $(KUBECTL) wait deployment --all --timeout=120s --for=condition=Available -n objectbucket-notifications-adapter-system + +.PHONY: deploy-combined +deploy-combined: manifests kustomize ## Deploy both func-operator and all event sources in a single namespace. + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + cd config/sources/objectbucket/manager && $(KUSTOMIZE) edit set image controller=${IMG_SOURCE_OBJECTBUCKET} + $(KUSTOMIZE) build config/combined | $(KUBECTL) apply -f - + $(KUBECTL) wait deployment --all --timeout=120s --for=condition=Available -n func-operator-system + .PHONY: deploy-debugger deploy-debugger: manifests kustomize ## Deploy debug controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${DEBUG_IMG} @@ -253,9 +299,17 @@ deploy-debugger: manifests kustomize ## Deploy debug controller to the K8s clust $(MAKE) patch-registry-cert .PHONY: undeploy -undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. +undeploy: kustomize ## Undeploy func-operator controller from the K8s cluster. 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 - +.PHONY: undeploy-source-objectbucket +undeploy-source-objectbucket: kustomize ## Undeploy objectbucket-notifications-adapter from the K8s cluster. + $(KUSTOMIZE) build config/sources/objectbucket/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + +.PHONY: undeploy-combined +undeploy-combined: kustomize ## Undeploy both func-operator and all event sources from the K8s cluster. + $(KUSTOMIZE) build config/combined | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + .PHONY: create-kind-cluster create-kind-cluster: ./hack/create-kind-cluster.sh @@ -382,6 +436,13 @@ bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metada $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) $(OPERATOR_SDK) bundle validate ./bundle +.PHONY: bundle-combined +bundle-combined: manifests kustomize operator-sdk ## Generate combined OLM bundle with both func-operator and all event sources. + cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) + cd config/sources/objectbucket/manager && $(KUSTOMIZE) edit set image controller=$(IMG_SOURCE_OBJECTBUCKET) + $(KUSTOMIZE) build config/combined/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) . diff --git a/PROJECT b/PROJECT index c651243..1d67883 100644 --- a/PROJECT +++ b/PROJECT @@ -19,4 +19,13 @@ resources: kind: Function path: github.com/functions-dev/func-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: functions.dev + group: sources + kind: ObjectBucketSource + path: github.com/functions-dev/func-operator/api/sources/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/sources/v1alpha1/groupversion_info.go b/api/sources/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..8dbac4c --- /dev/null +++ b/api/sources/v1alpha1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains API Schema definitions for the sources v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=sources.functions.dev +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "sources.functions.dev", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/sources/v1alpha1/objectbucketsource_types.go b/api/sources/v1alpha1/objectbucketsource_types.go new file mode 100644 index 0000000..f9a9732 --- /dev/null +++ b/api/sources/v1alpha1/objectbucketsource_types.go @@ -0,0 +1,83 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ConditionOBCCredentialsAvailable = "OBCCredentialsAvailable" + ConditionBucketNotificationSet = "BucketNotificationSet" + ConditionTestEventReceived = "TestEventReceived" + + FinalizerName = "sources.functions.dev/objectbucketsource" +) + +// ObjectBucketSourceSpec defines the desired state of ObjectBucketSource. +type ObjectBucketSourceSpec struct { + // ObjectBucketClaim is a reference to the ObjectBucketClaim in the same namespace. + ObjectBucketClaim OBCReference `json:"objectBucketClaim"` + // Events is the list of S3 event types to subscribe to (e.g. "s3:ObjectCreated:*"). + Events []string `json:"events"` + // Sink is the endpoint to dispatch matching CloudEvents to. + Sink SinkSpec `json:"sink"` +} + +type OBCReference struct { + // Name of the ObjectBucketClaim in the same namespace. + Name string `json:"name"` +} + +// SinkSpec defines the destination for CloudEvents. URI can be an HTTP URL +// (e.g. "http://foo.bar.svc.cluster.local") or a Kafka topic reference +// (e.g. "kafka:my-topic"). +type SinkSpec struct { + // URI is the destination to send CloudEvents to. Use an HTTP URL for + // HTTP delivery, or "kafka:" for Kafka delivery. + URI string `json:"uri"` +} + +// ObjectBucketSourceStatus defines the observed state of ObjectBucketSource. +type ObjectBucketSourceStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ObjectBucketSource is the Schema for the objectbucketsources API. +type ObjectBucketSource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ObjectBucketSourceSpec `json:"spec,omitempty"` + Status ObjectBucketSourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ObjectBucketSourceList contains a list of ObjectBucketSource. +type ObjectBucketSourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ObjectBucketSource `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ObjectBucketSource{}, &ObjectBucketSourceList{}) +} diff --git a/api/sources/v1alpha1/zz_generated.deepcopy.go b/api/sources/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..0d748a7 --- /dev/null +++ b/api/sources/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,159 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OBCReference) DeepCopyInto(out *OBCReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OBCReference. +func (in *OBCReference) DeepCopy() *OBCReference { + if in == nil { + return nil + } + out := new(OBCReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectBucketSource) DeepCopyInto(out *ObjectBucketSource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectBucketSource. +func (in *ObjectBucketSource) DeepCopy() *ObjectBucketSource { + if in == nil { + return nil + } + out := new(ObjectBucketSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ObjectBucketSource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectBucketSourceList) DeepCopyInto(out *ObjectBucketSourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ObjectBucketSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectBucketSourceList. +func (in *ObjectBucketSourceList) DeepCopy() *ObjectBucketSourceList { + if in == nil { + return nil + } + out := new(ObjectBucketSourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ObjectBucketSourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectBucketSourceSpec) DeepCopyInto(out *ObjectBucketSourceSpec) { + *out = *in + out.ObjectBucketClaim = in.ObjectBucketClaim + if in.Events != nil { + in, out := &in.Events, &out.Events + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.Sink = in.Sink +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectBucketSourceSpec. +func (in *ObjectBucketSourceSpec) DeepCopy() *ObjectBucketSourceSpec { + if in == nil { + return nil + } + out := new(ObjectBucketSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectBucketSourceStatus) DeepCopyInto(out *ObjectBucketSourceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectBucketSourceStatus. +func (in *ObjectBucketSourceStatus) DeepCopy() *ObjectBucketSourceStatus { + if in == nil { + return nil + } + out := new(ObjectBucketSourceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SinkSpec) DeepCopyInto(out *SinkSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SinkSpec. +func (in *SinkSpec) DeepCopy() *SinkSpec { + if in == nil { + return nil + } + out := new(SinkSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/func-operator/main.go similarity index 100% rename from cmd/main.go rename to cmd/func-operator/main.go diff --git a/cmd/objectbucket-notifications-adapter/main.go b/cmd/objectbucket-notifications-adapter/main.go new file mode 100644 index 0000000..b547458 --- /dev/null +++ b/cmd/objectbucket-notifications-adapter/main.go @@ -0,0 +1,354 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "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. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/IBM/sarama" + + sourcesv1alpha1 "github.com/functions-dev/func-operator/api/sources/v1alpha1" + "github.com/functions-dev/func-operator/internal/objectbucketsource/controller" + kafkaconfig "github.com/functions-dev/func-operator/internal/objectbucketsource/kafka" + "github.com/functions-dev/func-operator/internal/objectbucketsource/notificationserver" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(sourcesv1alpha1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +// nolint:gocyclo +func main() { + var metricsAddr string + var metricsCertPath, metricsCertName, metricsCertKey string + var webhookCertPath, webhookCertName, webhookCertKey string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", + "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher + + webhookTLSOpts := tlsOpts + + if len(webhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", + "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) + + var err error + webhookCertWatcher, err = certwatcher.New( + filepath.Join(webhookCertPath, webhookCertName), + filepath.Join(webhookCertPath, webhookCertKey), + ) + if err != nil { + setupLog.Error(err, "Failed to initialize webhook certificate watcher") + os.Exit(1) + } + + webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { + config.GetCertificate = webhookCertWatcher.GetCertificate + }) + } + + webhookServer := webhook.NewServer(webhook.Options{ + TLSOpts: webhookTLSOpts, + }) + + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + if len(metricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", + "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) + + var err error + metricsCertWatcher, err = certwatcher.New( + filepath.Join(metricsCertPath, metricsCertName), + filepath.Join(metricsCertPath, metricsCertKey), + ) + if err != nil { + setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) + os.Exit(1) + } + + metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { + config.GetCertificate = metricsCertWatcher.GetCertificate + }) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "dd939d79.functions.dev", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + noobaaAdapterID := envOrDefault("NOOBAA_ADAPTER_ID", "mcg-adapter") + noobaaAdapterTopic := envOrDefault("NOOBAA_ADAPTER_TOPIC_ARN", "mcg-adapter-connection/connect.json") + noobaaStorageClassPattern := envOrDefault("NOOBAA_ADAPTER_STORAGECLASS_PATTERN", `.*noobaa\.io$`) + + radosgwAdapterID := envOrDefault("RADOSGW_ADAPTER_ID", "rgw-adapter") + radosgwAdapterTopic := envOrDefault("RADOSGW_ADAPTER_TOPIC_ARN", + "arn:aws:sns:ocs-storagecluster-cephobjectstore::rgw-adapter-notifications") + radosgwStorageClassPattern := envOrDefault("RADOSGW_ADAPTER_STORAGECLASS_PATTERN", `.*ceph-rgw$`) + + adapterConfigs := make([]controller.AdapterConfig, 0, 2) + for _, cfg := range []struct { + id, topic, pattern string + }{ + {noobaaAdapterID, noobaaAdapterTopic, noobaaStorageClassPattern}, + {radosgwAdapterID, radosgwAdapterTopic, radosgwStorageClassPattern}, + } { + re, err := regexp.Compile(cfg.pattern) + if err != nil { + setupLog.Error(err, "invalid storageclass pattern", "pattern", cfg.pattern) + os.Exit(1) + } + adapterConfigs = append(adapterConfigs, controller.AdapterConfig{ + ID: cfg.id, + Topic: cfg.topic, + StorageClassPattern: re, + }) + } + + adapterPort := 8888 + if portStr := os.Getenv("ADAPTER_PORT"); portStr != "" { + var err error + adapterPort, err = strconv.Atoi(portStr) + if err != nil { + setupLog.Error(err, "invalid ADAPTER_PORT") + os.Exit(1) + } + } + notificationsMode := os.Getenv("NOTIFICATIONS_MODE") + if notificationsMode == "" { + notificationsMode = "http" + } + if notificationsMode != "http" && notificationsMode != "kafka" { + setupLog.Error(fmt.Errorf("invalid NOTIFICATIONS_MODE %q", notificationsMode), "must be \"http\" or \"kafka\"") + os.Exit(1) + } + + var kafkaNotificationsTopics []string + if topicsStr := os.Getenv("KAFKA_NOTIFICATIONS_TOPIC"); topicsStr != "" { + for _, t := range strings.Split(topicsStr, ",") { + if trimmed := strings.TrimSpace(t); trimmed != "" { + kafkaNotificationsTopics = append(kafkaNotificationsTopics, trimmed) + } + } + } + kafkaNotificationsGroupID := os.Getenv("KAFKA_NOTIFICATIONS_GROUP_ID") + + var kafkaBrokers []string + if brokersStr := os.Getenv("KAFKA_BROKERS"); brokersStr != "" { + kafkaBrokers = strings.Split(brokersStr, ",") + } + + var kafkaCfg *sarama.Config + if kafkaSecretName := os.Getenv("KAFKA_SECRET"); kafkaSecretName != "" { + ns := os.Getenv("POD_NAMESPACE") + if ns == "" { + nsBytes, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + setupLog.Error(err, "cannot determine pod namespace for KAFKA_SECRET") + os.Exit(1) + } + ns = strings.TrimSpace(string(nsBytes)) + } + clientset, err := kubernetes.NewForConfig(ctrl.GetConfigOrDie()) + if err != nil { + setupLog.Error(err, "creating kubernetes clientset for KAFKA_SECRET") + os.Exit(1) + } + secret, err := clientset.CoreV1().Secrets(ns).Get(context.Background(), kafkaSecretName, metav1.GetOptions{}) + if err != nil { + setupLog.Error(err, "reading KAFKA_SECRET", "name", kafkaSecretName, "namespace", ns) + os.Exit(1) + } + kafkaCfg, err = kafkaconfig.NewConfig(secret.Data) + if err != nil { + setupLog.Error(err, "configuring kafka from secret", "name", kafkaSecretName) + os.Exit(1) + } + setupLog.Info("kafka configured from secret", "name", kafkaSecretName, "namespace", ns) + } else { + var err error + kafkaCfg, err = kafkaconfig.NewConfig(nil) + if err != nil { + setupLog.Error(err, "creating default kafka config") + os.Exit(1) + } + } + + if err := (&controller.ObjectBucketSourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + AdapterConfigs: adapterConfigs, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ObjectBucketSource") + os.Exit(1) + } + // +kubebuilder:scaffold:builder + + if notificationsMode == "kafka" { + if len(kafkaNotificationsTopics) == 0 { + setupLog.Error(fmt.Errorf("KAFKA_NOTIFICATIONS_TOPIC is required when NOTIFICATIONS_MODE=kafka"), "missing env") + os.Exit(1) + } + if kafkaNotificationsGroupID == "" { + setupLog.Error(fmt.Errorf("KAFKA_NOTIFICATIONS_GROUP_ID is required when NOTIFICATIONS_MODE=kafka"), "missing env") + os.Exit(1) + } + if len(kafkaBrokers) == 0 { + setupLog.Error(fmt.Errorf("KAFKA_BROKERS is required when NOTIFICATIONS_MODE=kafka"), "missing env") + os.Exit(1) + } + } + + notifServer := ¬ificationserver.NotificationServer{ + Client: mgr.GetClient(), + Port: adapterPort, + KafkaBrokers: kafkaBrokers, + KafkaConfig: kafkaCfg, + NotificationsMode: notificationsMode, + KafkaNotificationsTopics: kafkaNotificationsTopics, + KafkaNotificationsGroupID: kafkaNotificationsGroupID, + } + if err := mgr.Add(notifServer); err != nil { + setupLog.Error(err, "unable to add notification server") + os.Exit(1) + } + + if metricsCertWatcher != nil { + setupLog.Info("Adding metrics certificate watcher to manager") + if err := mgr.Add(metricsCertWatcher); err != nil { + setupLog.Error(err, "unable to add metrics certificate watcher to manager") + os.Exit(1) + } + } + + if webhookCertWatcher != nil { + setupLog.Info("Adding webhook certificate watcher to manager") + if err := mgr.Add(webhookCertWatcher); err != nil { + setupLog.Error(err, "unable to add webhook certificate watcher to manager") + os.Exit(1) + } + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} + +func envOrDefault(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} diff --git a/config/combined/bases/func-operator.clusterserviceversion.yaml b/config/combined/bases/func-operator.clusterserviceversion.yaml new file mode 100644 index 0000000..72a8dd7 --- /dev/null +++ b/config/combined/bases/func-operator.clusterserviceversion.yaml @@ -0,0 +1,53 @@ +apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + annotations: + alm-examples: '[]' + capabilities: Basic Install + name: func-operator.v0.0.0 + namespace: placeholder +spec: + apiservicedefinitions: {} + customresourcedefinitions: + owned: + - description: Function is the Schema for the functions API. + displayName: Function + kind: Function + name: functions.functions.dev + version: v1alpha1 + - description: ObjectBucketSource is the Schema for the objectbucketsources API. + displayName: ObjectBucketSource + kind: ObjectBucketSource + name: objectbucketsources.sources.functions.dev + version: v1alpha1 + description: Operator for serverless functions and event sources + displayName: Functions 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: + - faas + - eventing + links: + - name: Func Operator + url: https://func-operator.domain + maintainers: + - email: email@example.com + name: creydr + maturity: alpha + provider: + name: knative + version: 0.0.0 diff --git a/config/combined/func-operator/kustomization.yaml b/config/combined/func-operator/kustomization.yaml new file mode 100644 index 0000000..d9b45f6 --- /dev/null +++ b/config/combined/func-operator/kustomization.yaml @@ -0,0 +1,11 @@ +namePrefix: func-operator- + +resources: +- ../../rbac +- ../../manager +- metrics_service.yaml + +patches: +- path: manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/config/combined/func-operator/manager_metrics_patch.yaml b/config/combined/func-operator/manager_metrics_patch.yaml new file mode 100644 index 0000000..2fd540e --- /dev/null +++ b/config/combined/func-operator/manager_metrics_patch.yaml @@ -0,0 +1,7 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTP +- op: add + path: /spec/template/spec/containers/0/args/- + value: --metrics-bind-address=:8080 +- op: add + path: /spec/template/spec/containers/0/args/- + value: --metrics-secure=false diff --git a/config/combined/func-operator/metrics_service.yaml b/config/combined/func-operator/metrics_service.yaml new file mode 100644 index 0000000..51439be --- /dev/null +++ b/config/combined/func-operator/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: func-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: http + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + control-plane: controller-manager + app.kubernetes.io/name: func-operator diff --git a/config/combined/kustomization.yaml b/config/combined/kustomization.yaml new file mode 100644 index 0000000..bd78bc0 --- /dev/null +++ b/config/combined/kustomization.yaml @@ -0,0 +1,11 @@ +# Combined overlay that deploys both func-operator and objectbucket-notifications-adapter +# in a single namespace. Used for generating a combined OLM bundle. +namespace: func-operator-system + +resources: +# Shared CRDs (included once to avoid duplicates) +- ../crd +# func-operator components +- func-operator +# objectbucket-notifications-adapter components +- source-objectbucket diff --git a/config/combined/manifests/kustomization.yaml b/config/combined/manifests/kustomization.yaml new file mode 100644 index 0000000..a409ef4 --- /dev/null +++ b/config/combined/manifests/kustomization.yaml @@ -0,0 +1,7 @@ +# These resources constitute the fully configured set of manifests +# used to generate the combined 'manifests/' directory in a bundle. +resources: +- ../bases/func-operator.clusterserviceversion.yaml +- .. +- ../../sources/objectbucket/samples +- ../../scorecard diff --git a/config/combined/source-objectbucket/kustomization.yaml b/config/combined/source-objectbucket/kustomization.yaml new file mode 100644 index 0000000..16d82bd --- /dev/null +++ b/config/combined/source-objectbucket/kustomization.yaml @@ -0,0 +1,17 @@ +namePrefix: objectbucket-notifications-adapter- + +resources: +- ../../sources/objectbucket/rbac +- manager.yaml +- service.yaml +- metrics_service.yaml + +patches: +- path: manager_metrics_patch.yaml + target: + kind: Deployment + +images: +- name: controller + newName: quay.io/maschmid/objectbucket-notifications-adapter + newTag: latest diff --git a/config/combined/source-objectbucket/manager.yaml b/config/combined/source-objectbucket/manager.yaml new file mode 100644 index 0000000..fc953bb --- /dev/null +++ b/config/combined/source-objectbucket/manager.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: manager + namespace: system + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest + name: manager + env: + - name: NOOBAA_ADAPTER_ID + value: "mcg-adapter" + - name: NOOBAA_ADAPTER_TOPIC_ARN + value: "mcg-adapter-connection/connect.json" + - name: NOOBAA_ADAPTER_STORAGECLASS_PATTERN + value: ".*noobaa\\.io$" + - name: RADOSGW_ADAPTER_ID + value: "rgw-adapter" + - name: RADOSGW_ADAPTER_TOPIC_ARN + value: "arn:aws:sns:ocs-storagecluster-cephobjectstore::rgw-adapter-notifications" + - name: RADOSGW_ADAPTER_STORAGECLASS_PATTERN + value: ".*ceph-rgw$" + - name: ADAPTER_PORT + value: "8888" + - name: NOTIFICATIONS_MODE + value: "http" + ports: + - containerPort: 8888 + name: notifications + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + volumeMounts: [] + volumes: [] + serviceAccountName: manager + terminationGracePeriodSeconds: 10 diff --git a/config/combined/source-objectbucket/manager_metrics_patch.yaml b/config/combined/source-objectbucket/manager_metrics_patch.yaml new file mode 100644 index 0000000..da3f7f8 --- /dev/null +++ b/config/combined/source-objectbucket/manager_metrics_patch.yaml @@ -0,0 +1,3 @@ +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/config/combined/source-objectbucket/metrics_service.yaml b/config/combined/source-objectbucket/metrics_service.yaml new file mode 100644 index 0000000..20d72c9 --- /dev/null +++ b/config/combined/source-objectbucket/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter diff --git a/config/combined/source-objectbucket/service.yaml b/config/combined/source-objectbucket/service.yaml new file mode 100644 index 0000000..f8f7430 --- /dev/null +++ b/config/combined/source-objectbucket/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: notifications + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize +spec: + selector: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + ports: + - port: 8888 + targetPort: 8888 + protocol: TCP + name: notifications diff --git a/config/crd/bases/sources.functions.dev_objectbucketsources.yaml b/config/crd/bases/sources.functions.dev_objectbucketsources.yaml new file mode 100644 index 0000000..06b0eea --- /dev/null +++ b/config/crd/bases/sources.functions.dev_objectbucketsources.yaml @@ -0,0 +1,140 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: objectbucketsources.sources.functions.dev +spec: + group: sources.functions.dev + names: + kind: ObjectBucketSource + listKind: ObjectBucketSourceList + plural: objectbucketsources + singular: objectbucketsource + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: ObjectBucketSource is the Schema for the objectbucketsources + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ObjectBucketSourceSpec defines the desired state of ObjectBucketSource. + properties: + events: + description: Events is the list of S3 event types to subscribe to + (e.g. "s3:ObjectCreated:*"). + items: + type: string + type: array + objectBucketClaim: + description: ObjectBucketClaim is a reference to the ObjectBucketClaim + in the same namespace. + properties: + name: + description: Name of the ObjectBucketClaim in the same namespace. + type: string + required: + - name + type: object + sink: + description: Sink is the endpoint to dispatch matching CloudEvents + to. + properties: + uri: + description: |- + URI is the destination to send CloudEvents to. Use an HTTP URL for + HTTP delivery, or "kafka:" for Kafka delivery. + type: string + required: + - uri + type: object + required: + - events + - objectBucketClaim + - sink + type: object + status: + description: ObjectBucketSourceStatus defines the observed state of ObjectBucketSource. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 52fb422..9956282 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/functions.dev_functions.yaml +- bases/sources.functions.dev_objectbucketsources.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d3f2546..5f28be4 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -91,6 +91,14 @@ rules: - patch - update - watch +- apiGroups: + - objectbucket.io + resources: + - objectbucketclaims + verbs: + - get + - list + - watch - apiGroups: - rbac.authorization.k8s.io resources: @@ -117,6 +125,32 @@ rules: - patch - update - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/finalizers + verbs: + - update +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/status + verbs: + - get + - patch + - update - apiGroups: - tekton.dev resources: diff --git a/config/sources/objectbucket/default/kustomization.yaml b/config/sources/objectbucket/default/kustomization.yaml new file mode 100644 index 0000000..f9a9394 --- /dev/null +++ b/config/sources/objectbucket/default/kustomization.yaml @@ -0,0 +1,14 @@ +namespace: objectbucket-notifications-adapter-system + +namePrefix: objectbucket-notifications-adapter- + +resources: +- ../../../crd +- ../rbac +- ../manager +- metrics_service.yaml + +patches: +- path: manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/config/sources/objectbucket/default/manager_metrics_patch.yaml b/config/sources/objectbucket/default/manager_metrics_patch.yaml new file mode 100644 index 0000000..da3f7f8 --- /dev/null +++ b/config/sources/objectbucket/default/manager_metrics_patch.yaml @@ -0,0 +1,3 @@ +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/config/sources/objectbucket/default/metrics_service.yaml b/config/sources/objectbucket/default/metrics_service.yaml new file mode 100644 index 0000000..20d72c9 --- /dev/null +++ b/config/sources/objectbucket/default/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter diff --git a/config/sources/objectbucket/kafka/kafka_env_patch.yaml b/config/sources/objectbucket/kafka/kafka_env_patch.yaml new file mode 100644 index 0000000..e7b8063 --- /dev/null +++ b/config/sources/objectbucket/kafka/kafka_env_patch.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: manager +spec: + template: + spec: + containers: + - name: manager + env: + - name: NOTIFICATIONS_MODE + value: "kafka" + - name: KAFKA_BROKERS + value: "my-cluster-kafka-bootstrap.kafka.svc:9095" + - name: KAFKA_SECRET + value: "objectbucket-notifications-adapter-user" + - name: KAFKA_NOTIFICATIONS_TOPIC + value: "mcg-adapter-notifications,rgw-adapter-notifications" + - name: KAFKA_NOTIFICATIONS_GROUP_ID + value: "objectbucket-notifications-adapter" diff --git a/config/sources/objectbucket/kafka/kustomization.yaml b/config/sources/objectbucket/kafka/kustomization.yaml new file mode 100644 index 0000000..1c264e7 --- /dev/null +++ b/config/sources/objectbucket/kafka/kustomization.yaml @@ -0,0 +1,7 @@ +resources: +- ../default + +patches: +- path: kafka_env_patch.yaml + target: + kind: Deployment diff --git a/config/sources/objectbucket/manager/kustomization.yaml b/config/sources/objectbucket/manager/kustomization.yaml new file mode 100644 index 0000000..59b3edc --- /dev/null +++ b/config/sources/objectbucket/manager/kustomization.yaml @@ -0,0 +1,9 @@ +resources: +- manager.yaml +- service.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: quay.io/maschmid/objectbucket-notifications-adapter + newTag: latest diff --git a/config/sources/objectbucket/manager/manager.yaml b/config/sources/objectbucket/manager/manager.yaml new file mode 100644 index 0000000..659cac9 --- /dev/null +++ b/config/sources/objectbucket/manager/manager.yaml @@ -0,0 +1,93 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: manager + namespace: system + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest + name: manager + env: + - name: NOOBAA_ADAPTER_ID + value: "mcg-adapter" + - name: NOOBAA_ADAPTER_TOPIC_ARN + value: "mcg-adapter-connection/connect.json" + - name: NOOBAA_ADAPTER_STORAGECLASS_PATTERN + value: ".*noobaa\\.io$" + - name: RADOSGW_ADAPTER_ID + value: "rgw-adapter" + - name: RADOSGW_ADAPTER_TOPIC_ARN + value: "arn:aws:sns:ocs-storagecluster-cephobjectstore::rgw-adapter-notifications" + - name: RADOSGW_ADAPTER_STORAGECLASS_PATTERN + value: ".*ceph-rgw$" + - name: ADAPTER_PORT + value: "8888" + - name: NOTIFICATIONS_MODE + value: "http" + ports: + - containerPort: 8888 + name: notifications + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + volumeMounts: [] + volumes: [] + serviceAccountName: manager + terminationGracePeriodSeconds: 10 diff --git a/config/sources/objectbucket/manager/service.yaml b/config/sources/objectbucket/manager/service.yaml new file mode 100644 index 0000000..f8f7430 --- /dev/null +++ b/config/sources/objectbucket/manager/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: notifications + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize +spec: + selector: + control-plane: manager + app.kubernetes.io/name: objectbucket-notifications-adapter + ports: + - port: 8888 + targetPort: 8888 + protocol: TCP + name: notifications diff --git a/config/sources/objectbucket/rbac/kustomization.yaml b/config/sources/objectbucket/rbac/kustomization.yaml new file mode 100644 index 0000000..3950530 --- /dev/null +++ b/config/sources/objectbucket/rbac/kustomization.yaml @@ -0,0 +1,12 @@ +resources: +- service_account.yaml +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml +- objectbucketsource_admin_role.yaml +- objectbucketsource_editor_role.yaml +- objectbucketsource_viewer_role.yaml diff --git a/config/sources/objectbucket/rbac/leader_election_role.yaml b/config/sources/objectbucket/rbac/leader_election_role.yaml new file mode 100644 index 0000000..05faa63 --- /dev/null +++ b/config/sources/objectbucket/rbac/leader_election_role.yaml @@ -0,0 +1,39 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/sources/objectbucket/rbac/leader_election_role_binding.yaml b/config/sources/objectbucket/rbac/leader_election_role_binding.yaml new file mode 100644 index 0000000..ac99b12 --- /dev/null +++ b/config/sources/objectbucket/rbac/leader_election_role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: manager + namespace: system diff --git a/config/sources/objectbucket/rbac/metrics_auth_role.yaml b/config/sources/objectbucket/rbac/metrics_auth_role.yaml new file mode 100644 index 0000000..32d2e4e --- /dev/null +++ b/config/sources/objectbucket/rbac/metrics_auth_role.yaml @@ -0,0 +1,17 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-auth-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/config/sources/objectbucket/rbac/metrics_auth_role_binding.yaml b/config/sources/objectbucket/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 0000000..8ae56e0 --- /dev/null +++ b/config/sources/objectbucket/rbac/metrics_auth_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-auth-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics-auth-role +subjects: +- kind: ServiceAccount + name: manager + namespace: system diff --git a/config/sources/objectbucket/rbac/metrics_reader_role.yaml b/config/sources/objectbucket/rbac/metrics_reader_role.yaml new file mode 100644 index 0000000..51a75db --- /dev/null +++ b/config/sources/objectbucket/rbac/metrics_reader_role.yaml @@ -0,0 +1,9 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/config/sources/objectbucket/rbac/objectbucketsource_admin_role.yaml b/config/sources/objectbucket/rbac/objectbucketsource_admin_role.yaml new file mode 100644 index 0000000..02176a2 --- /dev/null +++ b/config/sources/objectbucket/rbac/objectbucketsource_admin_role.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: objectbucketsource-admin-role +rules: +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources + verbs: + - '*' +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/status + verbs: + - get diff --git a/config/sources/objectbucket/rbac/objectbucketsource_editor_role.yaml b/config/sources/objectbucket/rbac/objectbucketsource_editor_role.yaml new file mode 100644 index 0000000..3ef439c --- /dev/null +++ b/config/sources/objectbucket/rbac/objectbucketsource_editor_role.yaml @@ -0,0 +1,26 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: objectbucketsource-editor-role +rules: +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/status + verbs: + - get diff --git a/config/sources/objectbucket/rbac/objectbucketsource_viewer_role.yaml b/config/sources/objectbucket/rbac/objectbucketsource_viewer_role.yaml new file mode 100644 index 0000000..d234542 --- /dev/null +++ b/config/sources/objectbucket/rbac/objectbucketsource_viewer_role.yaml @@ -0,0 +1,22 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: objectbucketsource-viewer-role +rules: +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources + verbs: + - get + - list + - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/status + verbs: + - get diff --git a/config/sources/objectbucket/rbac/role.yaml b/config/sources/objectbucket/rbac/role.yaml new file mode 100644 index 0000000..c57dba4 --- /dev/null +++ b/config/sources/objectbucket/rbac/role.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - objectbucket.io + resources: + - objectbucketclaims + verbs: + - get + - list + - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/finalizers + verbs: + - update +- apiGroups: + - sources.functions.dev + resources: + - objectbucketsources/status + verbs: + - get + - patch + - update diff --git a/config/sources/objectbucket/rbac/role_binding.yaml b/config/sources/objectbucket/rbac/role_binding.yaml new file mode 100644 index 0000000..89c2385 --- /dev/null +++ b/config/sources/objectbucket/rbac/role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: manager + namespace: system diff --git a/config/sources/objectbucket/rbac/service_account.yaml b/config/sources/objectbucket/rbac/service_account.yaml new file mode 100644 index 0000000..09d9f13 --- /dev/null +++ b/config/sources/objectbucket/rbac/service_account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: manager + namespace: system diff --git a/config/sources/objectbucket/samples/kustomization.yaml b/config/sources/objectbucket/samples/kustomization.yaml new file mode 100644 index 0000000..51e8acc --- /dev/null +++ b/config/sources/objectbucket/samples/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- sources_v1alpha1_objectbucketsource.yaml diff --git a/config/sources/objectbucket/samples/sources_v1alpha1_objectbucketsource.yaml b/config/sources/objectbucket/samples/sources_v1alpha1_objectbucketsource.yaml new file mode 100644 index 0000000..b57b91a --- /dev/null +++ b/config/sources/objectbucket/samples/sources_v1alpha1_objectbucketsource.yaml @@ -0,0 +1,14 @@ +apiVersion: sources.functions.dev/v1alpha1 +kind: ObjectBucketSource +metadata: + labels: + app.kubernetes.io/name: objectbucket-notifications-adapter + app.kubernetes.io/managed-by: kustomize + name: objectbucketsource-sample +spec: + objectBucketClaim: + name: foo-bucket + events: + - "s3:ObjectCreated:*" + sink: + uri: http://foo.default.svc.cluster.local diff --git a/go.mod b/go.mod index aed5542..17e5eaa 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,19 @@ go 1.26.0 require ( code.gitea.io/sdk/gitea v0.25.1 + github.com/IBM/sarama v1.50.3 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 + github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/go-git/go-git/v6 v6.0.0-alpha.4 github.com/go-logr/logr v1.4.4 + github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.24.1 github.com/stretchr/testify v1.11.1 + github.com/xdg-go/scram v1.2.0 golang.org/x/crypto v0.54.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.36.3 @@ -29,12 +36,20 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect - github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/coreos/go-semver v0.3.1 // indirect @@ -44,6 +59,7 @@ require ( github.com/docker/cli v29.3.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/eapache/go-resiliency v1.7.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -79,12 +95,17 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.21.3 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.19.1 // indirect @@ -95,11 +116,13 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v1.20.99 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.2 // indirect @@ -109,6 +132,8 @@ require ( github.com/vbatts/tar-split v0.12.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect diff --git a/go.sum b/go.sum index 25309a6..ad4a037 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/IBM/sarama v1.50.3 h1:zpY2iZYmt+z+0Bo3aYF+cD48OBt2hIgiDPZUuZKTXcc= +github.com/IBM/sarama v1.50.3/go.mod h1:Jo4MSfdDT3ycmQj7/ab8eLZwnvwCKZm/8H7SCbtyo8U= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -27,6 +29,30 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -90,6 +116,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= +github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -202,12 +230,17 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= @@ -216,6 +249,18 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -298,6 +343,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -313,6 +360,8 @@ github.com/prometheus/common v1.20.99 h1:vZEybF3CT0t6L0UjsOtHRML7vuIglHocmvJMMH/ github.com/prometheus/common v1.20.99/go.mod h1:VX44Tebe4qpuTK+MQWg25h4fJGKBqzObSdxuB7y8K/Y= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rickb777/date v1.20.2 h1:CUpAaa4ksqvcRaidSgwzK7zeO2wUG5/VGy6Zlfcu/d4= github.com/rickb777/date v1.20.2/go.mod h1:PVaM/Zn0IOzjm1uj84Eh9NJ/imtQSm1SVKtOvIunaYw= github.com/rickb777/plural v1.4.1 h1:5MMLcbIaapLFmvDGRT5iPk8877hpTPt8Y9cdSKRw9sU= @@ -345,11 +394,16 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -370,8 +424,15 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= @@ -405,20 +466,30 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -428,22 +499,33 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -469,6 +551,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/hack/noobaa-connection-setup.sh b/hack/noobaa-connection-setup.sh new file mode 100755 index 0000000..0cfc55e --- /dev/null +++ b/hack/noobaa-connection-setup.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# NooBaa Connection setup with defaults +# +# Prerequisites: OpenShift Data Foundation installed in `openshift-storage` +# +# before running this secript, install the CRD and the objectbucket-notifications-adapter from this repo: +# make install +# make deploy IMG=/objectbucket-notifications-adapter:tag + +oc create secret generic mcg-adapter-connection \ + --from-file=connect.json=/dev/stdin -n openshift-storage </objectbucket-notifications-adapter:tag + +set -Eeuxo pipefail + +mcg_adapter_topic="mcg-adapter-notifications" +mcg_adapter_namespace="objectbucket-notifications-adapter-system" +kafka_namespace="kafka" +kafka_cluster="my-cluster" +connection_name="mcg-adapter-connection" + +# --- Strimzi KafkaTopic and KafkaUsers --- + +cat < "$tmp/connect.json" </dev/null || true +oc create secret generic "${connection_name}" \ + --from-file="$tmp/connect.json" -n openshift-storage +rm -rf "$tmp" + +# --- Patch NooBaa CR --- + +existing_connections=$(oc get noobaa noobaa -n openshift-storage -o json \ + | jq -c '.spec.bucketNotifications.connections // []') + +updated_connections=$(echo "$existing_connections" | jq -c \ + --arg name "${connection_name}" \ + '[.[] | select(.name != $name)] + [{"name": $name, "namespace": "openshift-storage"}]') + +oc patch noobaa noobaa --type='merge' -n openshift-storage -p '{ + "spec": { + "bucketNotifications": { + "connections": '"${updated_connections}"', + "enabled": true + } + } +}' + +# --- objectbucket-notifications-adapter Kafka credentials secret --- +# Copy the objectbucket-notifications-adapter-user password from the Strimzi-managed secret in the +# kafka namespace into a secret in the adapter namespace, using the format +# expected by the adapter (see kafka-secret-format.md). + +oc create namespace "${mcg_adapter_namespace}" 2>/dev/null || true + +adapter_password=$(oc get secret -n "${kafka_namespace}" objectbucket-notifications-adapter-user \ + -o jsonpath='{.data.password}' | base64 --decode) + +oc delete secret -n "${mcg_adapter_namespace}" objectbucket-notifications-adapter-user 2>/dev/null || true +oc create secret generic objectbucket-notifications-adapter-user -n "${mcg_adapter_namespace}" \ + --from-literal=protocol=SASL_PLAINTEXT \ + --from-literal=sasl.mechanism=SCRAM-SHA-512 \ + --from-literal=user=objectbucket-notifications-adapter-user \ + --from-literal=password="${adapter_password}" diff --git a/hack/rook-kafka-connection-setup.sh b/hack/rook-kafka-connection-setup.sh new file mode 100755 index 0000000..692ded1 --- /dev/null +++ b/hack/rook-kafka-connection-setup.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +# Ceph/Rook Kafka Connection setup with defaults +# +# Prerequisites: +# - OpenShift Data Foundation installed in `openshift-storage` +# - Strimzi Kafka cluster `my-cluster` running in `kafka` namespace +# +# Before running this script, install the CRD and the objectbucket-notifications-adapter from this repo: +# make install +# make deploy-kafka IMG=/objectbucket-notifications-adapter:tag + +set -Eeuxo pipefail + +rgw_adapter_topic="rgw-adapter-notifications" +rgw_adapter_namespace="objectbucket-notifications-adapter-system" +kafka_namespace="kafka" +kafka_cluster="my-cluster" +notification_name="objectbucket-notifications-adapter" +notification_kafka_user="rgw-objectbucket-notifications-adapter" + +# --- Strimzi KafkaTopic and KafkaUsers --- + +cat <= 4.0.0 because of https://github.com/confluentinc/librdkafka/pull/4895 , rhel9 image has librdkafka-1.6.1-102.el9.x86_64 , which doesn't have the fix +#cat <Kafka connection secret --- +# strimzi_crt=$(oc -n "${kafka_namespace}" get secret my-cluster-cluster-ca-cert --template='{{index .data "ca.crt"}}' | base64 --decode ) + +#rgw_kafka_cacrt=$(oc -n "${kafka_namespace}" get secret "${notification_kafka_user}" --template='{{index .data "ca.crt"}}' | base64 --decode ) +#rgw_kafka_usercrt=$(oc -n "${kafka_namespace}" get secret "${notification_kafka_user}" --template='{{index .data "user.crt"}}' | base64 --decode ) +#rgw_kafka_userkey=$(oc -n "${kafka_namespace}" get secret "${notification_kafka_user}" --template='{{index .data "user.key"}}' | base64 --decode ) + +#rgw_kafka_username="${notification_kafka_user}" +#rgw_kafka_password=$(oc -n "${kafka_namespace}" get secret "${notification_kafka_user}" --template='{{index .data "password"}}' | base64 --decode ) + +# CA secret +#oc delete secret --namespace openshift-storage "${notification_kafka_user}-kafka" 2>/dev/null || true +#oc create secret --namespace openshift-storage generic "${notification_kafka_user}-kafka" \ +# --from-literal=ca.crt="${rgw_kafka_cacrt}" \ +# --from-literal=user.crt="${rgw_kafka_usercrt}" \ +# --from-literal=user.key="${rgw_kafka_userkey}" + +# Patch the cephObjectStores reconcileStrategy to Init so that it allows us adding the additionalVolumeMounts +#oc patch storagecluster ocs-storagecluster -n openshift-storage \ +# --type=merge \ +# -p '{"spec":{"managedResources":{"cephObjectStores":{"reconcileStrategy":"init"}}}}' + +#echo "Sleeping for 10s to let the StorageCluster update the reconcileStrategy" +#sleep 10 + +# Mount the Kafka Secret +#cat </dev/null || true +#oc create secret --namespace openshift-storage generic "${notification_kafka_user}" \ +# --from-literal=user="${notification_kafka_user}" \ +# --from-literal=password="${rgw_kafka_password}" + +# Create a CephObjectStoreUser so we have credentials for the CreateTopic command +cat </dev/null) +ROOK_SECRET_KEY=$(oc extract secret/$topicAdminSecretName -n openshift-storage --keys=SecretKey --to=- 2>/dev/null) + +S3_ENDPOINT=https://$(oc get route ocs-storagecluster-cephobjectstore-secure -n openshift-storage -o json | jq -r ".spec.host") +aws_alias() { + AWS_ACCESS_KEY_ID=$ROOK_ACCESS_KEY AWS_SECRET_ACCESS_KEY=$ROOK_SECRET_KEY aws --endpoint "$S3_ENDPOINT" --no-verify-ssl "$@" +} + +# TODO: cannot use SCRAM-SHA-512 because of https://github.com/confluentinc/librdkafka/pull/4895 , rhel9 image has librdkafka-1.6.1-102.el9.x86_64 , which doesn't have the fix +#aws_alias sns create-topic \ +# --region default \ +# --name "${rgw_adapter_topic}" \ +# --attributes '{ +# "push-endpoint": "kafka://'${notification_kafka_user}:${rgw_kafka_password}@${kafka_cluster}'-kafka-bootstrap.'${kafka_namespace}.svc:9094'", +# "use-ssl": "true", +# "ca-location": "/var/rgw/kafka-ca/ca.crt", +# "mechanism": "SCRAM-SHA-512", +# "kafka-ack-level": "broker" +#}' + +# TODO: Cannot use tls either, as ceph v20.1.0 does not yet implement mTLS (user cert/key) +#aws_alias sns create-topic \ +# --region default \ +# --name "${rgw_adapter_topic}" \ +# --attributes '{ +# "push-endpoint": "kafka://'${kafka_cluster}'-kafka-bootstrap.'${kafka_namespace}.svc:9093'", +# "use-ssl": "true", +# "ca-location": "/var/rgw/'${notification_kafka_user}-kafka'/ca.crt", +# "kafka-ack-level": "broker" +#}' + +# TODO: So instead relying on an anonymous admin user +aws_alias sns create-topic \ + --region default \ + --name "${rgw_adapter_topic}" \ + --attributes '{ + "push-endpoint": "kafka://'${kafka_cluster}'-kafka-bootstrap.'${kafka_namespace}.svc:9092'", + "use-ssl": "false", + "kafka-ack-level": "broker" +}' + +#aws_alias sns create-topic \ +# --region default \ +# --name "${rgw_adapter_topic}" \ +# --attributes '{ +# "push-endpoint": "kafka://'${kafka_cluster}'-kafka-bootstrap.'${kafka_namespace}.svc:9092'", +# "use-ssl": "false", +# "kafka-ack-level": "broker" +#}' + +#cat </dev/null || true + +adapter_password=$(oc get secret -n "${kafka_namespace}" objectbucket-notifications-adapter-user \ + -o jsonpath='{.data.password}' | base64 --decode) + +oc delete secret -n "${rgw_adapter_namespace}" objectbucket-notifications-adapter-user 2>/dev/null || true +oc create secret generic objectbucket-notifications-adapter-user -n "${rgw_adapter_namespace}" \ + --from-literal=protocol=SASL_PLAINTEXT \ + --from-literal=sasl.mechanism=SCRAM-SHA-512 \ + --from-literal=user=objectbucket-notifications-adapter-user \ + --from-literal=password="${adapter_password}" + + diff --git a/internal/objectbucketsource/cloudevents/dispatch.go b/internal/objectbucketsource/cloudevents/dispatch.go new file mode 100644 index 0000000..58cf081 --- /dev/null +++ b/internal/objectbucketsource/cloudevents/dispatch.go @@ -0,0 +1,77 @@ +package cloudevents + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/IBM/sarama" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cloudevents/sdk-go/v2/protocol/http" + "github.com/google/uuid" +) + +type recordsPayload struct { + Records []json.RawMessage `json:"Records"` +} + +func newCloudEvent(bucketName string, records []json.RawMessage) (cloudevents.Event, error) { + payload := recordsPayload{Records: records} + + e := cloudevents.NewEvent() + e.SetType("com.noobaa.s3.notification") + e.SetSource("noobaa/" + bucketName) + e.SetID(uuid.New().String()) + e.SetTime(time.Now()) + if err := e.SetData(cloudevents.ApplicationJSON, payload); err != nil { + return e, fmt.Errorf("setting event data: %w", err) + } + return e, nil +} + +func DispatchEvent(ctx context.Context, targetURI string, bucketName string, records []json.RawMessage) error { + e, err := newCloudEvent(bucketName, records) + if err != nil { + return err + } + + c, err := cloudevents.NewClientHTTP(http.WithTarget(targetURI)) + if err != nil { + return fmt.Errorf("creating cloudevent client for %s: %w", targetURI, err) + } + + ctx = cloudevents.WithEncodingStructured(ctx) + result := c.Send(ctx, e) + if cloudevents.IsUndelivered(result) { + return fmt.Errorf("failed to send CloudEvent to %s: %v", targetURI, result) + } + return nil +} + +func NewKafkaProducer(brokers []string, config *sarama.Config) (sarama.SyncProducer, error) { + return sarama.NewSyncProducer(brokers, config) +} + +func DispatchEventToKafka(ctx context.Context, producer sarama.SyncProducer, topic string, bucketName string, records []json.RawMessage) error { + e, err := newCloudEvent(bucketName, records) + if err != nil { + return err + } + + eventJSON, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("marshaling cloud event: %w", err) + } + + msg := &sarama.ProducerMessage{ + Topic: topic, + Key: sarama.StringEncoder(bucketName), + Value: sarama.ByteEncoder(eventJSON), + } + + if _, _, err := producer.SendMessage(msg); err != nil { + return fmt.Errorf("sending message to kafka topic %s: %w", topic, err) + } + return nil +} diff --git a/internal/objectbucketsource/cloudevents/dispatch_test.go b/internal/objectbucketsource/cloudevents/dispatch_test.go new file mode 100644 index 0000000..2bc9931 --- /dev/null +++ b/internal/objectbucketsource/cloudevents/dispatch_test.go @@ -0,0 +1,52 @@ +package cloudevents + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDispatchEvent(t *testing.T) { + var gotHeaders http.Header + var gotBody []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + var err error + gotBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + records := []json.RawMessage{ + json.RawMessage(`{"eventName":"s3:ObjectCreated:Put","s3":{"bucket":{"name":"my-bucket"},"object":{"key":"my-key"}}}`), + } + + err := DispatchEvent(context.Background(), srv.URL, "my-bucket", records) + if err != nil { + t.Fatalf("DispatchEvent returned error: %v", err) + } + + t.Logf("=== Headers ===") + for k, vals := range gotHeaders { + for _, v := range vals { + t.Logf(" %s: %s", k, v) + } + } + + t.Logf("=== Body (raw) ===") + t.Logf("%s", gotBody) + + t.Logf("=== Body (pretty) ===") + var pretty json.RawMessage + if err := json.Unmarshal(gotBody, &pretty); err == nil { + formatted, _ := json.MarshalIndent(pretty, " ", " ") + t.Logf("%s", formatted) + } +} diff --git a/internal/objectbucketsource/controller/objectbucketsource_controller.go b/internal/objectbucketsource/controller/objectbucketsource_controller.go new file mode 100644 index 0000000..1a1bde6 --- /dev/null +++ b/internal/objectbucketsource/controller/objectbucketsource_controller.go @@ -0,0 +1,312 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "regexp" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + sourcesv1alpha1 "github.com/functions-dev/func-operator/api/sources/v1alpha1" + "github.com/functions-dev/func-operator/internal/objectbucketsource/s3client" +) + +// AdapterConfig holds the notification configuration for a specific storage backend. +type AdapterConfig struct { + ID string + Topic string + StorageClassPattern *regexp.Regexp +} + +var obcGVR = schema.GroupVersionResource{ + Group: "objectbucket.io", + Version: "v1alpha1", + Resource: "objectbucketclaims", +} + +// ObjectBucketSourceReconciler reconciles an ObjectBucketSource object +type ObjectBucketSourceReconciler struct { + client.Client + Scheme *runtime.Scheme + AdapterConfigs []AdapterConfig +} + +// +kubebuilder:rbac:groups=sources.functions.dev,resources=objectbucketsources,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=sources.functions.dev,resources=objectbucketsources/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=sources.functions.dev,resources=objectbucketsources/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups=objectbucket.io,resources=objectbucketclaims,verbs=get;list;watch + +func (r *ObjectBucketSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + var source sourcesv1alpha1.ObjectBucketSource + if err := r.Get(ctx, req.NamespacedName, &source); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !source.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, &source) + } + + if !controllerutil.ContainsFinalizer(&source, sourcesv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(&source, sourcesv1alpha1.FinalizerName) + if err := r.Update(ctx, &source); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + + obcName := source.Spec.ObjectBucketClaim.Name + ns := source.Namespace + + adapterCfg, err := r.resolveAdapterConfig(ctx, ns, obcName) + if err != nil { + log.Info("cannot resolve adapter config for OBC, requeuing", "obc", obcName, "error", err) + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionOBCCredentialsAvailable, metav1.ConditionFalse, "OBCNotReady", err.Error()) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + bucketHost, bucketName, bucketPort, err := r.readOBCConfigMap(ctx, ns, obcName) + if err != nil { + log.Info("OBC ConfigMap not available, requeuing", "obc", obcName, "error", err) + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionOBCCredentialsAvailable, metav1.ConditionFalse, "ConfigMapNotReady", err.Error()) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + accessKey, secretKey, err := r.readOBCSecret(ctx, ns, obcName) + if err != nil { + log.Info("OBC Secret not available, requeuing", "obc", obcName, "error", err) + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionOBCCredentialsAvailable, metav1.ConditionFalse, "SecretNotReady", err.Error()) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionOBCCredentialsAvailable, metav1.ConditionTrue, "CredentialsAvailable", "OBC ConfigMap and Secret are available") + + mergedEvents, err := r.computeMergedEvents(ctx, ns, obcName) + if err != nil { + return ctrl.Result{}, fmt.Errorf("computing merged events: %w", err) + } + + endpoint := fmt.Sprintf("https://%s:%s", bucketHost, bucketPort) + s3c := s3client.NewS3Client(endpoint, accessKey, secretKey) + + if err := s3client.PutBucketNotification(ctx, s3c, bucketName, adapterCfg.ID, adapterCfg.Topic, mergedEvents); err != nil { + log.Error(err, "failed to set bucket notification", "bucket", bucketName) + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionBucketNotificationSet, metav1.ConditionFalse, "PutNotificationFailed", err.Error()) + return ctrl.Result{}, err + } + + log.Info("bucket notification set", "bucket", bucketName, "events", mergedEvents, "adapterID", adapterCfg.ID) + r.setCondition(ctx, &source, sourcesv1alpha1.ConditionBucketNotificationSet, metav1.ConditionTrue, "NotificationConfigured", "Bucket notification configured successfully") + + return ctrl.Result{}, nil +} + +func (r *ObjectBucketSourceReconciler) reconcileDelete(ctx context.Context, source *sourcesv1alpha1.ObjectBucketSource) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(source, sourcesv1alpha1.FinalizerName) { + return ctrl.Result{}, nil + } + + obcName := source.Spec.ObjectBucketClaim.Name + ns := source.Namespace + + adapterCfg, cfgErr := r.resolveAdapterConfig(ctx, ns, obcName) + if cfgErr != nil { + log.Info("cannot resolve adapter config during deletion, removing finalizer anyway", "obc", obcName, "error", cfgErr) + } + + bucketHost, bucketName, bucketPort, err := r.readOBCConfigMap(ctx, ns, obcName) + if err != nil { + log.Info("OBC ConfigMap not available during deletion, removing finalizer anyway", "obc", obcName) + } else if cfgErr != nil { + log.Info("adapter config not available during deletion, removing finalizer anyway", "obc", obcName) + } else { + accessKey, secretKey, secretErr := r.readOBCSecret(ctx, ns, obcName) + if secretErr != nil { + log.Info("OBC Secret not available during deletion, removing finalizer anyway", "obc", obcName) + } else { + mergedEvents, mergeErr := r.computeMergedEventsExcluding(ctx, ns, obcName, source.Name) + if mergeErr != nil { + log.Error(mergeErr, "computing merged events during deletion") + } else { + endpoint := fmt.Sprintf("https://%s:%s", bucketHost, bucketPort) + s3c := s3client.NewS3Client(endpoint, accessKey, secretKey) + + if len(mergedEvents) == 0 { + if err := s3client.RemoveBucketNotification(ctx, s3c, bucketName); err != nil { + log.Error(err, "removing bucket notification during deletion", "bucket", bucketName) + } else { + log.Info("removed bucket notification", "bucket", bucketName) + } + } else { + if err := s3client.PutBucketNotification(ctx, s3c, bucketName, adapterCfg.ID, adapterCfg.Topic, mergedEvents); err != nil { + log.Error(err, "updating bucket notification during deletion", "bucket", bucketName) + } else { + log.Info("updated bucket notification after deletion", "bucket", bucketName, "events", mergedEvents) + } + } + } + } + } + + controllerutil.RemoveFinalizer(source, sourcesv1alpha1.FinalizerName) + if err := r.Update(ctx, source); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *ObjectBucketSourceReconciler) readOBCConfigMap(ctx context.Context, namespace, name string) (host, bucketName, port string, err error) { + var cm corev1.ConfigMap + if err = r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &cm); err != nil { + return "", "", "", fmt.Errorf("getting ConfigMap %s/%s: %w", namespace, name, err) + } + + host = cm.Data["BUCKET_HOST"] + bucketName = cm.Data["BUCKET_NAME"] + port = cm.Data["BUCKET_PORT"] + + if host == "" || bucketName == "" || port == "" { + return "", "", "", fmt.Errorf("ConfigMap %s/%s missing required keys (BUCKET_HOST, BUCKET_NAME, BUCKET_PORT)", namespace, name) + } + return host, bucketName, port, nil +} + +func (r *ObjectBucketSourceReconciler) readOBCSecret(ctx context.Context, namespace, name string) (accessKey, secretKey string, err error) { + var secret corev1.Secret + if err = r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &secret); err != nil { + return "", "", fmt.Errorf("getting Secret %s/%s: %w", namespace, name, err) + } + + accessKey = string(secret.Data["AWS_ACCESS_KEY_ID"]) + secretKey = string(secret.Data["AWS_SECRET_ACCESS_KEY"]) + + if accessKey == "" || secretKey == "" { + return "", "", fmt.Errorf("secret %s/%s missing required keys (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)", namespace, name) + } + return accessKey, secretKey, nil +} + +func (r *ObjectBucketSourceReconciler) computeMergedEvents(ctx context.Context, namespace, obcName string) ([]string, error) { + return r.computeMergedEventsExcluding(ctx, namespace, obcName, "") +} + +func (r *ObjectBucketSourceReconciler) computeMergedEventsExcluding(ctx context.Context, namespace, obcName, excludeName string) ([]string, error) { + var list sourcesv1alpha1.ObjectBucketSourceList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + + eventSet := make(map[string]struct{}) + for _, t := range list.Items { + if t.Spec.ObjectBucketClaim.Name != obcName { + continue + } + if excludeName != "" && t.Name == excludeName { + continue + } + if !t.DeletionTimestamp.IsZero() && t.Name != excludeName { + continue + } + for _, e := range t.Spec.Events { + eventSet[e] = struct{}{} + } + } + + events := make([]string, 0, len(eventSet)) + for e := range eventSet { + events = append(events, e) + } + return events, nil +} + +func (r *ObjectBucketSourceReconciler) readOBCStorageClassName(ctx context.Context, namespace, name string) (string, error) { + var obc unstructured.Unstructured + obc.SetGroupVersionKind(schema.GroupVersionKind{ + Group: obcGVR.Group, + Version: obcGVR.Version, + Kind: "ObjectBucketClaim", + }) + if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &obc); err != nil { + return "", fmt.Errorf("getting ObjectBucketClaim %s/%s: %w", namespace, name, err) + } + sc, _, _ := unstructured.NestedString(obc.Object, "spec", "storageClassName") + return sc, nil +} + +func (r *ObjectBucketSourceReconciler) resolveAdapterConfig(ctx context.Context, namespace, obcName string) (*AdapterConfig, error) { + if len(r.AdapterConfigs) == 0 { + return nil, fmt.Errorf("no adapter configs defined") + } + if len(r.AdapterConfigs) == 1 { + return &r.AdapterConfigs[0], nil + } + storageClass, err := r.readOBCStorageClassName(ctx, namespace, obcName) + if err != nil { + return nil, err + } + for i := range r.AdapterConfigs { + cfg := &r.AdapterConfigs[i] + if cfg.StorageClassPattern != nil && cfg.StorageClassPattern.MatchString(storageClass) { + return cfg, nil + } + } + return nil, fmt.Errorf("no adapter config matches storageClassName %q for OBC %s/%s", storageClass, namespace, obcName) +} + +func (r *ObjectBucketSourceReconciler) setCondition(ctx context.Context, source *sourcesv1alpha1.ObjectBucketSource, condType string, status metav1.ConditionStatus, reason, message string) { + meta.SetStatusCondition(&source.Status.Conditions, metav1.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: source.Generation, + }) + if err := r.Status().Update(ctx, source); err != nil { + logf.FromContext(ctx).Error(err, "updating status condition", "type", condType) + } +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ObjectBucketSourceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&sourcesv1alpha1.ObjectBucketSource{}). + Named("objectbucketsource"). + Complete(r) +} diff --git a/internal/objectbucketsource/controller/objectbucketsource_controller_test.go b/internal/objectbucketsource/controller/objectbucketsource_controller_test.go new file mode 100644 index 0000000..d0bf536 --- /dev/null +++ b/internal/objectbucketsource/controller/objectbucketsource_controller_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sourcesv1alpha1 "github.com/functions-dev/func-operator/api/sources/v1alpha1" +) + +var _ = Describe("ObjectBucketSource Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + objectBucketSource := &sourcesv1alpha1.ObjectBucketSource{} + + BeforeEach(func() { + By("creating the custom resource for the Kind ObjectBucketSource") + err := k8sClient.Get(ctx, typeNamespacedName, objectBucketSource) + if err != nil && errors.IsNotFound(err) { + resource := &sourcesv1alpha1.ObjectBucketSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: sourcesv1alpha1.ObjectBucketSourceSpec{ + ObjectBucketClaim: sourcesv1alpha1.OBCReference{ + Name: "test-obc", + }, + Events: []string{"s3:ObjectCreated:*"}, + Sink: sourcesv1alpha1.SinkSpec{ + URI: "http://localhost:8080", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &sourcesv1alpha1.ObjectBucketSource{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance ObjectBucketSource") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &ObjectBucketSourceReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/internal/objectbucketsource/controller/suite_test.go b/internal/objectbucketsource/controller/suite_test.go new file mode 100644 index 0000000..42881a6 --- /dev/null +++ b/internal/objectbucketsource/controller/suite_test.go @@ -0,0 +1,116 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + sourcesv1alpha1 "github.com/functions-dev/func-operator/api/sources/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = sourcesv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/objectbucketsource/eventmatch/match.go b/internal/objectbucketsource/eventmatch/match.go new file mode 100644 index 0000000..700bbc0 --- /dev/null +++ b/internal/objectbucketsource/eventmatch/match.go @@ -0,0 +1,16 @@ +package eventmatch + +import "strings" + +// MatchEvent checks if an incoming event name (without "s3:" prefix, e.g. "ObjectCreated:Put") +// matches a subscription pattern (with "s3:" prefix, e.g. "s3:ObjectCreated:*"). +func MatchEvent(subscriptionPattern string, incomingEventName string) bool { + pattern := strings.TrimPrefix(subscriptionPattern, "s3:") + + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.HasPrefix(incomingEventName, prefix) + } + + return pattern == incomingEventName +} diff --git a/internal/objectbucketsource/kafka/config.go b/internal/objectbucketsource/kafka/config.go new file mode 100644 index 0000000..d2a35cb --- /dev/null +++ b/internal/objectbucketsource/kafka/config.go @@ -0,0 +1,147 @@ +package kafka + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "strings" + + "github.com/IBM/sarama" + "github.com/xdg-go/scram" +) + +const ( + SASLMechanismPlain = "PLAIN" + SASLMechanismSHA256 = "SCRAM-SHA-256" + SASLMechanismSHA512 = "SCRAM-SHA-512" +) + +func NewConfig(secretData map[string][]byte) (*sarama.Config, error) { + config := sarama.NewConfig() + config.Producer.Return.Successes = true + + if len(secretData) == 0 { + return config, nil + } + + protocol := strings.TrimSpace(string(secretData["protocol"])) + if protocol == "" { + return nil, fmt.Errorf("kafka secret missing required key \"protocol\"") + } + + switch protocol { + case "PLAINTEXT": + return config, nil + case "SASL_PLAINTEXT": + if err := configureSASL(config, secretData); err != nil { + return nil, err + } + case "SSL": + if err := configureTLS(config, secretData, true); err != nil { + return nil, err + } + case "SASL_SSL": + if err := configureSASL(config, secretData); err != nil { + return nil, err + } + if err := configureTLS(config, secretData, false); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported kafka protocol %q", protocol) + } + + return config, nil +} + +func configureSASL(config *sarama.Config, data map[string][]byte) error { + user := string(data["user"]) + password := string(data["password"]) + if user == "" || password == "" { + return fmt.Errorf("kafka SASL requires \"user\" and \"password\" keys") + } + + mechanism := strings.TrimSpace(string(data["sasl.mechanism"])) + if mechanism == "" { + mechanism = SASLMechanismPlain + } + + config.Net.SASL.Enable = true + config.Net.SASL.User = user + config.Net.SASL.Password = password + + switch mechanism { + case SASLMechanismPlain: + config.Net.SASL.Mechanism = sarama.SASLTypePlaintext + case SASLMechanismSHA256: + config.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256 + config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &scramClient{HashGeneratorFcn: scram.SHA256} + } + case SASLMechanismSHA512: + config.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512 + config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &scramClient{HashGeneratorFcn: scram.SHA512} + } + default: + return fmt.Errorf("unsupported SASL mechanism %q", mechanism) + } + + return nil +} + +func configureTLS(config *sarama.Config, data map[string][]byte, requireClientCert bool) error { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if caCert, ok := data["ca.crt"]; ok && len(caCert) > 0 { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return fmt.Errorf("failed to parse ca.crt") + } + tlsConfig.RootCAs = pool + } + + if requireClientCert { + skip := strings.TrimSpace(string(data["user.skip"])) == "true" + if !skip { + certPEM, hasCert := data["user.crt"] + keyPEM, hasKey := data["user.key"] + if !hasCert || !hasKey { + return fmt.Errorf("kafka SSL requires \"user.crt\" and \"user.key\" (or set \"user.skip\" to \"true\")") + } + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return fmt.Errorf("parsing client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + } + + config.Net.TLS.Enable = true + config.Net.TLS.Config = tlsConfig + return nil +} + +type scramClient struct { + *scram.ClientConversation + scram.HashGeneratorFcn +} + +func (c *scramClient) Begin(userName, password, authzID string) error { + client, err := c.NewClient(userName, password, authzID) + if err != nil { + return err + } + c.ClientConversation = client.NewConversation() + return nil +} + +func (c *scramClient) Step(challenge string) (string, error) { + return c.ClientConversation.Step(challenge) +} + +func (c *scramClient) Done() bool { + return c.ClientConversation.Done() +} diff --git a/internal/objectbucketsource/kafka/config_test.go b/internal/objectbucketsource/kafka/config_test.go new file mode 100644 index 0000000..65c143d --- /dev/null +++ b/internal/objectbucketsource/kafka/config_test.go @@ -0,0 +1,291 @@ +package kafka + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/IBM/sarama" +) + +const ( + keyProtocol = "protocol" + keyUser = "user" + keyPassword = "password" + keyCACrt = "ca.crt" +) + +func TestNewConfig_NilData(t *testing.T) { + cfg, err := NewConfig(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Net.TLS.Enable { + t.Error("TLS should be disabled") + } + if cfg.Net.SASL.Enable { + t.Error("SASL should be disabled") + } + if !cfg.Producer.Return.Successes { + t.Error("Producer.Return.Successes should be true") + } +} + +func TestNewConfig_MissingProtocol(t *testing.T) { + _, err := NewConfig(map[string][]byte{keyUser: []byte("x")}) + if err == nil { + t.Fatal("expected error for missing protocol") + } +} + +func TestNewConfig_Plaintext(t *testing.T) { + cfg, err := NewConfig(map[string][]byte{keyProtocol: []byte("PLAINTEXT")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Net.TLS.Enable || cfg.Net.SASL.Enable { + t.Error("PLAINTEXT should have no TLS/SASL") + } +} + +func TestNewConfig_SASLPlaintext(t *testing.T) { + tests := []struct { + name string + mechanism string + wantMech sarama.SASLMechanism + }{ + {"default PLAIN", "", sarama.SASLTypePlaintext}, + {"explicit PLAIN", SASLMechanismPlain, sarama.SASLTypePlaintext}, + {SASLMechanismSHA256, SASLMechanismSHA256, sarama.SASLTypeSCRAMSHA256}, + {SASLMechanismSHA512, SASLMechanismSHA512, sarama.SASLTypeSCRAMSHA512}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SASL_PLAINTEXT"), + keyUser: []byte("alice"), + keyPassword: []byte("secret"), + } + if tt.mechanism != "" { + data["sasl.mechanism"] = []byte(tt.mechanism) + } + + cfg, err := NewConfig(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.Net.SASL.Enable { + t.Error("SASL should be enabled") + } + if cfg.Net.SASL.User != "alice" { + t.Errorf("user = %q, want alice", cfg.Net.SASL.User) + } + if cfg.Net.SASL.Mechanism != tt.wantMech { + t.Errorf("mechanism = %v, want %v", cfg.Net.SASL.Mechanism, tt.wantMech) + } + if cfg.Net.TLS.Enable { + t.Error("TLS should be disabled for SASL_PLAINTEXT") + } + if tt.wantMech == sarama.SASLTypeSCRAMSHA256 || tt.wantMech == sarama.SASLTypeSCRAMSHA512 { + if cfg.Net.SASL.SCRAMClientGeneratorFunc == nil { + t.Error("SCRAM generator should be set") + } + } + }) + } +} + +func TestNewConfig_SASLMissingCredentials(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SASL_PLAINTEXT"), + keyUser: []byte("alice"), + } + _, err := NewConfig(data) + if err == nil { + t.Fatal("expected error for missing password") + } +} + +func TestNewConfig_UnsupportedMechanism(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SASL_PLAINTEXT"), + keyUser: []byte("alice"), + keyPassword: []byte("secret"), + "sasl.mechanism": []byte("OAUTHBEARER"), + } + _, err := NewConfig(data) + if err == nil { + t.Fatal("expected error for OAUTHBEARER") + } +} + +func TestNewConfig_SSL(t *testing.T) { + caCert, caKey := generateTestCA(t) + clientCert, clientKey := generateTestClientCert(t, caCert, caKey) + + t.Run("with client cert", func(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SSL"), + keyCACrt: pemEncodeCert(caCert), + "user.crt": pemEncodeCert(clientCert), + "user.key": pemEncodeKey(t, clientKey), + } + cfg, err := NewConfig(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.Net.TLS.Enable { + t.Error("TLS should be enabled") + } + if cfg.Net.TLS.Config.RootCAs == nil { + t.Error("RootCAs should be set") + } + if len(cfg.Net.TLS.Config.Certificates) != 1 { + t.Error("client certificate should be set") + } + }) + + t.Run("with user.skip", func(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SSL"), + keyCACrt: pemEncodeCert(caCert), + "user.skip": []byte("true"), + } + cfg, err := NewConfig(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.Net.TLS.Enable { + t.Error("TLS should be enabled") + } + if len(cfg.Net.TLS.Config.Certificates) != 0 { + t.Error("client certificate should not be set when user.skip=true") + } + }) + + t.Run("missing client cert", func(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SSL"), + keyCACrt: pemEncodeCert(caCert), + } + _, err := NewConfig(data) + if err == nil { + t.Fatal("expected error for missing client cert") + } + }) + + t.Run("no ca.crt uses system roots", func(t *testing.T) { + data := map[string][]byte{ + keyProtocol: []byte("SSL"), + "user.skip": []byte("true"), + } + cfg, err := NewConfig(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Net.TLS.Config.RootCAs != nil { + t.Error("RootCAs should be nil (system roots)") + } + }) +} + +func TestNewConfig_SASL_SSL(t *testing.T) { + caCert, _ := generateTestCA(t) + + data := map[string][]byte{ + keyProtocol: []byte("SASL_SSL"), + "sasl.mechanism": []byte("SCRAM-SHA-512"), + keyUser: []byte("alice"), + keyPassword: []byte("secret"), + keyCACrt: pemEncodeCert(caCert), + } + cfg, err := NewConfig(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.Net.SASL.Enable { + t.Error("SASL should be enabled") + } + if !cfg.Net.TLS.Enable { + t.Error("TLS should be enabled") + } + if len(cfg.Net.TLS.Config.Certificates) != 0 { + t.Error("SASL_SSL should not require client certs") + } +} + +func TestNewConfig_UnsupportedProtocol(t *testing.T) { + _, err := NewConfig(map[string][]byte{keyProtocol: []byte("BOGUS")}) + if err == nil { + t.Fatal("expected error for unsupported protocol") + } +} + +func generateTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert, key +} + +func generateTestClientCert(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "test-client"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert, key +} + +func pemEncodeCert(cert *x509.Certificate) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) +} + +func pemEncodeKey(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} diff --git a/internal/objectbucketsource/notificationserver/consumer.go b/internal/objectbucketsource/notificationserver/consumer.go new file mode 100644 index 0000000..08b50b8 --- /dev/null +++ b/internal/objectbucketsource/notificationserver/consumer.go @@ -0,0 +1,38 @@ +package notificationserver + +import ( + "github.com/IBM/sarama" +) + +type consumerGroupHandler struct { + handler *notificationHandler +} + +func (h *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error { + log.Info("kafka consumer group session setup") + return nil +} + +func (h *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error { + log.Info("kafka consumer group session cleanup") + return nil +} + +func (h *consumerGroupHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { + for msg := range claim.Messages() { + log.Info("received kafka notification", + "topic", msg.Topic, + "partition", msg.Partition, + "offset", msg.Offset) + + if err := h.handler.processNotification(session.Context(), msg.Value); err != nil { + log.Error(err, "processing kafka notification", + "topic", msg.Topic, + "partition", msg.Partition, + "offset", msg.Offset) + } + + session.MarkMessage(msg, "") + } + return nil +} diff --git a/internal/objectbucketsource/notificationserver/handler.go b/internal/objectbucketsource/notificationserver/handler.go new file mode 100644 index 0000000..ec6157e --- /dev/null +++ b/internal/objectbucketsource/notificationserver/handler.go @@ -0,0 +1,192 @@ +package notificationserver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/IBM/sarama" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + sourcesv1alpha1 "github.com/functions-dev/func-operator/api/sources/v1alpha1" + ceDispatch "github.com/functions-dev/func-operator/internal/objectbucketsource/cloudevents" + "github.com/functions-dev/func-operator/internal/objectbucketsource/eventmatch" +) + +type notificationHandler struct { + client client.Client + kafkaProducer sarama.SyncProducer +} + +type notificationPayload struct { + Records []json.RawMessage `json:"Records"` +} + +type recordHeader struct { + Event string `json:"Event,omitempty"` + EventName string `json:"eventName,omitempty"` + Bucket string `json:"Bucket,omitempty"` + S3 *s3Field `json:"s3,omitempty"` +} + +type s3Field struct { + Bucket s3Bucket `json:"bucket"` +} + +type s3Bucket struct { + Name string `json:"name"` +} + +type parsedRecord struct { + raw json.RawMessage + header recordHeader +} + +func (h *notificationHandler) handleNotification(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error(err, "reading request body") + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + + if err := h.processNotification(r.Context(), body); err != nil { + log.Error(err, "processing notification") + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *notificationHandler) processNotification(ctx context.Context, body []byte) error { + var payload notificationPayload + if err := json.Unmarshal(body, &payload); err != nil { + return fmt.Errorf("parsing notification payload: %w", err) + } + + var dataRecords []parsedRecord + for _, rawRecord := range payload.Records { + var header recordHeader + if err := json.Unmarshal(rawRecord, &header); err != nil { + log.Error(err, "parsing record header") + continue + } + + if header.Event == "s3:TestEvent" { + h.handleTestEvent(ctx, header.Bucket) + } else if header.EventName != "" && header.S3 != nil { + dataRecords = append(dataRecords, parsedRecord{raw: rawRecord, header: header}) + } + } + + if len(dataRecords) > 0 { + h.dispatchDataEvents(ctx, dataRecords) + } + + return nil +} + +func (h *notificationHandler) handleTestEvent(ctx context.Context, bucketName string) { + sources, err := h.findSourcesForBucket(ctx, bucketName) + if err != nil { + log.Error(err, "finding sources for test event", "bucket", bucketName) + return + } + + for i := range sources { + source := &sources[i] + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := h.client.Get(ctx, client.ObjectKeyFromObject(source), source); err != nil { + return err + } + meta.SetStatusCondition(&source.Status.Conditions, metav1.Condition{ + Type: sourcesv1alpha1.ConditionTestEventReceived, + Status: metav1.ConditionTrue, + Reason: "TestEventReceived", + Message: "Received test event from NooBaa", + ObservedGeneration: source.Generation, + }) + return h.client.Status().Update(ctx, source) + }); err != nil { + log.Error(err, "updating TestEventReceived condition", "source", source.Name, "namespace", source.Namespace) + } else { + log.Info("set TestEventReceived=True", "source", source.Name, "namespace", source.Namespace) + } + } +} + +func (h *notificationHandler) dispatchDataEvents(ctx context.Context, records []parsedRecord) { + var allSources sourcesv1alpha1.ObjectBucketSourceList + if err := h.client.List(ctx, &allSources); err != nil { + log.Error(err, "listing sources for data events") + return + } + + for _, source := range allSources.Items { + var matchedRecords []json.RawMessage + obcName := source.Spec.ObjectBucketClaim.Name + for _, rec := range records { + if rec.header.S3.Bucket.Name != obcName { + continue + } + for _, pattern := range source.Spec.Events { + if eventmatch.MatchEvent(pattern, rec.header.EventName) { + matchedRecords = append(matchedRecords, rec.raw) + break + } + } + } + + if len(matchedRecords) == 0 { + continue + } + + sinkURI := source.Spec.Sink.URI + if strings.HasPrefix(sinkURI, "kafka:") { + topic := strings.TrimPrefix(sinkURI, "kafka:") + if h.kafkaProducer == nil { + log.Error(fmt.Errorf("no kafka brokers configured"), "cannot dispatch to kafka", "topic", topic, "bucket", obcName) + continue + } + if err := ceDispatch.DispatchEventToKafka(ctx, h.kafkaProducer, topic, obcName, matchedRecords); err != nil { + log.Error(err, "dispatching event to kafka", "topic", topic, "bucket", obcName) + } else { + log.Info("dispatched event to kafka", "topic", topic, "bucket", obcName, "records", len(matchedRecords)) + } + } else { + if err := ceDispatch.DispatchEvent(ctx, sinkURI, obcName, matchedRecords); err != nil { + log.Error(err, "dispatching event", "target", sinkURI, "bucket", obcName) + } else { + log.Info("dispatched event", "target", sinkURI, "bucket", obcName, "records", len(matchedRecords)) + } + } + } +} + +func (h *notificationHandler) findSourcesForBucket(ctx context.Context, bucketName string) ([]sourcesv1alpha1.ObjectBucketSource, error) { + var allSources sourcesv1alpha1.ObjectBucketSourceList + if err := h.client.List(ctx, &allSources); err != nil { + return nil, err + } + + var matched []sourcesv1alpha1.ObjectBucketSource + for _, s := range allSources.Items { + if s.Spec.ObjectBucketClaim.Name == bucketName { + matched = append(matched, s) + } + } + return matched, nil +} diff --git a/internal/objectbucketsource/notificationserver/server.go b/internal/objectbucketsource/notificationserver/server.go new file mode 100644 index 0000000..9ffbf51 --- /dev/null +++ b/internal/objectbucketsource/notificationserver/server.go @@ -0,0 +1,113 @@ +package notificationserver + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/IBM/sarama" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + ceDispatch "github.com/functions-dev/func-operator/internal/objectbucketsource/cloudevents" +) + +var log = logf.Log.WithName("notification-server") + +type NotificationServer struct { + Client client.Client + Port int + KafkaBrokers []string + KafkaConfig *sarama.Config + NotificationsMode string + KafkaNotificationsTopics []string + KafkaNotificationsGroupID string +} + +func (s *NotificationServer) Start(ctx context.Context) error { + var kafkaProducer sarama.SyncProducer + if len(s.KafkaBrokers) > 0 { + var err error + kafkaProducer, err = ceDispatch.NewKafkaProducer(s.KafkaBrokers, s.KafkaConfig) + if err != nil { + return fmt.Errorf("creating kafka producer: %w", err) + } + defer func() { _ = kafkaProducer.Close() }() + log.Info("kafka producer initialized", "brokers", s.KafkaBrokers) + } + + handler := ¬ificationHandler{client: s.Client, kafkaProducer: kafkaProducer} + + if s.NotificationsMode == "kafka" { + return s.startKafkaConsumer(ctx, handler) + } + return s.startHTTPServer(ctx, handler) +} + +func (s *NotificationServer) startHTTPServer(ctx context.Context, handler *notificationHandler) error { + mux := http.NewServeMux() + mux.HandleFunc("/", handler.handleNotification) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", s.Port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error(err, "notification server shutdown error") + } + }() + + log.Info("starting notification HTTP server", "port", s.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("notification server: %w", err) + } + return nil +} + +func (s *NotificationServer) startKafkaConsumer(ctx context.Context, handler *notificationHandler) error { + consumerConfig := *s.KafkaConfig + consumerConfig.Consumer.Return.Errors = true + consumerConfig.Consumer.Offsets.Initial = sarama.OffsetNewest + + consumerGroup, err := sarama.NewConsumerGroup(s.KafkaBrokers, s.KafkaNotificationsGroupID, &consumerConfig) + if err != nil { + return fmt.Errorf("creating kafka consumer group: %w", err) + } + defer func() { _ = consumerGroup.Close() }() + + log.Info("starting kafka notification consumer", + "topics", s.KafkaNotificationsTopics, + "group", s.KafkaNotificationsGroupID, + "brokers", s.KafkaBrokers) + + go func() { + for err := range consumerGroup.Errors() { + log.Error(err, "kafka consumer group error") + } + }() + + cgHandler := &consumerGroupHandler{handler: handler} + + for { + if err := consumerGroup.Consume(ctx, s.KafkaNotificationsTopics, cgHandler); err != nil { + if ctx.Err() != nil { + return nil + } + log.Error(err, "kafka consumer group session error, restarting") + } + if ctx.Err() != nil { + return nil + } + } +} + +func (s *NotificationServer) NeedLeaderElection() bool { + return false +} diff --git a/internal/objectbucketsource/s3client/client.go b/internal/objectbucketsource/s3client/client.go new file mode 100644 index 0000000..8ac04f3 --- /dev/null +++ b/internal/objectbucketsource/s3client/client.go @@ -0,0 +1,62 @@ +package s3client + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +func NewS3Client(endpoint, accessKey, secretKey string) *s3.Client { + return s3.New(s3.Options{ + BaseEndpoint: aws.String(endpoint), + Credentials: credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + Region: "us-east-1", + UsePathStyle: true, + HTTPClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }, + }, + }) +} + +func PutBucketNotification(ctx context.Context, client *s3.Client, bucket, id, topic string, events []string) error { + s3Events := make([]s3types.Event, 0, len(events)) + for _, e := range events { + s3Events = append(s3Events, s3types.Event(e)) + } + + _, err := client.PutBucketNotificationConfiguration(ctx, &s3.PutBucketNotificationConfigurationInput{ + Bucket: aws.String(bucket), + NotificationConfiguration: &s3types.NotificationConfiguration{ + TopicConfigurations: []s3types.TopicConfiguration{ + { + Id: aws.String(id), + Events: s3Events, + TopicArn: aws.String(topic), + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("put-bucket-notification for %q: %w", bucket, err) + } + return nil +} + +func RemoveBucketNotification(ctx context.Context, client *s3.Client, bucket string) error { + _, err := client.PutBucketNotificationConfiguration(ctx, &s3.PutBucketNotificationConfigurationInput{ + Bucket: aws.String(bucket), + NotificationConfiguration: &s3types.NotificationConfiguration{}, + }) + if err != nil { + return fmt.Errorf("remove bucket notification for %q: %w", bucket, err) + } + return nil +} diff --git a/test/sources/objectbucket-e2e/e2e_suite_test.go b/test/sources/objectbucket-e2e/e2e_suite_test.go new file mode 100644 index 0000000..d920155 --- /dev/null +++ b/test/sources/objectbucket-e2e/e2e_suite_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" +) + +var ( + // Optional Environment Variables: + // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup. + // These variables are useful if CertManager is already installed, avoiding + // re-installation and conflicts. + skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" + // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster + isCertManagerAlreadyInstalled = false + + // projectImage is the name of the image which will be build and loaded + // with the code source changes to be tested. + projectImage = "example.com/objectbucket-notifications-adapter:v0.0.1" +) + +// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, +// temporary environment to validate project changes with the purposed to be used in CI jobs. +// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs +// CertManager. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting objectbucket-notifications-adapter integration test suite\n") + RunSpecs(t, "e2e suite") +} + +var _ = BeforeSuite(func() { + By("building the manager(Operator) image") + cmd := exec.Command("make", "docker-build-source-objectbucket", fmt.Sprintf("IMG_SOURCE_OBJECTBUCKET=%s", projectImage)) + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + + // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is + // built and available before running the tests. Also, remove the following block. + By("loading the manager(Operator) image on Kind") + err = utils.LoadImageToKindClusterWithName(projectImage) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + + // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing. + // To prevent errors when tests run in environments with CertManager already installed, + // we check for its presence before execution. + // Setup CertManager before the suite if not skipped and if not already installed + if !skipCertManagerInstall { + By("checking if cert manager is installed already") + isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled() + if !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") + Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") + } + } +}) + +var _ = AfterSuite(func() { + // Teardown CertManager after the suite if not skipped and if it was not already installed + if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") + utils.UninstallCertManager() + } +}) diff --git a/test/sources/objectbucket-e2e/e2e_test.go b/test/sources/objectbucket-e2e/e2e_test.go new file mode 100644 index 0000000..b3a4d53 --- /dev/null +++ b/test/sources/objectbucket-e2e/e2e_test.go @@ -0,0 +1,329 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" +) + +// namespace where the project is deployed in +const namespace = "objectbucket-notifications-adapter-system" + +// serviceAccountName created for the project +const serviceAccountName = "objectbucket-notifications-adapter-manager" + +// metricsServiceName is the name of the metrics service of the project +const metricsServiceName = "objectbucket-notifications-adapter-metrics-service" + +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = "objectbucket-notifications-adapter-metrics-binding" + +var _ = Describe("Manager", Ordered, func() { + var controllerPodName string + + // Before running the tests, set up the environment by creating the namespace, + // enforce the restricted security policy to the namespace, installing CRDs, + // and deploying the controller. + BeforeAll(func() { + By("creating manager namespace") + cmd := exec.Command("kubectl", "create", "ns", namespace) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + + By("labeling the namespace to enforce the restricted security policy") + cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, + "pod-security.kubernetes.io/enforce=restricted") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("installing CRDs") + cmd = exec.Command("make", "install") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") + + By("deploying the controller-manager") + cmd = exec.Command("make", "deploy-source-objectbucket", fmt.Sprintf("IMG_SOURCE_OBJECTBUCKET=%s", projectImage)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + }) + + // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, + // and deleting the namespace. + AfterAll(func() { + By("cleaning up the curl pod for metrics") + cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) + _, _ = utils.Run(cmd) + + By("undeploying the controller-manager") + cmd = exec.Command("make", "undeploy-source-objectbucket") + _, _ = utils.Run(cmd) + + By("uninstalling CRDs") + cmd = exec.Command("make", "uninstall") + _, _ = utils.Run(cmd) + + By("removing manager namespace") + cmd = exec.Command("kubectl", "delete", "ns", namespace) + _, _ = utils.Run(cmd) + }) + + // After each test, check for failures and collect logs, events, + // and pod descriptions for debugging. + AfterEach(func() { + specReport := CurrentSpecReport() + if specReport.Failed() { + By("Fetching controller manager pod logs") + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + controllerLogs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } + + By("Fetching Kubernetes events") + cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") + eventsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + } + + By("Fetching curl-metrics logs") + cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + } + + By("Fetching controller manager pod description") + cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) + podDescription, err := utils.Run(cmd) + if err == nil { + fmt.Println("Pod description:\n", podDescription) + } else { + fmt.Println("Failed to describe controller pod") + } + } + }) + + SetDefaultEventuallyTimeout(2 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + + Context("Manager", func() { + It("should run successfully", func() { + By("validating that the controller-manager pod is running as expected") + verifyControllerUp := func(g Gomega) { + // Get the name of the controller-manager pod + cmd := exec.Command("kubectl", "get", + "pods", "-l", "control-plane=manager", + "-o", "go-template={{ range .items }}"+ + "{{ if not .metadata.deletionTimestamp }}"+ + "{{ .metadata.name }}"+ + "{{ \"\\n\" }}{{ end }}{{ end }}", + "-n", namespace, + ) + + podOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + podNames := utils.GetNonEmptyLines(podOutput) + g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") + controllerPodName = podNames[0] + g.Expect(controllerPodName).To(ContainSubstring("manager")) + + // Validate the pod's status + cmd = exec.Command("kubectl", "get", + "pods", controllerPodName, "-o", "jsonpath={.status.phase}", + "-n", namespace, + ) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") + } + Eventually(verifyControllerUp).Should(Succeed()) + }) + + It("should ensure the metrics endpoint is serving metrics", func() { + By("creating a ClusterRoleBinding for the service account to allow access to metrics") + cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, + "--clusterrole=objectbucket-notifications-adapter-metrics-reader", + fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), + ) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") + + By("validating that the metrics service is available") + cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") + + By("getting the service account token") + token, err := serviceAccountToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).NotTo(BeEmpty()) + + By("waiting for the metrics endpoint to be ready") + verifyMetricsEndpointReady := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "endpoints", metricsServiceName, "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("8443"), "Metrics endpoint is not ready") + } + Eventually(verifyMetricsEndpointReady).Should(Succeed()) + + By("verifying that the controller manager is serving the metrics server") + verifyMetricsServerStarted := func(g Gomega) { + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("controller-runtime.metrics\tServing metrics server"), + "Metrics server not yet started") + } + Eventually(verifyMetricsServerStarted).Should(Succeed()) + + By("creating the curl-metrics pod to access the metrics endpoint") + cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", + "--namespace", namespace, + "--image=curlimages/curl:latest", + "--overrides", + fmt.Sprintf(`{ + "spec": { + "containers": [{ + "name": "curl", + "image": "curlimages/curl:latest", + "command": ["/bin/sh", "-c"], + "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"], + "securityContext": { + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + }, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + }], + "serviceAccount": "%s" + } + }`, token, metricsServiceName, namespace, serviceAccountName)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") + + By("waiting for the curl-metrics pod to complete.") + verifyCurlUp := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "curl-metrics", + "-o", "jsonpath={.status.phase}", + "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") + } + Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) + + By("getting the metrics by checking curl-metrics logs") + metricsOutput := getMetricsOutput() + Expect(metricsOutput).To(ContainSubstring( + "controller_runtime_reconcile_total", + )) + }) + + // +kubebuilder:scaffold:e2e-webhooks-checks + + // TODO: Customize the e2e test suite with scenarios specific to your project. + // Consider applying sample/CR(s) and check their status and/or verifying + // the reconciliation by using the metrics, i.e.: + // metricsOutput := getMetricsOutput() + // Expect(metricsOutput).To(ContainSubstring( + // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, + // strings.ToLower(), + // )) + }) +}) + +// serviceAccountToken returns a token for the specified service account in the given namespace. +// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request +// and parsing the resulting token from the API response. +func serviceAccountToken() (string, error) { + const tokenRequestRawString = `{ + "apiVersion": "authentication.k8s.io/v1", + "kind": "TokenRequest" + }` + + // Temporary file to store the token request + secretName := fmt.Sprintf("%s-token-request", serviceAccountName) + tokenRequestFile := filepath.Join("/tmp", secretName) + err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)) + if err != nil { + return "", err + } + + var out string + verifyTokenCreation := func(g Gomega) { + // Execute kubectl command to create the token + cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf( + "/api/v1/namespaces/%s/serviceaccounts/%s/token", + namespace, + serviceAccountName, + ), "-f", tokenRequestFile) + + output, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred()) + + // Parse the JSON output to extract the token + var token tokenRequest + err = json.Unmarshal(output, &token) + g.Expect(err).NotTo(HaveOccurred()) + + out = token.Status.Token + } + Eventually(verifyTokenCreation).Should(Succeed()) + + return out, err +} + +// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. +func getMetricsOutput() string { + By("getting the curl-metrics logs") + cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) + return metricsOutput +} + +// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, +// containing only the token field that we need to extract. +type tokenRequest struct { + Status struct { + Token string `json:"token"` + } `json:"status"` +} diff --git a/test/sources/objectbucket-utils/utils.go b/test/sources/objectbucket-utils/utils.go new file mode 100644 index 0000000..b95c874 --- /dev/null +++ b/test/sources/objectbucket-utils/utils.go @@ -0,0 +1,254 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck +) + +const ( + prometheusOperatorVersion = "v0.77.1" + prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + + "releases/download/%s/bundle.yaml" + + certmanagerVersion = "v1.16.3" + certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" +) + +func warnError(err error) { + _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) +} + +// Run executes the provided command within this context +func Run(cmd *exec.Cmd) (string, error) { + dir, _ := GetProjectDir() + cmd.Dir = dir + + if err := os.Chdir(cmd.Dir); err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) + } + + cmd.Env = append(os.Environ(), "GO111MODULE=on") + command := strings.Join(cmd.Args, " ") + _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) + } + + return string(output), nil +} + +// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. +func InstallPrometheusOperator() error { + url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) + cmd := exec.Command("kubectl", "create", "-f", url) + _, err := Run(cmd) + return err +} + +// UninstallPrometheusOperator uninstalls the prometheus +func UninstallPrometheusOperator() { + url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) + cmd := exec.Command("kubectl", "delete", "-f", url) + if _, err := Run(cmd); err != nil { + warnError(err) + } +} + +// IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed +// by verifying the existence of key CRDs related to Prometheus. +func IsPrometheusCRDsInstalled() bool { + // List of common Prometheus CRDs + prometheusCRDs := []string{ + "prometheuses.monitoring.coreos.com", + "prometheusrules.monitoring.coreos.com", + "prometheusagents.monitoring.coreos.com", + } + + cmd := exec.Command("kubectl", "get", "crds", "-o", "custom-columns=NAME:.metadata.name") + output, err := Run(cmd) + if err != nil { + return false + } + crdList := GetNonEmptyLines(output) + for _, crd := range prometheusCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// UninstallCertManager uninstalls the cert manager +func UninstallCertManager() { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "delete", "-f", url) + if _, err := Run(cmd); err != nil { + warnError(err) + } +} + +// InstallCertManager installs the cert manager bundle. +func InstallCertManager() error { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "apply", "-f", url) + if _, err := Run(cmd); err != nil { + return err + } + // Wait for cert-manager-webhook to be ready, which can take time if cert-manager + // was re-installed after uninstalling on a cluster. + cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "5m", + ) + + _, err := Run(cmd) + return err +} + +// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed +// by verifying the existence of key CRDs related to Cert Manager. +func IsCertManagerCRDsInstalled() bool { + // List of common Cert Manager CRDs + certManagerCRDs := []string{ + "certificates.cert-manager.io", + "issuers.cert-manager.io", + "clusterissuers.cert-manager.io", + "certificaterequests.cert-manager.io", + "orders.acme.cert-manager.io", + "challenges.acme.cert-manager.io", + } + + // Execute the kubectl command to get all CRDs + cmd := exec.Command("kubectl", "get", "crds") + output, err := Run(cmd) + if err != nil { + return false + } + + // Check if any of the Cert Manager CRDs are present + crdList := GetNonEmptyLines(output) + for _, crd := range certManagerCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// LoadImageToKindClusterWithName loads a local docker image to the kind cluster +func LoadImageToKindClusterWithName(name string) error { + cluster := "kind" + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { + cluster = v + } + kindOptions := []string{"load", "docker-image", name, "--name", cluster} + cmd := exec.Command("kind", kindOptions...) + _, err := Run(cmd) + return err +} + +// GetNonEmptyLines converts given command output string into individual objects +// according to line breakers, and ignores the empty elements in it. +func GetNonEmptyLines(output string) []string { + var res []string + elements := strings.Split(output, "\n") + for _, element := range elements { + if element != "" { + res = append(res, element) + } + } + + return res +} + +// GetProjectDir will return the directory where the project is +func GetProjectDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return wd, fmt.Errorf("failed to get current working directory: %w", err) + } + wd = strings.ReplaceAll(wd, "/test/sources/objectbucket-e2e", "") + return wd, nil +} + +// UncommentCode searches for target in the file and remove the comment prefix +// of the target content. The target content may span multiple lines. +func UncommentCode(filename, target, prefix string) error { + // false positive + // nolint:gosec + content, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filename, err) + } + strContent := string(content) + + idx := strings.Index(strContent, target) + if idx < 0 { + return fmt.Errorf("unable to find the code %q to be uncomment", target) + } + + out := new(bytes.Buffer) + _, err = out.Write(content[:idx]) + if err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewBufferString(target)) + if !scanner.Scan() { + return nil + } + for { + if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + // Avoid writing a newline in case the previous line was the last in target. + if !scanner.Scan() { + break + } + if _, err = out.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + } + + if _, err = out.Write(content[idx+len(target):]); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + // false positive + // nolint:gosec + if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file %q: %w", filename, err) + } + + return nil +} From 9dad80954a28e904d1b7a7d3dba94ffb6eb7feb7 Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Tue, 28 Jul 2026 16:14:51 +0200 Subject: [PATCH 2/6] lint fixes --- api/sources/v1alpha1/groupversion_info.go | 16 ++++++++++++++-- api/sources/v1alpha1/objectbucketsource_types.go | 4 ---- test/sources/objectbucket-e2e/e2e_suite_test.go | 5 +++-- test/sources/objectbucket-e2e/e2e_test.go | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/api/sources/v1alpha1/groupversion_info.go b/api/sources/v1alpha1/groupversion_info.go index 8dbac4c..11dce07 100644 --- a/api/sources/v1alpha1/groupversion_info.go +++ b/api/sources/v1alpha1/groupversion_info.go @@ -20,8 +20,9 @@ limitations under the License. package v1alpha1 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" ) var ( @@ -29,8 +30,19 @@ var ( GroupVersion = schema.GroupVersion{Group: "sources.functions.dev", Version: "v1alpha1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme. - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme ) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(GroupVersion, + &ObjectBucketSource{}, + &ObjectBucketSourceList{}, + ) + + metav1.AddToGroupVersion(scheme, GroupVersion) + + return nil +} diff --git a/api/sources/v1alpha1/objectbucketsource_types.go b/api/sources/v1alpha1/objectbucketsource_types.go index f9a9732..5211213 100644 --- a/api/sources/v1alpha1/objectbucketsource_types.go +++ b/api/sources/v1alpha1/objectbucketsource_types.go @@ -77,7 +77,3 @@ type ObjectBucketSourceList struct { metav1.ListMeta `json:"metadata,omitempty"` Items []ObjectBucketSource `json:"items"` } - -func init() { - SchemeBuilder.Register(&ObjectBucketSource{}, &ObjectBucketSourceList{}) -} diff --git a/test/sources/objectbucket-e2e/e2e_suite_test.go b/test/sources/objectbucket-e2e/e2e_suite_test.go index d920155..a7af327 100644 --- a/test/sources/objectbucket-e2e/e2e_suite_test.go +++ b/test/sources/objectbucket-e2e/e2e_suite_test.go @@ -25,7 +25,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" + utils "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" ) var ( @@ -54,7 +54,8 @@ func TestE2E(t *testing.T) { var _ = BeforeSuite(func() { By("building the manager(Operator) image") - cmd := exec.Command("make", "docker-build-source-objectbucket", fmt.Sprintf("IMG_SOURCE_OBJECTBUCKET=%s", projectImage)) + cmd := exec.Command("make", "docker-build-source-objectbucket", + fmt.Sprintf("IMG_SOURCE_OBJECTBUCKET=%s", projectImage)) _, err := utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") diff --git a/test/sources/objectbucket-e2e/e2e_test.go b/test/sources/objectbucket-e2e/e2e_test.go index b3a4d53..3a3266a 100644 --- a/test/sources/objectbucket-e2e/e2e_test.go +++ b/test/sources/objectbucket-e2e/e2e_test.go @@ -27,7 +27,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" + utils "github.com/functions-dev/func-operator/test/sources/objectbucket-utils" ) // namespace where the project is deployed in From 656482a58c0babc35b0616362855fe6a5fd83deb Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Tue, 28 Jul 2026 16:21:29 +0200 Subject: [PATCH 3/6] make update-codege --- api/sources/v1alpha1/zz_generated.deepcopy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/sources/v1alpha1/zz_generated.deepcopy.go b/api/sources/v1alpha1/zz_generated.deepcopy.go index 0d748a7..2d5ed60 100644 --- a/api/sources/v1alpha1/zz_generated.deepcopy.go +++ b/api/sources/v1alpha1/zz_generated.deepcopy.go @@ -22,7 +22,7 @@ package v1alpha1 import ( "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. From 538f21c4fbae063c7d8bec8f2d34c02c415f387e Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Wed, 29 Jul 2026 11:32:47 +0200 Subject: [PATCH 4/6] keep defaults in kustomization --- config/combined/source-objectbucket/kustomization.yaml | 2 +- config/sources/objectbucket/manager/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/combined/source-objectbucket/kustomization.yaml b/config/combined/source-objectbucket/kustomization.yaml index 16d82bd..c3e375a 100644 --- a/config/combined/source-objectbucket/kustomization.yaml +++ b/config/combined/source-objectbucket/kustomization.yaml @@ -13,5 +13,5 @@ patches: images: - name: controller - newName: quay.io/maschmid/objectbucket-notifications-adapter + newName: localhost:5001/objectbucket-notifications-adapter newTag: latest diff --git a/config/sources/objectbucket/manager/kustomization.yaml b/config/sources/objectbucket/manager/kustomization.yaml index 59b3edc..f82d1ac 100644 --- a/config/sources/objectbucket/manager/kustomization.yaml +++ b/config/sources/objectbucket/manager/kustomization.yaml @@ -5,5 +5,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/maschmid/objectbucket-notifications-adapter + newName: localhost:5001/objectbucket-notifications-adapter newTag: latest From b7f6b5358ee63d0d629700ade6a6514703dd63d6 Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Wed, 29 Jul 2026 12:00:21 +0200 Subject: [PATCH 5/6] move combined csv to manifests/bases --- .../bases/func-operator.clusterserviceversion.yaml | 0 config/combined/manifests/kustomization.yaml | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) rename config/combined/{ => manifests}/bases/func-operator.clusterserviceversion.yaml (100%) diff --git a/config/combined/bases/func-operator.clusterserviceversion.yaml b/config/combined/manifests/bases/func-operator.clusterserviceversion.yaml similarity index 100% rename from config/combined/bases/func-operator.clusterserviceversion.yaml rename to config/combined/manifests/bases/func-operator.clusterserviceversion.yaml diff --git a/config/combined/manifests/kustomization.yaml b/config/combined/manifests/kustomization.yaml index a409ef4..f98a159 100644 --- a/config/combined/manifests/kustomization.yaml +++ b/config/combined/manifests/kustomization.yaml @@ -1,7 +1,14 @@ # These resources constitute the fully configured set of manifests # used to generate the combined 'manifests/' directory in a bundle. +namespace: func-operator-system + resources: -- ../bases/func-operator.clusterserviceversion.yaml -- .. +- bases/func-operator.clusterserviceversion.yaml +# Shared CRDs (included once to avoid duplicates) +- ../../crd +# func-operator components +- ../func-operator +# objectbucket-notifications-adapter components +- ../source-objectbucket - ../../sources/objectbucket/samples - ../../scorecard From 716758dff1c1086cad10afe45700bdd08d67d71a Mon Sep 17 00:00:00 2001 From: Marek Schmidt Date: Wed, 29 Jul 2026 13:14:35 +0200 Subject: [PATCH 6/6] cleanup kustomization.yamls --- config/combined/source-objectbucket/kustomization.yaml | 5 ----- config/sources/objectbucket/manager/kustomization.yaml | 4 ---- 2 files changed, 9 deletions(-) diff --git a/config/combined/source-objectbucket/kustomization.yaml b/config/combined/source-objectbucket/kustomization.yaml index c3e375a..678d7a2 100644 --- a/config/combined/source-objectbucket/kustomization.yaml +++ b/config/combined/source-objectbucket/kustomization.yaml @@ -10,8 +10,3 @@ patches: - path: manager_metrics_patch.yaml target: kind: Deployment - -images: -- name: controller - newName: localhost:5001/objectbucket-notifications-adapter - newTag: latest diff --git a/config/sources/objectbucket/manager/kustomization.yaml b/config/sources/objectbucket/manager/kustomization.yaml index f82d1ac..7e3a0f4 100644 --- a/config/sources/objectbucket/manager/kustomization.yaml +++ b/config/sources/objectbucket/manager/kustomization.yaml @@ -3,7 +3,3 @@ resources: - service.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -images: -- name: controller - newName: localhost:5001/objectbucket-notifications-adapter - newTag: latest