From 4a2d6f7c6d97bff9f8e1014f02bb9e0e652bbecc Mon Sep 17 00:00:00 2001 From: Alexandre Dutra Date: Thu, 18 Jan 2024 14:11:10 +0100 Subject: [PATCH] Kubernetes Operator for Nessie --- gradle/libs.versions.toml | 1 + gradle/projects.main.properties | 1 + operator/Makefile | 110 ++++ operator/PROJECT | 17 + operator/README.md | 142 +++++ operator/build.gradle.kts | 80 +++ operator/examples/nessie-autoscaling.yaml | 19 + operator/examples/nessie-inmemory.yaml | 43 ++ operator/examples/nessie-operator-rbac.yaml | 12 + operator/examples/nessie-rocks.yaml | 23 + operator/examples/nessie-simple.yaml | 7 + .../operator/NessieReconciler.java | 205 +++++++ ...tractHorizontalPodAutoscalerDependent.java | 77 +++ .../dependent/AbstractIngressDependent.java | 87 +++ .../dependent/DeploymentDependent.java | 578 ++++++++++++++++++ ...rizontalPodAutoscalerV2Beta1Dependent.java | 87 +++ ...rizontalPodAutoscalerV2Beta2Dependent.java | 92 +++ .../HorizontalPodAutoscalerV2Dependent.java | 92 +++ .../dependent/IngressV1Beta1Dependent.java | 139 +++++ .../dependent/IngressV1Dependent.java | 142 +++++ .../PersistentVolumeClaimDependent.java | 124 ++++ .../dependent/ServiceAccountDependent.java | 87 +++ .../operator/dependent/ServiceDependent.java | 69 +++ .../dependent/ServiceMonitorDependent.java | 98 +++ .../operator/helper/EventsHelper.java | 164 +++++ .../operator/helper/KubernetesHelper.java | 153 +++++ .../operator/helper/TimeUtils.java | 76 +++ .../operator/resource/Nessie.java | 54 ++ .../resource/NessieAuthentication.java | 46 ++ .../resource/NessieAuthorization.java | 45 ++ .../operator/resource/NessieAutoscaling.java | 49 ++ .../resource/NessieAwsCredentials.java | 34 ++ .../resource/NessieBigTableCredentials.java | 33 + .../operator/resource/NessieCredentials.java | 26 + .../operator/resource/NessieImage.java | 81 +++ .../operator/resource/NessieIngress.java | 57 ++ .../operator/resource/NessieRemoteDebug.java | 30 + .../operator/resource/NessieService.java | 46 ++ .../resource/NessieServiceAccount.java | 45 ++ .../resource/NessieServiceMonitor.java | 46 ++ .../operator/resource/NessieSpec.java | 120 ++++ .../operator/resource/NessieStatus.java | 75 +++ .../operator/resource/NessieTelemetry.java | 57 ++ .../operator/resource/NessieVersionStore.java | 129 ++++ operator/src/main/kubernetes/nessie.svg | 144 +++++ .../src/main/resources/application.properties | 65 ++ operator/src/main/resources/nessie-banner.txt | 8 + .../operator/TestNessieReconciler.java | 207 +++++++ .../operator/helper/TestTimeUtils.java | 48 ++ .../resources/nessie-test-deployment.yaml | 140 +++++ .../test/resources/nessie-test-ingress.yaml | 35 ++ .../src/test/resources/nessie-test-pvc.yaml | 29 + .../nessie-test-service-account.yaml | 15 + .../test/resources/nessie-test-service.yaml | 26 + operator/src/test/resources/nessie-test.yaml | 105 ++++ 55 files changed, 4520 insertions(+) create mode 100644 operator/Makefile create mode 100644 operator/PROJECT create mode 100644 operator/README.md create mode 100644 operator/build.gradle.kts create mode 100644 operator/examples/nessie-autoscaling.yaml create mode 100644 operator/examples/nessie-inmemory.yaml create mode 100644 operator/examples/nessie-operator-rbac.yaml create mode 100644 operator/examples/nessie-rocks.yaml create mode 100644 operator/examples/nessie-simple.yaml create mode 100644 operator/src/main/java/org/projectnessie/operator/NessieReconciler.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/AbstractHorizontalPodAutoscalerDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/AbstractIngressDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/DeploymentDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta1Dependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta2Dependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Dependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Beta1Dependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Dependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/PersistentVolumeClaimDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/ServiceAccountDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/ServiceDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/dependent/ServiceMonitorDependent.java create mode 100644 operator/src/main/java/org/projectnessie/operator/helper/EventsHelper.java create mode 100644 operator/src/main/java/org/projectnessie/operator/helper/KubernetesHelper.java create mode 100644 operator/src/main/java/org/projectnessie/operator/helper/TimeUtils.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/Nessie.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieAuthentication.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieAuthorization.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieAutoscaling.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieAwsCredentials.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieBigTableCredentials.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieCredentials.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieImage.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieIngress.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieRemoteDebug.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieService.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieServiceAccount.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieServiceMonitor.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieSpec.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieStatus.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieTelemetry.java create mode 100644 operator/src/main/java/org/projectnessie/operator/resource/NessieVersionStore.java create mode 100644 operator/src/main/kubernetes/nessie.svg create mode 100644 operator/src/main/resources/application.properties create mode 100644 operator/src/main/resources/nessie-banner.txt create mode 100644 operator/src/test/java/org/projectnessie/operator/TestNessieReconciler.java create mode 100644 operator/src/test/java/org/projectnessie/operator/helper/TestTimeUtils.java create mode 100644 operator/src/test/resources/nessie-test-deployment.yaml create mode 100644 operator/src/test/resources/nessie-test-ingress.yaml create mode 100644 operator/src/test/resources/nessie-test-pvc.yaml create mode 100644 operator/src/test/resources/nessie-test-service-account.yaml create mode 100644 operator/src/test/resources/nessie-test-service.yaml create mode 100644 operator/src/test/resources/nessie-test.yaml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b17ecb8b8dc..1ab7daf2cfb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -146,6 +146,7 @@ quarkus-bom = { module = "io.quarkus.platform:quarkus-bom", version.ref = "quark quarkus-cassandra-bom = { module = "io.quarkus.platform:quarkus-cassandra-bom", version.ref = "quarkus" } quarkus-google-cloud-services-bom = { module = "io.quarkus.platform:quarkus-google-cloud-services-bom", version.ref = "quarkus" } quarkus-logging-sentry = { module = "io.quarkiverse.loggingsentry:quarkus-logging-sentry", version = "2.0.5" } +quarkus-operator-sdk-bom = { module = "io.quarkus.platform:quarkus-operator-sdk-bom", version.ref = "quarkus" } rest-assured = { module = "io.rest-assured:rest-assured", version = "5.4.0" } rocksdb-jni = { module = "org.rocksdb:rocksdbjni", version = "8.10.0" } scala-library-v212 = { module = "org.scala-lang:scala-library", version = { strictly = "[2.12, 2.13[", prefer = "2.12.18" }} diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index c4586af94e4..5790651cc9c 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -21,6 +21,7 @@ nessie-jaxrs-testextension=servers/jax-rs-testextension nessie-jaxrs-tests=servers/jax-rs-tests nessie-keycloak-testcontainer=testing/keycloak-container nessie-nessie-testcontainer=testing/nessie-container +nessie-operator=operator nessie-quarkus-auth=servers/quarkus-auth nessie-quarkus-cli=servers/quarkus-cli nessie-quarkus-common=servers/quarkus-common diff --git a/operator/Makefile b/operator/Makefile new file mode 100644 index 00000000000..19489ac25f0 --- /dev/null +++ b/operator/Makefile @@ -0,0 +1,110 @@ + +VERSION ?= $(shell cat ../version.txt | sed -e 's/.*-SNAPSHOT/latest/g') +RELEASE_VERSION ?= $(shell cat ../version.txt | sed -e 's/-SNAPSHOT//g') + +# CHANNELS define the bundle channels used in the bundle. +# Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") +# To re-generate a bundle for other specific channels without changing the standard setup, you can: +# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable) +# - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable") +ifneq ($(origin CHANNELS), undefined) +BUNDLE_CHANNELS := --channels=$(CHANNELS) +endif + +# DEFAULT_CHANNEL defines the default channel used in the bundle. +# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") +# To re-generate a bundle for any other default channel without changing the default setup, you can: +# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable) +# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable") +ifneq ($(origin DEFAULT_CHANNEL), undefined) +BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) +endif +BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) + +# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. +# This variable is used to construct full image tags for bundle and catalog images. +IMAGE_TAG_BASE ?= ghcr.io/projectnessie/nessie-operator + +# BUNDLE_IMG defines the image:tag used for the bundle. +# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) +BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:$(VERSION) + +# Image URL to use all building/pushing image targets +IMG ?= $(IMAGE_TAG_BASE):$(VERSION) + +PULL_POLICY ?= $(shell [ "$(VERSION)" = "latest" ] && echo "Always" || echo "IfNotPresent") +PLATFORM ?= linux/$(shell arch) + +all: docker-build + +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Build + +docker-build: ## Build docker image with the manager. + ../gradlew --no-build-cache :nessie-operator:spotlessApply :nessie-operator:clean :nessie-operator:build -x check \ + -Dquarkus.container-image.image=${IMG} \ + -Dquarkus.jib.platforms=${PLATFORM} \ + -Dquarkus.kubernetes.image-pull-policy=${PULL_POLICY} + +docker-push: ## Build and push docker image with the manager. + ../gradlew --no-build-cache :nessie-operator:spotlessApply :nessie-operator:clean :nessie-operator:build -x check \ + -Dquarkus.container-image.push=true \ + -Dquarkus.container-image.image=${IMG} \ + -Dquarkus.jib.platforms=${PLATFORM} \ + -Dquarkus.kubernetes.image-pull-policy=${PULL_POLICY} + +##@ Deployment + +install: ## Install CRDs into the K8s cluster specified in ~/.kube/config. + @$(foreach file, $(wildcard build/kubernetes/*-v1.yml), kubectl apply -f $(file);) + +uninstall: ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. + @$(foreach file, $(wildcard build/kubernetes/*-v1.yml), kubectl delete -f $(file);) + +deploy: ## Deploy controller to the K8s cluster specified in ~/.kube/config. + kubectl apply -f build/kubernetes/kubernetes.yml + +undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. + kubectl delete -f build/kubernetes/kubernetes.yml + +##@ Helm + +helm-install: ## Install CRDs and the operator using Helm. + helm install nessie-operator build/helm -n nessie-operator + +helm-upgrade: ## Upgrade CRDs and the operator using Helm. + helm upgrade nessie-operator build/helm -n nessie-operator + +helm-uninstall: ## Uninstall CRDs and the operator using Helm. + helm uninstall nessie-operator -n nessie-operator + +##@ Bundle + +.PHONY: bundle +bundle: ## Generate bundle manifests and metadata, then validate generated files. + cat build/kubernetes/* | operator-sdk generate bundle -q --overwrite --version $(RELEASE_VERSION) $(BUNDLE_METADATA_OPTS) + operator-sdk bundle validate ./bundle + # TODO use quarkus + +.PHONY: bundle-build +bundle-build: ## Build the bundle image. + docker build -f build/bundle/nessie-operator/bundle.Dockerfile -t $(BUNDLE_IMG) build/bundle/nessie-operator + +.PHONY: bundle-push +bundle-push: ## Push the bundle image. + docker push $(BUNDLE_IMG) diff --git a/operator/PROJECT b/operator/PROJECT new file mode 100644 index 00000000000..b80524b72fb --- /dev/null +++ b/operator/PROJECT @@ -0,0 +1,17 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html +domain: projectnessie.org +layout: +- quarkus.javaoperatorsdk.io/v1-alpha +projectName: nessie-operator +resources: +- api: + crdVersion: v1 + namespaced: true + domain: projectnessie.org + group: nessie + kind: Nessie + version: v1alpha1 +version: "3" diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 00000000000..af2e8c2998a --- /dev/null +++ b/operator/README.md @@ -0,0 +1,142 @@ +# Kubernetes Operator for Nessie + +## Overview + +This module is a Kubernetes Operator for Nessie. + +**WARNING: This is a work in progress and is not ready for production use.** + +This project was created using [Operator SDK]: + +```bash +operator-sdk init --plugins=quarkus --domain=projectnessie.org --project-name=nessie-operator +operator-sdk create api --plugins=quarkus --version=v1alpha1 --kind=Nessie +``` + +[Operator SDK]:https://sdk.operatorframework.io/docs/cli/operator-sdk/ + +## Usage + +TODO describe typical deployment scenarios. + +## Development + +### TODOS + +- [ ] Features + - [ ] Enable operator remote debugging +- [ ] CRD validation + - [ ] Admission webhook ? https://github.com/operator-framework/josdk-webhooks +- [ ] Operator release + - [ ] Publish operator image as part of release process + - [ ] Publish operator Helm charts + - [ ] Publish manifests + Kustomize? + - [ ] Operator Lifecycle Manager (OLM) support + - [ ] Generate and Validate ClusterServiceVersion (CSV) metadata + - https://olm.operatorframework.io/docs/tasks/creating-operator-manifests/ + - https://github.com/operator-framework/operator-lifecycle-manager/blob/master/doc/design/building-your-csv.md + - https://k8s-operatorhub.github.io/community-operators/packaging-required-fields/ + - [ ] Publish bundle image as part of release process + - [ ] Publish bundle to operatorhub.io: + - How to contribute: https://operatorhub.io/contribute + - Instructions for packaging: https://k8s-operatorhub.github.io/community-operators/packaging-operator/ + - Testing the bundle locally: https://k8s-operatorhub.github.io/community-operators/testing-operators/ + - [ ] Publish a certified bundle to Openshift OperatorHub? + - https://github.com/redhat-openshift-ecosystem/community-operators-prod + - https://github.com/redhat-openshift-ecosystem/certified-operators +- [ ] Tests + - [ ] Unit tests + - [ ] Smoke test + - [ ] Missing / invalid configs + - [ ] Out-of-cluster tests ? + - [ ] Integration tests: + - [ ] All version stores + - [ ] https://github.com/java-operator-sdk/jenvtest ? + - Examples: https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator + - [ ] E2E tests / Adhoc tests + - [ ] Deploy operator in a real cluster (GCP / EKS / AKS / OCP) +- [ ] GC + - [ ] Add a NessieTask CRD ? Reuse Job CRD? + - [ ] Automatically set up a temporary database for Nessie GC + - [ ] Periodic Nessie GC + +### Known issues + +- [ ] Check generated RBAC rules + +### Prerequisites + +- Operator SDK: https://sdk.operatorframework.io/docs/installation/ + +### Building the operator image + +```bash +make docker-build +``` + +### Adhoc testing with Minikube + +Install [minikube](https://minikube.sigs.k8s.io/docs/start/). + +If you need ingress, install the ingress addon: + +```bash +minikube addons enable ingress +minikube tunnel +``` + +Create the nessie-operator and nessie-ns namespaces (only needed once): + +```bash +kubectl create namespace nessie-operator +kubectl create namespace nessie-ns +``` + +Grant admin rights to the nessie-operator service account (only needed once): + +```bash +kubectl apply -f examples/nessie-operator-rbac.yaml +``` + +Build the operator docker image _inside_ minikube to facilitate testing (doesn't need a registry): + +```bash +eval $(minikube docker-env) +make docker-build PULL_POLICY=IfNotPresent +``` + +Note: the `PULL_POLICY=IfNotPresent` is required to avoid pulling the image from a registry. + +Install the CRDs and deploy the operator in the nessie-operator namespace: + +```bash +make install deploy +``` + +Create a Nessie resource in the nessie-ns namespace: + +```bash +kubectl apply -n nessie-ns -f examples/nessie-simple.yaml +``` + +You should see 1 pod, 1 deployment and 1 service running. + +### Testing OLM bundles (WIP) + +Install OLM in your cluster: + +```bash +operator-sdk olm install +``` + +Manual bundle deployment instructions: +https://docs.quarkiverse.io/quarkus-operator-sdk/dev/index.html#_deployment + +Install `opm` (operator package manager): download the binary from: +https://github.com/operator-framework/operator-registry/releases/tag/v1.36.0 + +MacOS users will need to remove the quarantine attribute from the binary: + +```bash +xattr -d com.apple.quarantine ~/bin/opm +``` diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts new file mode 100644 index 00000000000..43c875f498b --- /dev/null +++ b/operator/build.gradle.kts @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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. + */ + +/* + * Copyright (C) 2024 Dremio + * + * 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. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +plugins { + alias(libs.plugins.quarkus) + id("nessie-conventions-quarkus") + id("nessie-jacoco") +} + +extra["maven.name"] = "Nessie - Kubernetes Operator" + +dependencies { + implementation(enforcedPlatform(libs.quarkus.bom)) + implementation(enforcedPlatform(libs.quarkus.operator.sdk.bom)) + + implementation("io.quarkiverse.operatorsdk:quarkus-operator-sdk") + implementation("io.quarkiverse.operatorsdk:quarkus-operator-sdk-bundle-generator") + implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-container-image-jib") + + compileOnly("io.sundr:builder-annotations:0.103.0") + compileOnly("io.fabric8:generator-annotations") + + annotationProcessor(enforcedPlatform(libs.quarkus.bom)) + annotationProcessor("io.sundr:builder-annotations:0.103.0") + // see https://github.com/sundrio/sundrio/issues/104 + annotationProcessor("io.fabric8:kubernetes-client") + + testImplementation(enforcedPlatform(libs.quarkus.bom)) + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.quarkus:quarkus-junit5-mockito") + testImplementation("io.quarkus:quarkus-test-kubernetes-client") + testImplementation("org.bouncycastle:bcpkix-jdk18on") + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.bundles.junit.testing) + testImplementation(libs.awaitility) +} + +listOf("javadoc", "sourcesJar").forEach { name -> + tasks.named(name).configure { dependsOn(tasks.named("compileQuarkusGeneratedSourcesJava")) } +} + +listOf("checkstyleTest", "compileTestJava").forEach { name -> + tasks.named(name).configure { dependsOn(tasks.named("compileQuarkusTestGeneratedSourcesJava")) } +} + +tasks.named("processTestResources").configure { + inputs.property("projectVersion", project.version) + filter(ReplaceTokens::class, mapOf("tokens" to mapOf("projectVersion" to project.version))) +} diff --git a/operator/examples/nessie-autoscaling.yaml b/operator/examples/nessie-autoscaling.yaml new file mode 100644 index 00000000000..f950a5cb0b5 --- /dev/null +++ b/operator/examples/nessie-autoscaling.yaml @@ -0,0 +1,19 @@ +apiVersion: nessie.projectnessie.org/v1alpha1 +kind: Nessie +metadata: + name: nessie-autoscaling +spec: + size: 1 + logLevel: INFO + image: + repository: projectnessie/nessie + tag: 0.75.0 + versionStore: + type: Jdbc + jdbc: + jdbcUrl: jdbc:h2:mem:nessie + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 3 + targetCpuUtilizationPercentage: 50 diff --git a/operator/examples/nessie-inmemory.yaml b/operator/examples/nessie-inmemory.yaml new file mode 100644 index 00000000000..84b3f1af9a8 --- /dev/null +++ b/operator/examples/nessie-inmemory.yaml @@ -0,0 +1,43 @@ +apiVersion: nessie.projectnessie.org/v1alpha1 +kind: Nessie +metadata: + name: nessie-inmemory +spec: + size: 1 + logLevel: INFO + image: + repository: projectnessie/nessie + tag: 0.75.0 + serviceAccount: + create: true + name: nessie-inmemory-sa + versionStore: + type: InMemory + authentication: + enabled: true + oidcAuthServerUrl: http://localhost:8080/auth/realms/nessie + oidcClientId: quarkus-app + authorization: + enabled: true + rules: + allowViewingBranch: op=='VIEW_REFERENCE' && role.startsWith('test_user') && ref.startsWith('allowedBranch') + telemetry: + enabled: true + endpoint: http://localhost:14268/api/traces + sample: "1.0" + attributes: + foo: "bar" + extraEnv: + - name: NESSIE_QUARKUS_PROFILE + value: "prod" + advancedConfig: + nessie.version.store.persist.cache-capacity-mb: 1024 + nessie.version.store.persist.cache-capacity-fraction-of-heap: 0.7 + nessie.version.store.persist.cache-capacity-fraction-adjust-mb: 256 + nessie.version.store.persist.cache-capacity-fraction-min-size-mb: 64 + nessie.server.default-branch: my-branch + nessie.version.store.persist.repository-id: my-repository + quarkus: + log: + console.format: "%d{HH:mm:ss} %s%e%n" + category."org.projectnessie".level: "DEBUG" diff --git a/operator/examples/nessie-operator-rbac.yaml b/operator/examples/nessie-operator-rbac.yaml new file mode 100644 index 00000000000..c0984c1c2fb --- /dev/null +++ b/operator/examples/nessie-operator-rbac.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: nessie-operator-admin +subjects: + - kind: ServiceAccount + name: nessie-operator + namespace: nessie-operator +roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: "" diff --git a/operator/examples/nessie-rocks.yaml b/operator/examples/nessie-rocks.yaml new file mode 100644 index 00000000000..1fae05be689 --- /dev/null +++ b/operator/examples/nessie-rocks.yaml @@ -0,0 +1,23 @@ +apiVersion: nessie.projectnessie.org/v1alpha1 +kind: Nessie +metadata: + name: nessie-rocks +spec: + size: 1 + logLevel: INFO + image: + repository: projectnessie/nessie + tag: 0.75.0 + versionStore: + type: RocksDb + rocksdb: + storageClassName: standard + storageSize: 64Mi + # Access nessie: + # curl -H "Host: nessie-rocks.example.com" -k https://$(minikube ip)/api/v2/config + ingress: + enabled: true + rules: + - host: nessie-rocks.example.com + paths: + - / diff --git a/operator/examples/nessie-simple.yaml b/operator/examples/nessie-simple.yaml new file mode 100644 index 00000000000..630b314e8b1 --- /dev/null +++ b/operator/examples/nessie-simple.yaml @@ -0,0 +1,7 @@ +apiVersion: nessie.projectnessie.org/v1alpha1 +kind: Nessie +metadata: + name: nessie-simple + namespace: nessie-ns +spec: + size: 1 diff --git a/operator/src/main/java/org/projectnessie/operator/NessieReconciler.java b/operator/src/main/java/org/projectnessie/operator/NessieReconciler.java new file mode 100644 index 00000000000..00aae96fcd1 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/NessieReconciler.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES; +import static org.projectnessie.operator.helper.EventsHelper.EventType.Normal; + +import io.javaoperatorsdk.operator.AggregatedOperatorException; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ContextInitializer; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusHandler; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowReconcileResult; +import io.quarkiverse.operatorsdk.annotations.CSVMetadata; +import io.quarkiverse.operatorsdk.annotations.CSVMetadata.Icon; +import io.quarkiverse.operatorsdk.annotations.RBACRule; +import jakarta.inject.Inject; +import java.util.concurrent.TimeUnit; +import org.projectnessie.operator.dependent.DeploymentDependent; +import org.projectnessie.operator.dependent.HorizontalPodAutoscalerV2Beta1Dependent; +import org.projectnessie.operator.dependent.HorizontalPodAutoscalerV2Beta2Dependent; +import org.projectnessie.operator.dependent.HorizontalPodAutoscalerV2Dependent; +import org.projectnessie.operator.dependent.IngressV1Beta1Dependent; +import org.projectnessie.operator.dependent.IngressV1Dependent; +import org.projectnessie.operator.dependent.PersistentVolumeClaimDependent; +import org.projectnessie.operator.dependent.ServiceAccountDependent; +import org.projectnessie.operator.dependent.ServiceDependent; +import org.projectnessie.operator.dependent.ServiceMonitorDependent; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@CSVMetadata(icon = @Icon(fileName = "nessie.svg")) +@ControllerConfiguration( + name = "nessie-reconciler", + namespaces = WATCH_ALL_NAMESPACES, + dependents = { + @Dependent( + name = "service-account", + type = ServiceAccountDependent.class, + activationCondition = ServiceAccountDependent.ActivationCondition.class), + @Dependent( + name = "pvc", + type = PersistentVolumeClaimDependent.class, + activationCondition = PersistentVolumeClaimDependent.ActivationCondition.class, + readyPostcondition = PersistentVolumeClaimDependent.ReadyCondition.class), + @Dependent( + name = "deployment", + type = DeploymentDependent.class, + readyPostcondition = DeploymentDependent.ReadyCondition.class), + @Dependent(name = "service", type = ServiceDependent.class, dependsOn = "deployment"), + @Dependent( + name = "ingress-v1", + type = IngressV1Dependent.class, + dependsOn = "service", + activationCondition = IngressV1Dependent.ActivationCondition.class, + readyPostcondition = IngressV1Dependent.ReadyCondition.class), + @Dependent( + name = "ingress-v1beta1", + type = IngressV1Beta1Dependent.class, + dependsOn = "service", + activationCondition = IngressV1Beta1Dependent.ActivationCondition.class, + readyPostcondition = IngressV1Beta1Dependent.ReadyCondition.class), + @Dependent( + name = "service-monitor", + type = ServiceMonitorDependent.class, + dependsOn = "service", + activationCondition = ServiceMonitorDependent.ActivationCondition.class), + @Dependent( + name = "autoscaler-v2", + type = HorizontalPodAutoscalerV2Dependent.class, + dependsOn = "deployment", + activationCondition = HorizontalPodAutoscalerV2Dependent.ActivationCondition.class), + @Dependent( + name = "autoscaler-v2beta2", + type = HorizontalPodAutoscalerV2Beta2Dependent.class, + dependsOn = "deployment", + activationCondition = HorizontalPodAutoscalerV2Beta2Dependent.ActivationCondition.class), + @Dependent( + name = "autoscaler-v2beta1", + type = HorizontalPodAutoscalerV2Beta1Dependent.class, + dependsOn = "deployment", + activationCondition = HorizontalPodAutoscalerV2Beta1Dependent.ActivationCondition.class), + }, + maxReconciliationInterval = + @MaxReconciliationInterval(interval = 5, timeUnit = TimeUnit.MINUTES)) +@RBACRule( + apiGroups = "", + resources = "events", + verbs = {"get", "list", "watch", "create", "update", "patch", "delete"}) +public class NessieReconciler + implements Reconciler, + ContextInitializer, + Cleaner, + ErrorStatusHandler { + + public static final String DEPENDENT_RESOURCES_SELECTOR = + "app.kubernetes.io/managed-by=nessie-operator"; + + private static final Logger LOGGER = LoggerFactory.getLogger(NessieReconciler.class); + + @Inject KubernetesHelper kubernetesHelper; + @Inject EventsHelper eventsHelper; + + @Override + public void initContext(Nessie nessie, Context context) { + LOGGER.debug( + "Reconciling Nessie {} in namespace {}", + nessie.getMetadata().getName(), + nessie.getMetadata().getNamespace()); + KubernetesHelper.storeInContext(context, kubernetesHelper); + EventsHelper.storeInContext(context, eventsHelper); + } + + @Override + public UpdateControl reconcile(Nessie nessie, Context context) { + boolean ready = + context + .managedDependentResourceContext() + .getWorkflowReconcileResult() + .map(wrr -> checkReconcileResult(nessie, wrr)) + .orElse(false); + updateNessieStatus(nessie, context, ready); + return UpdateControl.patchStatus(nessie); + } + + @Override + public DeleteControl cleanup(Nessie nessie, Context context) { + LOGGER.debug( + "Deleted Nessie {} in namespace {}", + nessie.getMetadata().getName(), + nessie.getMetadata().getNamespace()); + eventsHelper.clearEvents(nessie); + return DeleteControl.defaultDelete(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + Nessie nessie, Context context, Exception e) { + if (e.getCause() instanceof AggregatedOperatorException) { + e = (Exception) e.getCause(); + } + if (e instanceof AggregatedOperatorException aoe) { + aoe.getAggregatedExceptions() + .values() + .forEach(error -> eventsHelper.fireErrorEvent(nessie, error)); + } else { + eventsHelper.fireErrorEvent(nessie, e); + } + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + private boolean checkReconcileResult(Nessie nessie, WorkflowReconcileResult wrr) { + if (wrr.erroredDependentsExist()) { + wrr.getErroredDependents() + .values() + .forEach(error -> eventsHelper.fireErrorEvent(nessie, error)); + } + boolean ready = wrr.allDependentResourcesReady(); + if (ready) { + eventsHelper.fireEvent(nessie, Normal, "ReconcileSuccess", "Reconciled successfully"); + } + return ready; + } + + private void updateNessieStatus(Nessie nessie, Context context, boolean ready) { + LOGGER.debug("Nessie is ready? {}", ready); + nessie.getOrCreateStatus().setReady(ready); + if (ready && nessie.getSpec().ingress().enabled()) { + try { + if (kubernetesHelper.isIngressV1Supported()) { + IngressV1Dependent.updateStatus(nessie, context); + } else if (kubernetesHelper.isIngressV1Beta1Supported()) { + IngressV1Beta1Dependent.updateStatus(nessie, context); + } + } catch (Exception e) { + // Can happen if ingress is misconfigured + LOGGER.warn("Failed to compute Ingress URL", e); + nessie.getStatus().setExposedUrl(null); + } + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/AbstractHorizontalPodAutoscalerDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/AbstractHorizontalPodAutoscalerDependent.java new file mode 100644 index 00000000000..fdef8330bd4 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/AbstractHorizontalPodAutoscalerDependent.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieAutoscaling; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AbstractHorizontalPodAutoscalerDependent + extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(IngressV1Dependent.class); + + protected AbstractHorizontalPodAutoscalerDependent(Class resourceClass) { + super(resourceClass); + } + + @Override + public HPA create(HPA desired, Nessie nessie, Context context) { + LOGGER.debug("Creating horizontal pod autoscaler for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent(nessie, EventType.Normal, "CreatingHPA", "Creating horizontal pod autoscaler"); + return super.create(desired, nessie, context); + } + + protected void checkAutoscalingConfig(NessieAutoscaling autoscaling) { + if (autoscaling.targetCpuUtilizationPercentage() == 0 + && autoscaling.targetMemoryUtilizationPercentage() == 0) { + throw new IllegalArgumentException( + "At least one of 'targetCpuUtilizationPercentage' or 'targetMemoryUtilizationPercentage' " + + "must be set when autoscaling is enabled."); + } + if (autoscaling.minReplicas() > autoscaling.maxReplicas()) { + throw new IllegalArgumentException( + "'minReplicas' must be less than or equal to 'maxReplicas'."); + } + } + + public abstract static class ActivationCondition + implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, Nessie nessie, Context context) { + if (nessie.getSpec().autoscaling().enabled() + && nessie.getSpec().versionStore().type().supportsMultipleReplicas()) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return isAutoscalingSupported(helper); + } + return false; + } + + protected abstract boolean isAutoscalingSupported(KubernetesHelper helper); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/AbstractIngressDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/AbstractIngressDependent.java new file mode 100644 index 00000000000..b265aed9676 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/AbstractIngressDependent.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractIngressDependent + extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractIngressDependent.class); + + protected AbstractIngressDependent(Class resourceClass) { + super(resourceClass); + } + + @Override + public I create(I desired, Nessie nessie, Context context) { + LOGGER.debug("Creating ingress for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent(nessie, EventType.Normal, "CreatingIngress", "Creating ingress"); + return super.create(desired, nessie, context); + } + + public abstract static class ActivationCondition + implements Condition { + + private final String networkingVersion; + + protected ActivationCondition(String networkingVersion) { + this.networkingVersion = networkingVersion; + } + + @Override + public boolean isMet( + DependentResource dependentResource, Nessie nessie, Context context) { + if (nessie.getSpec().ingress().enabled()) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return helper.isApiSupported("networking.k8s.io", networkingVersion); + } + return false; + } + } + + public abstract static class ReadyCondition + implements Condition { + + private final Class resourceClass; + + protected ReadyCondition(Class resourceClass) { + this.resourceClass = resourceClass; + } + + @Override + public boolean isMet( + DependentResource dependentResource, Nessie nessie, Context context) { + boolean conditionMet = + context.getSecondaryResource(resourceClass).map(this::checkIngressReady).orElse(false); + LOGGER.debug("Ingress is ready? {}", conditionMet); + return conditionMet; + } + + protected abstract boolean checkIngressReady(I ingress); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/DeploymentDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/DeploymentDependent.java new file mode 100644 index 00000000000..eac3a8d877a --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/DeploymentDependent.java @@ -0,0 +1,578 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import static org.projectnessie.operator.dependent.ServiceAccountDependent.getServiceAccountName; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.ContainerBuilder; +import io.fabric8.kubernetes.api.model.ContainerPortBuilder; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder; +import io.fabric8.kubernetes.api.model.KeyToPathBuilder; +import io.fabric8.kubernetes.api.model.LabelSelectorBuilder; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaimVolumeSource; +import io.fabric8.kubernetes.api.model.PodSpec; +import io.fabric8.kubernetes.api.model.PodSpecBuilder; +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.api.model.SecretKeySelectorBuilder; +import io.fabric8.kubernetes.api.model.SecretVolumeSourceBuilder; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeBuilder; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.VolumeMountBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentSpecBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentStatus; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieAwsCredentials; +import org.projectnessie.operator.resource.NessieCredentials; +import org.projectnessie.operator.resource.NessieService; +import org.projectnessie.operator.resource.NessieSpec.LogLevel; +import org.projectnessie.operator.resource.NessieVersionStore; +import org.projectnessie.operator.resource.NessieVersionStore.BigTable; +import org.projectnessie.operator.resource.NessieVersionStore.Cassandra; +import org.projectnessie.operator.resource.NessieVersionStore.DynamoDb; +import org.projectnessie.operator.resource.NessieVersionStore.Jdbc; +import org.projectnessie.operator.resource.NessieVersionStore.MongoDb; +import org.projectnessie.operator.resource.NessieVersionStore.VersionStoreType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class DeploymentDependent extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(DeploymentDependent.class); + + public static class ReadyCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + Nessie nessie, + Context context) { + boolean conditionMet = + context + .getSecondaryResource(Deployment.class) + .map( + in -> { + DeploymentStatus status = in.getStatus(); + if (status != null) { + return status.getReadyReplicas() != null + && status.getReadyReplicas() == nessie.getSpec().size(); + } + return false; + }) + .orElse(false); + LOGGER.debug("Deployment ready? {}", conditionMet); + return conditionMet; + } + } + + public DeploymentDependent() { + super(Deployment.class); + } + + @Override + public Deployment create(Deployment desired, Nessie nessie, Context context) { + LOGGER.debug("Creating deployment for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent(nessie, EventType.Normal, "CreatingDeployment", "Creating deployment"); + return super.create(desired, nessie, context); + } + + public Deployment desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + Map selectorLabels = helper.selectorLabels(nessie); + Deployment deployment = + new DeploymentBuilder() + .withMetadata( + helper + .metaBuilder(nessie) + // also apply pod labels to the deployment (but not pod annotations) + .addToLabels(nessie.getSpec().podLabels()) + .build()) + .withSpec( + new DeploymentSpecBuilder() + .withSelector( + new LabelSelectorBuilder().withMatchLabels(selectorLabels).build()) + .withReplicas(nessie.getSpec().size()) + .withTemplate( + new PodTemplateSpecBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withLabels(selectorLabels) + .addToLabels(nessie.getSpec().podLabels()) + .withAnnotations(nessie.getSpec().podAnnotations()) + .build()) + .withSpec( + new PodSpecBuilder() + .withServiceAccountName(getServiceAccountName(nessie)) + .withSecurityContext(nessie.getSpec().podSecurityContext()) + .withImagePullSecrets( + nessie.getSpec().image().pullSecretRef() != null + ? List.of(nessie.getSpec().image().pullSecretRef()) + : List.of()) + .withNodeSelector(nessie.getSpec().nodeSelector()) + .withTolerations(nessie.getSpec().tolerations()) + .withAffinity(nessie.getSpec().affinity()) + .withContainers( + new ContainerBuilder() + .withName("nessie") + .withImage(nessie.getSpec().image().fullName()) + .withResources(nessie.getSpec().resources()) + .withSecurityContext(nessie.getSpec().securityContext()) + .withImagePullPolicy( + nessie + .getSpec() + .image() + .pullPolicyWithDefault() + .name()) + .withPorts( + new ContainerPortBuilder() + .withContainerPort(NessieService.DEFAULT_PORT) + .withName("nessie") + .withProtocol("TCP") + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + configureVersionStore(nessie, deployment); + configureAuthentication(nessie, deployment); + configureAuthorization(nessie, deployment); + configureTelemetry(nessie, deployment); + configureLogLevel(nessie, deployment); + configureAdvancedConfig(nessie, deployment); + configureExtraEnv(nessie, deployment); + return deployment; + } + + private void configureAuthentication(Nessie nessie, Deployment deployment) { + List envVars = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + if (nessie.getSpec().authentication().enabled()) { + envVars.add(envVar("NESSIE_SERVER_AUTHENTICATION_ENABLED", "true")); + String oidcAuthServerUrl = nessie.getSpec().authentication().oidcAuthServerUrl(); + if (oidcAuthServerUrl != null) { + envVars.add(envVar("QUARKUS_OIDC_AUTH_SERVER_URL", oidcAuthServerUrl)); + } else { + throw new IllegalArgumentException( + "OIDC authentication is enabled, but no OIDC auth server URL is configured."); + } + String oidcClientId = nessie.getSpec().authentication().oidcClientId(); + if (oidcClientId != null) { + envVars.add(envVar("QUARKUS_OIDC_CLIENT_ID", oidcClientId)); + } else { + throw new IllegalArgumentException( + "OIDC authentication is enabled, but no OIDC client ID is configured."); + } + } else { + envVars.add(envVar("QUARKUS_OIDC_TENANT_ENABLED", "false")); + } + } + + private void configureAuthorization(Nessie nessie, Deployment deployment) { + if (nessie.getSpec().authorization().enabled()) { + List envVars = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + envVars.add(envVar("NESSIE_SERVER_AUTHORIZATION_ENABLED", "true")); + if (!nessie.getSpec().authorization().rules().isEmpty()) { + for (Map.Entry entry : + nessie.getSpec().authorization().rules().entrySet()) { + envVars.add( + envVar( + "NESSIE_SERVER_AUTHORIZATION_RULES_" + entry.getKey().toUpperCase(), + entry.getValue())); + } + } + } + } + + private void configureTelemetry(Nessie nessie, Deployment deployment) { + List envVars = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + if (nessie.getSpec().telemetry().enabled()) { + String endpoint = nessie.getSpec().telemetry().endpoint(); + if (endpoint != null) { + envVars.add(envVar("QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", endpoint)); + } else { + throw new IllegalArgumentException( + "Telemetry is enabled, but no telemetry endpoint is configured."); + } + Map attributes = + new LinkedHashMap<>(nessie.getSpec().telemetry().attributes()); + attributes.putIfAbsent("service.name", nessie.getMetadata().getName()); + String attributesStr = + attributes.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .reduce((a, b) -> a + "," + b) + .orElse(""); + envVars.add(envVar("QUARKUS_OTEL_TRACER_RESOURCE_ATTRIBUTES", attributesStr)); + String sample = nessie.getSpec().telemetry().sample(); + if (sample != null && !sample.isEmpty()) { + switch (sample) { + case "all": + envVars.add(envVar("QUARKUS_OTEL_TRACER_SAMPLER", "parentbased_always_on")); + break; + case "none": + envVars.add(envVar("QUARKUS_OTEL_TRACER_SAMPLER", "always_off")); + break; + default: + envVars.add(envVar("QUARKUS_OTEL_TRACER_SAMPLER", "parentbased_traceidratio")); + envVars.add(envVar("QUARKUS_OTEL_TRACER_SAMPLER_ARG", sample)); + break; + } + } + } else { + envVars.add(envVar("QUARKUS_OTEL_SDK_DISABLED", "true")); + } + } + + private void configureLogLevel(Nessie nessie, Deployment deployment) { + List envVars = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + LogLevel logLevel = nessie.getSpec().logLevel(); + envVars.add(envVar("QUARKUS_LOG_LEVEL", logLevel.name())); + envVars.add(envVar("QUARKUS_LOG_CONSOLE_LEVEL", logLevel.name())); + envVars.add(envVar("QUARKUS_LOG_FILE_LEVEL", logLevel.name())); + if (logLevel.compareTo(LogLevel.INFO) < 0) { + envVars.add(envVar("QUARKUS_LOG_MIN_LEVEL", logLevel.name())); + } + } + + private void configureVersionStore(Nessie nessie, Deployment deployment) { + checkNullVersionStoreOptions(nessie.getSpec().versionStore()); + PodSpec pod = deployment.getSpec().getTemplate().getSpec(); + Container container = pod.getContainers().get(0); + VersionStoreType type = nessie.getSpec().versionStore().type(); + if (!type.supportsMultipleReplicas()) { + if (nessie.getSpec().autoscaling().enabled()) { + throw new IllegalArgumentException( + "Autoscaling cannot be used with %s version store.".formatted(type)); + } + if (nessie.getSpec().size() > 1) { + throw new IllegalArgumentException( + type + " version store can only be used with a single replica."); + } + } + switch (type) { + case InMemory: + break; + case RocksDb: + configureRocks(nessie, container, pod.getVolumes()); + break; + case Jdbc: + configureJdbc(nessie, container); + break; + case BigTable: + configureBigTable(nessie, container, pod.getVolumes()); + break; + case MongoDb: + configureMongo(nessie, container); + break; + case Cassandra: + configureCassandra(nessie, container); + break; + case DynamoDb: + configureDynamo(nessie, container); + break; + default: + throw new IllegalArgumentException("Unknown version store type: " + type); + } + } + + private static void checkNullVersionStoreOptions(NessieVersionStore versionStore) { + // TODO can we replace this with an OneOf schema? + VersionStoreType type = versionStore.type(); + switch (type) { + case InMemory -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.jdbc(), type, "jdbc"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case RocksDb -> { + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.jdbc(), type, "jdbc"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case DynamoDb -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.jdbc(), type, "jdbc"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case MongoDb -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.jdbc(), type, "jdbc"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case Cassandra -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.jdbc(), type, "jdbc"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case Jdbc -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.bigtable(), type, "bigtable"); + } + case BigTable -> { + requireNull(versionStore.rocksdb(), type, "rocksdb"); + requireNull(versionStore.dynamodb(), type, "dynamodb"); + requireNull(versionStore.mongodb(), type, "mongodb"); + requireNull(versionStore.cassandra(), type, "cassandra"); + requireNull(versionStore.jdbc(), type, "jdbc"); + } + default -> throw new IllegalArgumentException("Unknown version store type: " + type); + } + } + + private static void requireNull(Object o, VersionStoreType type, String name) { + if (o != null) { + throw new IllegalArgumentException( + "Version store type is '%s', but %s is configured.".formatted(type, name)); + } + } + + private static void configureRocks(Nessie nessie, Container container, List volumes) { + container + .getEnv() + .add(envVar("NESSIE_VERSION_STORE_PERSIST_ROCKS_DATABASE_PATH", "/rocks-nessie")); + container.getVolumeMounts().add(volumeMount("rocks-storage", "/rocks-nessie")); + // Note: readOnly: false creates an infinite reconcile loop, because the actual deployment + // will contain readOnly: null regardless of the value in the desired deployment. + PersistentVolumeClaimVolumeSource claim = + new PersistentVolumeClaimVolumeSource(nessie.getMetadata().getName(), null); + volumes.add( + new VolumeBuilder().withName("rocks-storage").withPersistentVolumeClaim(claim).build()); + } + + private static void configureJdbc(Nessie nessie, Container container) { + Jdbc jdbc = nessie.getSpec().versionStore().jdbc(); + container.getEnv().add(envVar("QUARKUS_DATASOURCE_JDBC_URL", jdbc.jdbcUrl())); + NessieCredentials credentials = jdbc.credentials(); + if (credentials != null) { + container + .getEnv() + .add( + envVar( + "QUARKUS_DATASOURCE_USERNAME", + credentials.secretRef(), + credentials.usernameKey())); + container + .getEnv() + .add( + envVar( + "QUARKUS_DATASOURCE_PASSWORD", + credentials.secretRef(), + credentials.passwordKey())); + } + } + + private static void configureBigTable(Nessie nessie, Container container, List volumes) { + BigTable bigtable = nessie.getSpec().versionStore().bigtable(); + container.getEnv().add(envVar("QUARKUS_GOOGLE_CLOUD_PROJECT_ID", bigtable.projectId())); + container + .getEnv() + .add(envVar("NESSIE_VERSION_STORE_PERSIST_BIGTABLE_INSTANCE_ID", bigtable.instanceId())); + container + .getEnv() + .add( + envVar( + "NESSIE_VERSION_STORE_PERSIST_BIGTABLE_APP_PROFILE_ID", bigtable.appProfileId())); + container + .getEnv() + .add(envVar("GOOGLE_APPLICATION_CREDENTIALS", "/bigtable-nessie/sa_credentials.json")); + container.getVolumeMounts().add(volumeMount("bigtable-creds", "/bigtable-nessie")); + volumes.add( + new VolumeBuilder() + .withName("bigtable-creds") + .withSecret( + new SecretVolumeSourceBuilder() + .withSecretName(bigtable.credentials().secretRef().getName()) + .withItems( + new KeyToPathBuilder() + .withKey(bigtable.credentials().serviceAccountKey()) + .withPath("sa_credentials.json") + .build()) + .build()) + .build()); + } + + private static void configureMongo(Nessie nessie, Container container) { + MongoDb mongodb = nessie.getSpec().versionStore().mongodb(); + container.getEnv().add(envVar("QUARKUS_MONGODB_DATABASE", mongodb.name())); + container.getEnv().add(envVar("QUARKUS_MONGODB_CONNECTION_STRING", mongodb.connectionString())); + NessieCredentials credentials = mongodb.credentials(); + if (credentials != null) { + container + .getEnv() + .add( + envVar( + "QUARKUS_MONGODB_CREDENTIALS_USERNAME", + credentials.secretRef(), + credentials.usernameKey())); + container + .getEnv() + .add( + envVar( + "QUARKUS_MONGODB_CREDENTIALS_PASSWORD", + credentials.secretRef(), + credentials.passwordKey())); + } + } + + private static void configureCassandra(Nessie nessie, Container container) { + Cassandra cassandra = nessie.getSpec().versionStore().cassandra(); + container.getEnv().add(envVar("QUARKUS_CASSANDRA_KEYSPACE", cassandra.keyspace())); + container + .getEnv() + .add( + envVar( + "QUARKUS_CASSANDRA_CONTACT_POINTS", + cassandra.contactPoints().stream().reduce((a, b) -> a + "," + b).orElse(""))); + container + .getEnv() + .add(envVar("QUARKUS_CASSANDRA_LOCAL_DATACENTER", cassandra.localDatacenter())); + NessieCredentials credentials = cassandra.credentials(); + if (credentials != null) { + container + .getEnv() + .add( + envVar( + "QUARKUS_CASSANDRA_AUTH_USERNAME", + credentials.secretRef(), + credentials.usernameKey())); + container + .getEnv() + .add( + envVar( + "QUARKUS_CASSANDRA_AUTH_PASSWORD", + credentials.secretRef(), + credentials.passwordKey())); + } + } + + private static void configureDynamo(Nessie nessie, Container container) { + DynamoDb dynamodb = nessie.getSpec().versionStore().dynamodb(); + container.getEnv().add(envVar("AWS_REGION", dynamodb.region())); + NessieAwsCredentials credentials = dynamodb.credentials(); + container + .getEnv() + .add(envVar("AWS_ACCESS_KEY_ID", credentials.secretRef(), credentials.awsAccessKeyId())); + container + .getEnv() + .add( + envVar( + "AWS_SECRET_ACCESS_KEY", + credentials.secretRef(), + credentials.awsSecretAccessKey())); + } + + private void configureExtraEnv(Nessie nessie, Deployment deployment) { + List envVars = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + if (nessie.getSpec().extraEnv() != null && !nessie.getSpec().extraEnv().isEmpty()) { + envVars.addAll(nessie.getSpec().extraEnv()); + } + } + + private void configureAdvancedConfig(Nessie nessie, Deployment deployment) { + JsonNode advancedConfig = nessie.getSpec().advancedConfig(); + if (advancedConfig != null && !advancedConfig.isEmpty()) { + applyAdvancedConfig( + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(), + advancedConfig, + ""); + } + } + + private static void applyAdvancedConfig( + List envVars, JsonNode configNode, String prefix) { + for (Map.Entry entry : configNode.properties()) { + String key = prefix + entry.getKey(); + JsonNode value = entry.getValue(); + if (value instanceof ObjectNode) { + applyAdvancedConfig(envVars, value, key + "."); + } else if (value.isValueNode()) { + String envVarName = toEnvVarName(key); + envVars.add(envVar(envVarName, value.asText())); + } else { + throw new IllegalArgumentException( + "Advanced config value %s: expected object or scalar, got %s" + .formatted(key, value.getNodeType())); + } + } + } + + private static String toEnvVarName(String key) { + return key.toUpperCase().replace("\"", "_").replace(".", "_").replace("-", "_"); + } + + private static EnvVar envVar(String name, String value) { + return new EnvVarBuilder().withName(name).withValue(value).build(); + } + + private static EnvVar envVar(String name, LocalObjectReference secretRef, String key) { + return new EnvVarBuilder() + .withName(name) + .withValueFrom( + new EnvVarSourceBuilder() + .withSecretKeyRef( + new SecretKeySelectorBuilder() + .withName(secretRef.getName()) + .withKey(key) + .build()) + .build()) + .build(); + } + + private static VolumeMount volumeMount(String name, String mountPath) { + return new VolumeMountBuilder().withName(name).withMountPath(mountPath).build(); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta1Dependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta1Dependent.java new file mode 100644 index 00000000000..e45003fc78e --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta1Dependent.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.CrossVersionObjectReferenceBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.HorizontalPodAutoscaler; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.HorizontalPodAutoscalerBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.HorizontalPodAutoscalerSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.MetricSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.MetricSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta1.ResourceMetricSourceBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieAutoscaling; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class HorizontalPodAutoscalerV2Beta1Dependent + extends AbstractHorizontalPodAutoscalerDependent { + + public HorizontalPodAutoscalerV2Beta1Dependent() { + super(HorizontalPodAutoscaler.class); + } + + @Override + protected HorizontalPodAutoscaler desired(Nessie nessie, Context context) { + NessieAutoscaling autoscaling = nessie.getSpec().autoscaling(); + checkAutoscalingConfig(autoscaling); + HorizontalPodAutoscalerSpecBuilder specBuilder = + new HorizontalPodAutoscalerSpecBuilder() + .withScaleTargetRef( + new CrossVersionObjectReferenceBuilder() + .withApiVersion("apps/v1") + .withKind("Deployment") + .withName(nessie.getMetadata().getName()) + .build()) + .withMinReplicas(autoscaling.minReplicas()) + .withMaxReplicas(autoscaling.maxReplicas()); + if (autoscaling.targetCpuUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("cpu", autoscaling.targetCpuUtilizationPercentage())); + } + if (autoscaling.targetMemoryUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("memory", autoscaling.targetMemoryUtilizationPercentage())); + } + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return new HorizontalPodAutoscalerBuilder() + .withMetadata(helper.metaBuilder(nessie).build()) + .withSpec(specBuilder.build()) + .build(); + } + + private static MetricSpec metric(String name, int percentage) { + return new MetricSpecBuilder() + .withType("Resource") + .withResource( + new ResourceMetricSourceBuilder() + .withName(name) + .withTargetAverageUtilization(percentage) + .build()) + .build(); + } + + public static class ActivationCondition + extends AbstractHorizontalPodAutoscalerDependent.ActivationCondition< + HorizontalPodAutoscaler> { + + @Override + protected boolean isAutoscalingSupported(KubernetesHelper helper) { + return helper.isAutoscalingV2Beta1Supported(); + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta2Dependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta2Dependent.java new file mode 100644 index 00000000000..7bf9dcddff0 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Beta2Dependent.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.CrossVersionObjectReferenceBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.HorizontalPodAutoscaler; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.HorizontalPodAutoscalerBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.HorizontalPodAutoscalerSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.MetricSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.MetricSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.MetricTargetBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2beta2.ResourceMetricSourceBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieAutoscaling; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class HorizontalPodAutoscalerV2Beta2Dependent + extends AbstractHorizontalPodAutoscalerDependent { + + public HorizontalPodAutoscalerV2Beta2Dependent() { + super(HorizontalPodAutoscaler.class); + } + + @Override + protected HorizontalPodAutoscaler desired(Nessie nessie, Context context) { + NessieAutoscaling autoscaling = nessie.getSpec().autoscaling(); + checkAutoscalingConfig(autoscaling); + HorizontalPodAutoscalerSpecBuilder specBuilder = + new HorizontalPodAutoscalerSpecBuilder() + .withScaleTargetRef( + new CrossVersionObjectReferenceBuilder() + .withApiVersion("apps/v1") + .withKind("Deployment") + .withName(nessie.getMetadata().getName()) + .build()) + .withMinReplicas(autoscaling.minReplicas()) + .withMaxReplicas(autoscaling.maxReplicas()); + if (autoscaling.targetCpuUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("cpu", autoscaling.targetCpuUtilizationPercentage())); + } + if (autoscaling.targetMemoryUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("memory", autoscaling.targetMemoryUtilizationPercentage())); + } + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return new HorizontalPodAutoscalerBuilder() + .withMetadata(helper.metaBuilder(nessie).build()) + .withSpec(specBuilder.build()) + .build(); + } + + private static MetricSpec metric(String name, int percentage) { + return new MetricSpecBuilder() + .withType("Resource") + .withResource( + new ResourceMetricSourceBuilder() + .withName(name) + .withTarget( + new MetricTargetBuilder() + .withType("Utilization") + .withAverageUtilization(percentage) + .build()) + .build()) + .build(); + } + + public static class ActivationCondition + extends AbstractHorizontalPodAutoscalerDependent.ActivationCondition< + HorizontalPodAutoscaler> { + + @Override + protected boolean isAutoscalingSupported(KubernetesHelper helper) { + return helper.isAutoscalingV2Beta2Supported(); + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Dependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Dependent.java new file mode 100644 index 00000000000..ea9478d7777 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/HorizontalPodAutoscalerV2Dependent.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.autoscaling.v2.CrossVersionObjectReferenceBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscalerBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscalerSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpecBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricTargetBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.ResourceMetricSourceBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieAutoscaling; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class HorizontalPodAutoscalerV2Dependent + extends AbstractHorizontalPodAutoscalerDependent { + + public HorizontalPodAutoscalerV2Dependent() { + super(HorizontalPodAutoscaler.class); + } + + @Override + protected HorizontalPodAutoscaler desired(Nessie nessie, Context context) { + NessieAutoscaling autoscaling = nessie.getSpec().autoscaling(); + checkAutoscalingConfig(autoscaling); + HorizontalPodAutoscalerSpecBuilder specBuilder = + new HorizontalPodAutoscalerSpecBuilder() + .withScaleTargetRef( + new CrossVersionObjectReferenceBuilder() + .withApiVersion("apps/v1") + .withKind("Deployment") + .withName(nessie.getMetadata().getName()) + .build()) + .withMinReplicas(autoscaling.minReplicas()) + .withMaxReplicas(autoscaling.maxReplicas()); + if (autoscaling.targetCpuUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("cpu", autoscaling.targetCpuUtilizationPercentage())); + } + if (autoscaling.targetMemoryUtilizationPercentage() > 0) { + specBuilder.withMetrics(metric("memory", autoscaling.targetMemoryUtilizationPercentage())); + } + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return new HorizontalPodAutoscalerBuilder() + .withMetadata(helper.metaBuilder(nessie).build()) + .withSpec(specBuilder.build()) + .build(); + } + + private static MetricSpec metric(String name, int percentage) { + return new MetricSpecBuilder() + .withType("Resource") + .withResource( + new ResourceMetricSourceBuilder() + .withName(name) + .withTarget( + new MetricTargetBuilder() + .withType("Utilization") + .withAverageUtilization(percentage) + .build()) + .build()) + .build(); + } + + public static class ActivationCondition + extends AbstractHorizontalPodAutoscalerDependent.ActivationCondition< + HorizontalPodAutoscaler> { + + @Override + protected boolean isAutoscalingSupported(KubernetesHelper helper) { + return helper.isAutoscalingV2Supported(); + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Beta1Dependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Beta1Dependent.java new file mode 100644 index 00000000000..5ed3951ae55 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Beta1Dependent.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.networking.v1beta1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressBuilder; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressLoadBalancerIngress; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressRuleBuilder; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressSpecBuilder; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressStatus; +import io.fabric8.kubernetes.api.model.networking.v1beta1.IngressTLSBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import java.util.List; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieIngress; +import org.projectnessie.operator.resource.NessieIngress.Rule; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class IngressV1Beta1Dependent extends AbstractIngressDependent { + + public IngressV1Beta1Dependent() { + super(Ingress.class); + } + + @Override + public Ingress desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + ObjectMeta metadata = + helper + .metaBuilder(nessie) + .withAnnotations(nessie.getSpec().ingress().annotations()) + .build(); + Ingress ingress = + new IngressBuilder() + .withMetadata(metadata) + .withSpec( + new IngressSpecBuilder() + .withIngressClassName(nessie.getSpec().ingress().ingressClassName()) + .build()) + .build(); + configureRules(ingress, nessie); + configureTls(ingress, nessie); + return ingress; + } + + private void configureRules(Ingress ingress, Nessie nessie) { + List rules = nessie.getSpec().ingress().rules(); + if (rules.isEmpty()) { + throw new IllegalArgumentException("At least one ingress rule must be specified"); + } + for (Rule rule : rules) { + IngressRuleBuilder ruleBuilder = new IngressRuleBuilder(); + ruleBuilder.withHost(rule.host()); + for (String path : rule.paths()) { + ruleBuilder + .withNewHttp() + .withPaths() + .addNewPath() + .withPath(path) + .withPathType("ImplementationSpecific") + .withNewBackend() + .withServiceName(nessie.getMetadata().getName()) + .withNewServicePort() + .withValue(nessie.getSpec().service().port()) + .endServicePort() + .endBackend() + .endPath() + .endHttp(); + } + ingress.getSpec().getRules().add(ruleBuilder.build()); + } + } + + private void configureTls(Ingress ingress, Nessie nessie) { + if (nessie.getSpec().ingress().tls() != null && !nessie.getSpec().ingress().tls().isEmpty()) { + for (NessieIngress.Tls tls : nessie.getSpec().ingress().tls()) { + IngressTLSBuilder tlsBuilder = new IngressTLSBuilder(); + tlsBuilder.withHosts(tls.hosts()); + tlsBuilder.withSecretName(tls.secretRef().getName()); + ingress.getSpec().getTls().add(tlsBuilder.build()); + } + } + } + + public static String getExposedUrl(Ingress ingress) { + IngressLoadBalancerIngress ing = ingress.getStatus().getLoadBalancer().getIngress().get(0); + return "https://" + (ing.getHostname() != null ? ing.getHostname() : ing.getIp()); + } + + public static void updateStatus(Nessie nessie, Context context) { + context + .getSecondaryResource(Ingress.class) + .ifPresentOrElse( + ingress -> nessie.getStatus().setExposedUrl(getExposedUrl(ingress)), + () -> nessie.getStatus().setExposedUrl(null)); + } + + public static class ActivationCondition + extends AbstractIngressDependent.ActivationCondition { + + public ActivationCondition() { + super("v1beta1"); + } + } + + public static class ReadyCondition extends AbstractIngressDependent.ReadyCondition { + + public ReadyCondition() { + super(Ingress.class); + } + + @Override + protected boolean checkIngressReady(Ingress ingress) { + IngressStatus status = ingress.getStatus(); + if (status != null) { + List ingresses = status.getLoadBalancer().getIngress(); + return ingresses != null && !ingresses.isEmpty(); + } + return false; + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Dependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Dependent.java new file mode 100644 index 00000000000..3a755bd1bca --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/IngressV1Dependent.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressLoadBalancerIngress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressRuleBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressSpecBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressStatus; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLSBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import java.util.List; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieIngress; +import org.projectnessie.operator.resource.NessieIngress.Rule; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class IngressV1Dependent extends CRUDKubernetesDependentResource { + + public IngressV1Dependent() { + super(Ingress.class); + } + + @Override + public Ingress desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + ObjectMeta metadata = + helper + .metaBuilder(nessie) + .withAnnotations(nessie.getSpec().ingress().annotations()) + .build(); + Ingress ingress = + new IngressBuilder() + .withMetadata(metadata) + .withSpec( + new IngressSpecBuilder() + .withIngressClassName(nessie.getSpec().ingress().ingressClassName()) + .build()) + .build(); + configureRules(ingress, nessie); + configureTls(ingress, nessie); + return ingress; + } + + private void configureRules(Ingress ingress, Nessie nessie) { + List rules = nessie.getSpec().ingress().rules(); + if (rules.isEmpty()) { + throw new IllegalArgumentException("At least one ingress rule must be specified"); + } + for (Rule rule : rules) { + IngressRuleBuilder ruleBuilder = new IngressRuleBuilder(); + ruleBuilder.withHost(rule.host()); + for (String path : rule.paths()) { + ruleBuilder + .withNewHttp() + .withPaths() + .addNewPath() + .withPath(path) + .withPathType("ImplementationSpecific") + .withNewBackend() + .withNewService() + .withName(nessie.getMetadata().getName()) + .withNewPort() + .withNumber(nessie.getSpec().service().port()) + .endPort() + .endService() + .endBackend() + .endPath() + .endHttp(); + } + ingress.getSpec().getRules().add(ruleBuilder.build()); + } + } + + private void configureTls(Ingress ingress, Nessie nessie) { + if (nessie.getSpec().ingress().tls() != null && !nessie.getSpec().ingress().tls().isEmpty()) { + for (NessieIngress.Tls tls : nessie.getSpec().ingress().tls()) { + IngressTLSBuilder tlsBuilder = new IngressTLSBuilder(); + tlsBuilder.withHosts(tls.hosts()); + tlsBuilder.withSecretName(tls.secretRef().getName()); + ingress.getSpec().getTls().add(tlsBuilder.build()); + } + } + } + + public static String getExposedUrl(Ingress ingress) { + IngressLoadBalancerIngress ing = ingress.getStatus().getLoadBalancer().getIngress().get(0); + return "https://" + (ing.getHostname() != null ? ing.getHostname() : ing.getIp()); + } + + public static void updateStatus(Nessie nessie, Context context) { + context + .getSecondaryResource(Ingress.class) + .ifPresentOrElse( + ingress -> nessie.getStatus().setExposedUrl(getExposedUrl(ingress)), + () -> nessie.getStatus().setExposedUrl(null)); + } + + public static class ActivationCondition + extends AbstractIngressDependent.ActivationCondition { + + public ActivationCondition() { + super("v1"); + } + } + + public static class ReadyCondition extends AbstractIngressDependent.ReadyCondition { + + public ReadyCondition() { + super(Ingress.class); + } + + @Override + protected boolean checkIngressReady(Ingress ingress) { + IngressStatus status = ingress.getStatus(); + if (status != null) { + List ingresses = status.getLoadBalancer().getIngress(); + return ingresses != null && !ingresses.isEmpty(); + } + return false; + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/PersistentVolumeClaimDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/PersistentVolumeClaimDependent.java new file mode 100644 index 00000000000..eef241efeab --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/PersistentVolumeClaimDependent.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.LabelSelectorBuilder; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaim; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaimBuilder; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaimSpec; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaimSpecBuilder; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaimStatus; +import io.fabric8.kubernetes.api.model.VolumeResourceRequirementsBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import java.util.Map; +import java.util.Objects; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieVersionStore.RocksDb; +import org.projectnessie.operator.resource.NessieVersionStore.VersionStoreType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class PersistentVolumeClaimDependent + extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = + LoggerFactory.getLogger(PersistentVolumeClaimDependent.class); + + public PersistentVolumeClaimDependent() { + super(PersistentVolumeClaim.class); + } + + @Override + public PersistentVolumeClaim create( + PersistentVolumeClaim desired, Nessie nessie, Context context) { + LOGGER.debug("Creating pvc for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent(nessie, EventType.Normal, "CreatingPVC", "Creating pvc"); + return super.create(desired, nessie, context); + } + + @Override + public PersistentVolumeClaim desired(Nessie nessie, Context context) { + RocksDb rocksdb = nessie.getSpec().versionStore().rocksdb(); + Objects.requireNonNull(rocksdb, "rocksdb config must not be null"); + PersistentVolumeClaimSpec volumeClaimSpec = + new PersistentVolumeClaimSpecBuilder() + .withAccessModes("ReadWriteOnce") + .withStorageClassName(rocksdb.storageClassName()) + .withResources( + new VolumeResourceRequirementsBuilder() + .withRequests(Map.of("storage", rocksdb.storageSize())) + .build()) + .build(); + if (rocksdb.selectorLabels() != null && !rocksdb.selectorLabels().isEmpty()) { + volumeClaimSpec.setSelector( + new LabelSelectorBuilder().withMatchLabels(rocksdb.selectorLabels()).build()); + } + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return new PersistentVolumeClaimBuilder() + .withMetadata(helper.metaBuilder(nessie).build()) + .withSpec(volumeClaimSpec) + .build(); + } + + @Override + public Result match( + PersistentVolumeClaim actualResource, Nessie nessie, Context context) { + return Result.nonComputed(true); + } + + public static boolean isPvcBound(Context context) { + return context + .getSecondaryResource(PersistentVolumeClaim.class) + .map(PersistentVolumeClaim::getStatus) + .map(PersistentVolumeClaimStatus::getPhase) + .map(phase -> phase.equals("Bound")) + .orElse(false); + } + + public static class ActivationCondition implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + Nessie nessie, + Context context) { + boolean met = nessie.getSpec().versionStore().type() == VersionStoreType.RocksDb; + LOGGER.debug("PVC activation condition met: {}", met); + return met; + } + } + + public static class ReadyCondition implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + Nessie nessie, + Context context) { + boolean conditionMet = isPvcBound(context); + LOGGER.debug("PVC is ready: {}", conditionMet); + return conditionMet; + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/ServiceAccountDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceAccountDependent.java new file mode 100644 index 00000000000..c4d02c9fad2 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceAccountDependent.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.ServiceAccount; +import io.fabric8.kubernetes.api.model.ServiceAccountBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieServiceAccount; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class ServiceAccountDependent + extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(ServiceAccountDependent.class); + + public ServiceAccountDependent() { + super(ServiceAccount.class); + } + + @Override + public ServiceAccount create(ServiceAccount desired, Nessie nessie, Context context) { + LOGGER.debug("Creating service account for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent( + nessie, EventType.Normal, "CreatingServiceAccount", "Creating service account"); + return super.create(desired, nessie, context); + } + + @Override + public ServiceAccount desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + NessieServiceAccount serviceAccount = nessie.getSpec().serviceAccount(); + ObjectMeta metadata = + helper + .metaBuilder(nessie, getServiceAccountName(nessie)) + .withAnnotations(serviceAccount.annotations()) + .build(); + return new ServiceAccountBuilder().withMetadata(metadata).build(); + } + + public static String getServiceAccountName(Nessie nessie) { + NessieServiceAccount serviceAccount = nessie.getSpec().serviceAccount(); + if (serviceAccount.name() != null) { + return serviceAccount.name(); + } else if (serviceAccount.create()) { + return nessie.getMetadata().getName(); + } else { + return "default"; + } + } + + public static class ActivationCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + Nessie nessie, + Context context) { + return nessie.getSpec().serviceAccount().create(); + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/ServiceDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceDependent.java new file mode 100644 index 00000000000..806ce14e9c0 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceDependent.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.ServiceBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.projectnessie.operator.resource.NessieService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class ServiceDependent extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(ServiceDependent.class); + + public ServiceDependent() { + super(Service.class); + } + + @Override + public Service create(Service desired, Nessie nessie, Context context) { + LOGGER.debug("Creating service for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent(nessie, EventType.Normal, "CreatingService", "Creating service"); + return super.create(desired, nessie, context); + } + + @Override + public Service desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + NessieService service = nessie.getSpec().service(); + return new ServiceBuilder() + .withMetadata(helper.metaBuilder(nessie).build()) + .withNewSpec() + .withType(service.type().name()) + .addNewPort() + .withName("nessie-server") + .withProtocol("TCP") + .withPort(service.port()) + .withNewTargetPort() + .withValue(NessieService.DEFAULT_PORT) + .endTargetPort() + .endPort() + .withSelector(helper.selectorLabels(nessie)) + .endSpec() + .build(); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/dependent/ServiceMonitorDependent.java b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceMonitorDependent.java new file mode 100644 index 00000000000..aace78b7a96 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/dependent/ServiceMonitorDependent.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.dependent; + +import static org.projectnessie.operator.helper.EventsHelper.EventType.Warning; + +import io.fabric8.openshift.api.model.monitoring.v1.ServiceMonitor; +import io.fabric8.openshift.api.model.monitoring.v1.ServiceMonitorBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import org.projectnessie.operator.NessieReconciler; +import org.projectnessie.operator.helper.EventsHelper; +import org.projectnessie.operator.helper.EventsHelper.EventType; +import org.projectnessie.operator.helper.KubernetesHelper; +import org.projectnessie.operator.resource.Nessie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@KubernetesDependent(labelSelector = NessieReconciler.DEPENDENT_RESOURCES_SELECTOR) +public class ServiceMonitorDependent + extends CRUDKubernetesDependentResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(ServiceMonitorDependent.class); + + public ServiceMonitorDependent() { + super(ServiceMonitor.class); + } + + @Override + public ServiceMonitor create(ServiceMonitor desired, Nessie nessie, Context context) { + LOGGER.debug("Creating service monitor for {}", nessie.getMetadata().getName()); + EventsHelper helper = EventsHelper.retrieveFromContext(context); + helper.fireEvent( + nessie, EventType.Normal, "CreatingServiceMonitor", "Creating service monitor"); + return super.create(desired, nessie, context); + } + + @Override + public ServiceMonitor desired(Nessie nessie, Context context) { + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + return new ServiceMonitorBuilder() + .withMetadata( + helper + .metaBuilder(nessie) + .addToLabels(nessie.getSpec().serviceMonitor().labels()) + .build()) + .withNewSpec() + .addNewEndpoint() + .withPort("nessie-server") + .withScheme("http") + .withInterval(nessie.getSpec().serviceMonitor().interval()) + .withPath("/q/metrics") + .endEndpoint() + .withNewNamespaceSelector() + .withMatchNames(nessie.getMetadata().getNamespace()) + .endNamespaceSelector() + .withNewSelector() + .withMatchLabels(helper.selectorLabels(nessie)) + .endSelector() + .endSpec() + .build(); + } + + public static class ActivationCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + Nessie nessie, + Context context) { + boolean conditionMet = nessie.getSpec().serviceMonitor().enabled(); + KubernetesHelper helper = KubernetesHelper.retrieveFromContext(context); + if (conditionMet && !helper.isMonitoringSupported()) { + String msg = "Service monitor creation requested, but monitoring is not supported"; + EventsHelper.retrieveFromContext(context) + .fireEvent(nessie, Warning, "ServiceMonitorNotSupported", msg); + conditionMet = false; + } + return conditionMet; + } + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/helper/EventsHelper.java b/operator/src/main/java/org/projectnessie/operator/helper/EventsHelper.java new file mode 100644 index 00000000000..3c8a927b81c --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/helper/EventsHelper.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.helper; + +import io.fabric8.kubernetes.api.model.Event; +import io.fabric8.kubernetes.api.model.EventBuilder; +import io.fabric8.kubernetes.api.model.EventSource; +import io.fabric8.kubernetes.api.model.MicroTime; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.ObjectReferenceBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.NonDeletingOperation; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import org.projectnessie.operator.resource.Nessie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helper class to create and update events. + * + *

Events are created for each Nessie resource and each reason. The event is updated when the + * reason is triggered again for the same resource. + * + *

Loosely inspired from event_broadcaster.go. + */ +@ApplicationScoped +public class EventsHelper { + + public enum EventType { + Normal, + Warning + } + + private static final String HELPER_CONTEXT_KEY = "events-helper"; + + public static EventsHelper retrieveFromContext(Context context) { + return context + .managedDependentResourceContext() + .getMandatory(HELPER_CONTEXT_KEY, EventsHelper.class); + } + + public static void storeInContext(Context context, EventsHelper eventsHelper) { + context.managedDependentResourceContext().put(HELPER_CONTEXT_KEY, eventsHelper); + } + + private static final Logger LOGGER = LoggerFactory.getLogger(EventsHelper.class); + + // Guarded by this + private final Map> eventsCache = new HashMap<>(); + private final KubernetesClient client; + + @Inject + public EventsHelper(KubernetesClient client) { + this.client = client; + } + + public synchronized void clearEvents(Nessie nessie) { + eventsCache.remove(nessie.getMetadata().getUid()); + } + + public synchronized void fireEvent( + Nessie nessie, EventType type, String reason, String message, Object... args) { + ZonedDateTime now = ZonedDateTime.now(); + String timestamp = TimeUtils.formatTime(now); + MicroTime microTime = new MicroTime(TimeUtils.formatMicroTime(now)); + Event event = + eventsCache + .computeIfAbsent(nessie.getMetadata().getUid(), k -> new HashMap<>()) + .get(reason); + String formatted = formatAndTrim(message, args); + if (event == null) { + String eventName = "nessie-" + nessie.getMetadata().getUid() + "-" + reason; + event = + new EventBuilder() + .withMetadata(new ObjectMetaBuilder().withName(eventName).build()) + .withEventTime(microTime) + .withType(type.name()) + .withReason(reason) + .withMessage(formatted) + .withAction("Reconcile") + .withCount(1) + .withFirstTimestamp(timestamp) + .withLastTimestamp(timestamp) + .withInvolvedObject( + new ObjectReferenceBuilder() + .withName(nessie.getMetadata().getName()) + .withNamespace(nessie.getMetadata().getNamespace()) + .withUid(nessie.getMetadata().getUid()) + .withResourceVersion(nessie.getMetadata().getResourceVersion()) + .withApiVersion(Nessie.API_VERSION) + .withKind(Nessie.KIND) + .build()) + .withSource(new EventSource("projectnessie.org/nessie-reconciler", null)) + .withReportingComponent("projectnessie.org/nessie-reconciler") + .withReportingInstance("nessie-operator") + .build(); + LOGGER.debug("Creating event {}", event.getMetadata().getName()); + try { + client.v1().events().resource(event).createOr(NonDeletingOperation::patch); + eventsCache.get(nessie.getMetadata().getUid()).put(reason, event); + } catch (Exception e) { + LOGGER.warn("Failed to create event {}", event, e); + } + } else { + try { + Event updated = + client + .v1() + .events() + .resource(event) + .edit( + e -> { + int count = e.getSeries() == null ? 2 : e.getSeries().getCount() + 1; + LOGGER.debug( + "Updating event {}, count = {}", e.getMetadata().getName(), count); + return new EventBuilder(e) + .withMessage(formatted) + .withLastTimestamp(timestamp) + .withCount(count) + .withNewSeries() + .withCount(count) + .withLastObservedTime(microTime) + .endSeries() + .build(); + }); + eventsCache.get(nessie.getMetadata().getUid()).put(reason, updated); + } catch (Exception e) { + LOGGER.warn("Failed to update event {}", event, e); + } + } + } + + public void fireErrorEvent(Nessie nessie, Exception error) { + fireEvent(nessie, EventType.Warning, "ReconcileError", error.getMessage()); + } + + private static String formatAndTrim(String message, Object[] args) { + // Message is limited to 1024 characters in practice + message = String.format(message, args); + if (message.length() > 1024) { + message = message.substring(0, 1024); + } + return message; + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/helper/KubernetesHelper.java b/operator/src/main/java/org/projectnessie/operator/helper/KubernetesHelper.java new file mode 100644 index 00000000000..53892c93974 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/helper/KubernetesHelper.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.helper; + +import io.fabric8.kubernetes.api.model.APIGroup; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.VersionInfo; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.quarkus.runtime.Startup; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import java.util.Map; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.projectnessie.operator.resource.Nessie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Dependent +public final class KubernetesHelper { + + private static final Logger LOGGER = LoggerFactory.getLogger(KubernetesHelper.class); + + private static final String HELPER_CONTEXT_KEY = "kube-helper"; + + public static KubernetesHelper retrieveFromContext(Context context) { + return context + .managedDependentResourceContext() + .getMandatory(HELPER_CONTEXT_KEY, KubernetesHelper.class); + } + + public static void storeInContext(Context context, KubernetesHelper kubernetesHelper) { + context.managedDependentResourceContext().put(HELPER_CONTEXT_KEY, kubernetesHelper); + } + + private final KubernetesClient client; + private final String operatorVersion; + + @Inject + public KubernetesHelper( + KubernetesClient client, + @ConfigProperty(name = "quarkus.application.version") String operatorVersion) { + this.client = client; + this.operatorVersion = operatorVersion; + } + + @Startup + public void logStartupInfo() { + LOGGER.info("Nessie operator version: {}", getOperatorVersion()); + LOGGER.info( + "Kubernetes cluster version: {}.{}", + getKubernetesVersion().getMajor(), + getKubernetesVersion().getMinor()); + } + + public VersionInfo getKubernetesVersion() { + return client.getKubernetesVersion(); + } + + public String getOperatorVersion() { + return operatorVersion; + } + + /** + * Create metadata for a dependent resource. The dependent resource name will be identical to the + * primary resource name. + */ + public ObjectMetaBuilder metaBuilder(Nessie nessie) { + return metaBuilder(nessie, nessie.getMetadata().getName()); + } + + /** + * Create metadata for a dependent resource with the given name and all recommended meta labels. + * + * @see Recommended + * Labels + */ + public ObjectMetaBuilder metaBuilder(Nessie nessie, String name) { + ObjectMeta metadata = nessie.getMetadata(); + return new ObjectMetaBuilder() + .withName(name) + .withNamespace(metadata.getNamespace()) + .withLabels(selectorLabels(nessie)) + .addToLabels( + Map.of( + "app.kubernetes.io/version", + operatorVersion, + "app.kubernetes.io/component", + "nessie", + "app.kubernetes.io/part-of", + "nessie", + "app.kubernetes.io/managed-by", + "nessie-operator")); + } + + /** Create selector labels for the given Nessie resource. */ + public Map selectorLabels(Nessie nessie) { + return Map.of( + "app.kubernetes.io/name", + "nessie", + "app.kubernetes.io/instance", + nessie.getMetadata().getName()); + } + + public boolean isApiSupported(String apiGroup, String apiVersion) { + APIGroup group = client.getApiGroup(apiGroup); + boolean supported = false; + if (group != null) { + supported = group.getVersions().stream().anyMatch(v -> v.getVersion().equals(apiVersion)); + } + LOGGER.debug("API {}/{} supported: {}", apiGroup, apiVersion, supported); + return supported; + } + + public boolean isMonitoringSupported() { + return isApiSupported("monitoring.coreos.com", "v1"); + } + + public boolean isIngressV1Supported() { + return isApiSupported("networking.k8s.io", "v1"); + } + + public boolean isIngressV1Beta1Supported() { + return !isIngressV1Supported() && isApiSupported("networking.k8s.io", "v1beta1"); + } + + public boolean isAutoscalingV2Supported() { + return isApiSupported("autoscaling", "v2"); + } + + public boolean isAutoscalingV2Beta2Supported() { + return !isAutoscalingV2Supported() && isApiSupported("autoscaling", "v2beta2"); + } + + public boolean isAutoscalingV2Beta1Supported() { + return !isAutoscalingV2Beta2Supported() && isApiSupported("autoscaling", "v2beta1"); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/helper/TimeUtils.java b/operator/src/main/java/org/projectnessie/operator/helper/TimeUtils.java new file mode 100644 index 00000000000..6198873100e --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/helper/TimeUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.helper; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +public final class TimeUtils { + + private TimeUtils() {} + + /** + * A formatter that is compliant with the Kubernetes API server's expectations for the Time v1 + * type. + * + *

Kubernetes expects Time to be formatted as RFC 3339 with a time zone offset, or 'Z'. The Go + * constant definition is: + * + *

+   *   const RFC3339 = "2006-01-02T15:04:05Z07:00"
+   * 
+ * + * @see Time + * v1 + * @see Go time package constants + * @see Kubernetes + * time.go + */ + private static final DateTimeFormatter TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"); + + /** + * A formatter that is compliant with the Kubernetes API server's expectations for the MicroTime + * v1 type. MicroTime is a version of Time with microsecond-level precision. + * + *

Kubernetes expects MicroTime to be formatted as RFC 3339 with a fractional seconds part and + * a time zone offset, or 'Z'. The Go constant definition is: + * + *

+   *   const RFC3339Micro = "2006-01-02T15:04:05.000000Z07:00"
+   * 
+ * + * @see MicroTime + * v1 + * @see Go time package constants + * @see Kubernetes + * micro_time.go + */ + private static final DateTimeFormatter MICRO_TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX"); + + public static String formatTime(ZonedDateTime zdt) { + return TIME.format(zdt); + } + + public static String formatMicroTime(ZonedDateTime zdt) { + return MICRO_TIME.format(zdt); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/Nessie.java b/operator/src/main/java/org/projectnessie/operator/resource/Nessie.java new file mode 100644 index 00000000000..618f262ddba --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/Nessie.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import io.sundr.builder.annotations.Buildable; +import io.sundr.builder.annotations.BuildableReference; + +@Version(Nessie.VERSION) +@Group(Nessie.GROUP) +@Buildable( + builderPackage = "io.fabric8.kubernetes.api.builder", + editableEnabled = false, + refs = { + @BuildableReference(ObjectMeta.class), + @BuildableReference(CustomResource.class), + }) +public class Nessie extends CustomResource implements Namespaced { + + public static final String GROUP = "nessie.projectnessie.org"; + public static final String VERSION = "v1alpha1"; + public static final String KIND = "Nessie"; + public static final String PLURAL = "nessies"; + public static final String NAME = PLURAL + "." + GROUP; + public static final String API_VERSION = GROUP + "/" + VERSION; + + @JsonIgnore + public NessieStatus getOrCreateStatus() { + NessieStatus status = getStatus(); + if (status == null) { + status = new NessieStatus(); + setStatus(status); + } + return status; + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthentication.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthentication.java new file mode 100644 index 00000000000..d7630e5ff37 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthentication.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Nullable; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieAuthentication( + @JsonPropertyDescription( + "Specifies whether authentication for the nessie server should be enabled.") + @PrinterColumn(name = "AUTHN", priority = 1) + boolean enabled, + @JsonPropertyDescription( + "Sets the base URL of the OpenID Connect (OIDC) server. Required if authentication is enabled.") + @Nullable + @jakarta.annotation.Nullable + String oidcAuthServerUrl, + @JsonPropertyDescription( + "OIDC client ID to use when authentication is enabled, in order to identify the application.") + @Default("nessie") + String oidcClientId) { + + public NessieAuthentication() { + this(false, null, "nessie"); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthorization.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthorization.java new file mode 100644 index 00000000000..00914356ab3 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieAuthorization.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.sundr.builder.annotations.Buildable; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieAuthorization( + @JsonPropertyDescription( + "Specifies whether authorization for the Nessie server should be enabled.") + @Default("false") + @PrinterColumn(name = "AUTHZ", priority = 1) + boolean enabled, + @JsonPropertyDescription( + """ + The authorization rules when authorization.enabled=true. \ + Example rules can be found at \ + https://projectnessie.org/features/metadata_authorization/#authorization-rules""") + @Default("{}") + Map rules) { + + public NessieAuthorization() { + this(false, Map.of()); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieAutoscaling.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieAutoscaling.java new file mode 100644 index 00000000000..261c306847e --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieAutoscaling.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieAutoscaling( + @JsonPropertyDescription( + """ + Specifies whether automatic horizontal scaling should be enabled. \ + Do not enable this when using InMemory or RocksDb version store type.""") + @Default("false") + boolean enabled, + @JsonPropertyDescription("The minimum number of replicas to maintain.") @Default("1") + int minReplicas, + @JsonPropertyDescription("The maximum number of replicas to maintain.") @Default("3") + int maxReplicas, + @JsonPropertyDescription( + "The target CPU utilization percentage. Set to zero or empty to disable.") + @Default("80") + int targetCpuUtilizationPercentage, + @JsonPropertyDescription( + "The target memory utilization percentage. Set to zero or empty to disable.") + @Default("0") + int targetMemoryUtilizationPercentage) { + + public NessieAutoscaling() { + this(false, 1, 3, 80, 0); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieAwsCredentials.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieAwsCredentials.java new file mode 100644 index 00000000000..beaca0bb5a2 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieAwsCredentials.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.fabric8.generator.annotation.Default; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieAwsCredentials( + @Default("{ \"name\": \"awscreds\" }") LocalObjectReference secretRef, + @Default("aws_access_key_id") String awsAccessKeyId, + @Default("aws_secret_access_key") String awsSecretAccessKey) { + + public NessieAwsCredentials() { + this(new LocalObjectReference("awscreds"), "aws_access_key_id", "aws_secret_access_key"); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieBigTableCredentials.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieBigTableCredentials.java new file mode 100644 index 00000000000..0c8b13401cd --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieBigTableCredentials.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.fabric8.generator.annotation.Default; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieBigTableCredentials( + @Default("{ \"name\": \"bigtable-creds\" }") LocalObjectReference secretRef, + @Default("sa_json") String serviceAccountKey) { + + public NessieBigTableCredentials() { + this(new LocalObjectReference("bigtable-creds"), "sa_json"); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieCredentials.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieCredentials.java new file mode 100644 index 00000000000..b2c470b3d5e --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieCredentials.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import io.fabric8.generator.annotation.Required; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +public record NessieCredentials( + @Required LocalObjectReference secretRef, + @Required String usernameKey, + @Required String passwordKey) {} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieImage.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieImage.java new file mode 100644 index 00000000000..8d5e093243e --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieImage.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Nullable; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieImage( + @JsonPropertyDescription("The image repository. Defaults to " + DEFAULT_REPOSITORY + ".") + @Default(DEFAULT_REPOSITORY) + @PrinterColumn(name = "REPOSITORY", priority = 5) + String repository, + @JsonPropertyDescription(""" + The image tag to use. Defaults to "latest".""") + @Default("latest") + @PrinterColumn(name = "TAG", priority = 5) + String tag, + @JsonPropertyDescription( + """ + The image pull policy to use. Defaults to "Always" if the tag is "latest" or "latest-java", \ + otherwise to "IfNotPresent".""") + @Nullable + @jakarta.annotation.Nullable + PullPolicy pullPolicy, + @JsonPropertyDescription( + """ + Optional. The secret to use when pulling the image from private repositories. \ + See https://kubernetes.io/docs/concepts/containers/images#specifying-imagepullsecrets-on-a-pod.""") + @Nullable + @jakarta.annotation.Nullable + LocalObjectReference pullSecretRef) { + + public static final String DEFAULT_REPOSITORY = "ghcr.io/projectnessie/nessie"; + + public enum PullPolicy { + Always, + Never, + IfNotPresent + } + + public NessieImage() { + this(DEFAULT_REPOSITORY, "latest", PullPolicy.Always, null); + } + + @JsonIgnore + public String fullName() { + return repository + ":" + tag; + } + + @JsonIgnore + public PullPolicy pullPolicyWithDefault() { + if (pullPolicy != null) { + return pullPolicy; + } + return tag.equals("latest") || tag.equals("latest-java") + ? PullPolicy.Always + : PullPolicy.IfNotPresent; + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieIngress.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieIngress.java new file mode 100644 index 00000000000..11029c72ff8 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieIngress.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Required; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.sundr.builder.annotations.Buildable; +import java.util.List; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieIngress( + @JsonPropertyDescription("Specifies whether an ingress should be created.") @Default("false") + boolean enabled, + @JsonPropertyDescription(""" + The ingress class name to use. Defaults to "nginx".""") + @Default("nginx") + String ingressClassName, + @JsonPropertyDescription("Annotations to add to the ingress.") @Default("{}") + Map annotations, + @JsonPropertyDescription( + "A list of rules used configure the ingress. Required if ingress is enabled.") + @Default("[]") + List rules, + @JsonPropertyDescription( + """ + A list of TLS certificates; each entry has a list of hosts in the certificate, \ + along with the secret name used to terminate TLS traffic on port 443.""") + @Default("[]") + List tls) { + + public NessieIngress() { + this(false, "nginx", Map.of(), List.of(), List.of()); + } + + public record Rule(@Required String host, @Required List paths) {} + + public record Tls(@Required List hosts, @Required LocalObjectReference secretRef) {} +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieRemoteDebug.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieRemoteDebug.java new file mode 100644 index 00000000000..48dc5ac5df6 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieRemoteDebug.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieRemoteDebug( + @JsonPropertyDescription("Whether to enable remote debugging.") @Default("false") + boolean enabled, + @JsonPropertyDescription("The port to use for remote debugging.") @Default("5005") int port, + @JsonPropertyDescription("Whether to suspend.") @Default("false") boolean suspend) {} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieService.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieService.java new file mode 100644 index 00000000000..0fedc00bcea --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieService.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieService( + @JsonPropertyDescription("The type of service to create. Defaults to ClusterIP.") + @Default("ClusterIP") + NessieService.Type type, + @JsonPropertyDescription( + "The port on which the service should listen. Defaults to " + DEFAULT_PORT + ".") + @Default("19120") + int port) { + + public static final int DEFAULT_PORT = 19120; + + public NessieService() { + this(Type.ClusterIP, DEFAULT_PORT); + } + + public enum Type { + ClusterIP, + NodePort, + LoadBalancer + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceAccount.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceAccount.java new file mode 100644 index 00000000000..adabbbc3ad9 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceAccount.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Nullable; +import io.sundr.builder.annotations.Buildable; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieServiceAccount( + @JsonPropertyDescription("Specifies whether a service account should be created.") + @Default("true") + boolean create, + @JsonPropertyDescription("Annotations to add to the service account.") @Default("{}") + Map annotations, + @JsonPropertyDescription( + """ + The name of the service account to use. \ + If not set and create is true, a name is generated after the Nessie CRD name.""") + @Nullable + @jakarta.annotation.Nullable + String name) { + + public NessieServiceAccount() { + this(true, Map.of(), null); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceMonitor.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceMonitor.java new file mode 100644 index 00000000000..0c3e8178046 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieServiceMonitor.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Pattern; +import io.sundr.builder.annotations.Buildable; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +public record NessieServiceMonitor( + @JsonPropertyDescription( + """ + Specifies whether a ServiceMonitor for Prometheus operator should be created. \ + The default is true if Prometheus monitoring is installed, false otherwise.""") + @Default("true") + boolean enabled, + @JsonPropertyDescription( + "The scrape interval; leave empty to let Prometheus decide. Must be a valid duration, e.g. 1d, 1h30m, 5m, 10s.") + @Pattern( + "((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)") + @Default("") + String interval, + @JsonPropertyDescription( + "Labels for the created ServiceMonitor so that Prometheus operator can properly pick it up.") + @Default("{}") + Map labels) { + + public NessieServiceMonitor() { + this(true, "", Map.of()); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieSpec.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieSpec.java new file mode 100644 index 00000000000..f8584635beb --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieSpec.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.JsonNode; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Min; +import io.fabric8.kubernetes.api.model.Affinity; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.PodSecurityContext; +import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.fabric8.kubernetes.api.model.SecurityContext; +import io.fabric8.kubernetes.api.model.Toleration; +import io.sundr.builder.annotations.Buildable; +import java.util.List; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieSpec( + @JsonPropertyDescription("The number of replicas to run.") + @Default("1") + @Min(1) + @PrinterColumn(name = "SIZE") + int size, + @JsonPropertyDescription("The image to use for the Nessie server.") @Default("{}") + NessieImage image, + @JsonPropertyDescription("The log level to use for the Nessie server.") @Default("INFO") + LogLevel logLevel, + @JsonPropertyDescription( + "The resources to allocate to the Nessie server. There are no defaults.") + ResourceRequirements resources, + @JsonPropertyDescription("Nessie version store options.") @Default("{}") + NessieVersionStore versionStore, + @JsonPropertyDescription("Nessie service options.") @Default("{}") NessieService service, + @JsonPropertyDescription("Nessie ingress options.") @Default("{}") NessieIngress ingress, + @JsonPropertyDescription("Nessie authentication options.") @Default("{}") + NessieAuthentication authentication, + @JsonPropertyDescription("Nessie authorization options.") @Default("{}") + NessieAuthorization authorization, + @JsonPropertyDescription("Nessie telemetry options.") @Default("{}") NessieTelemetry telemetry, + @JsonPropertyDescription("Nessie ServiceMonitor (Prometheus) options.") @Default("{}") + NessieServiceMonitor serviceMonitor, + @JsonPropertyDescription("Nessie service account options.") @Default("{}") + NessieServiceAccount serviceAccount, + @JsonPropertyDescription("Nessie autoscaling options.") @Default("{}") + NessieAutoscaling autoscaling, + @JsonPropertyDescription("Nessie remote debugging options.") @Default("{}") + NessieRemoteDebug remoteDebug, + @JsonPropertyDescription( + """ + You can pass here any valid Nessie or Quarkus configuration property. \ + Any property that is defined here takes precedence over all the other \ + configuration values generated by this operator.""") + @JsonAnySetter + @Default("{}") + JsonNode advancedConfig, + @JsonPropertyDescription("Extra environment variables to add to the Nessie server container.") + @Default("[]") + List extraEnv, + @JsonPropertyDescription( + """ + Node labels which must match for the nessie pod to be scheduled on that node. \ + See https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector.""") + @Default("{}") + Map nodeSelector, + @JsonPropertyDescription( + """ + Tolerations for the nessie pod. \ + See https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/.""") + @Default("[]") + List tolerations, + @JsonPropertyDescription( + """ + Affinity rules for the nessie pod. \ + See https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity.""") + @Default("{}") + Affinity affinity, + @JsonPropertyDescription("Additional pod labels.") @Default("{}") Map podLabels, + @JsonPropertyDescription("Additional pod annotations.") @Default("{}") + Map podAnnotations, + @JsonPropertyDescription( + """ + Security context for the nessie pod. \ + See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/.""") + @Default("{}") + PodSecurityContext podSecurityContext, + @JsonPropertyDescription( + """ + Security context for the nessie container. \ + See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/.""") + @Default("{}") + SecurityContext securityContext) { + + public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieStatus.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieStatus.java new file mode 100644 index 00000000000..fb0a3c3b9b0 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieStatus.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.kubernetes.api.model.Condition; +import io.fabric8.kubernetes.api.model.ConditionBuilder; +import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; +import io.sundr.builder.annotations.Buildable; +import java.util.ArrayList; +import java.util.List; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +public class NessieStatus extends ObservedGenerationAwareStatus { + + @PrinterColumn(name = "READY") + private boolean ready; + + @JsonInclude(Include.NON_EMPTY) + private List conditions = new ArrayList<>(); + + @JsonInclude(Include.NON_NULL) + @PrinterColumn(name = "INGRESS_URL", priority = 10) + private String exposedUrl; + + public boolean isReady() { + return ready; + } + + public void setReady(boolean ready) { + this.ready = ready; + setCondition( + new ConditionBuilder() + .withType("Ready") + .withStatus(ready ? "True" : "False") + .withMessage("Nessie is ready") + .build()); + } + + public List getConditions() { + return conditions; + } + + public void setConditions(List conditions) { + this.conditions = conditions; + } + + public void setCondition(Condition condition) { + conditions.removeIf(c -> c.getType().equals(condition.getType())); + conditions.add(condition); + } + + public void setExposedUrl(String exposedUrl) { + this.exposedUrl = exposedUrl; + } + + public String getExposedUrl() { + return exposedUrl; + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieTelemetry.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieTelemetry.java new file mode 100644 index 00000000000..f54bf685abd --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieTelemetry.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Nullable; +import io.sundr.builder.annotations.Buildable; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieTelemetry( + @JsonPropertyDescription("Specifies whether tracing for the nessie server should be enabled.") + @Default("false") + @PrinterColumn(name = "TELEMETRY", priority = 1) + boolean enabled, + @JsonPropertyDescription( + "The collector endpoint URL to connect to. Required if telemetry is enabled.") + @Nullable + @jakarta.annotation.Nullable + String endpoint, + @JsonPropertyDescription( + """ + Which requests should be sampled. Valid values are: "all", "none", or a ratio between 0.0 and \ + "1.0d" (inclusive). E.g. "0.5d" means that 50% of the requests will be sampled.""") + @Default("1.0d") + String sample, + @JsonPropertyDescription( + """ + Resource attributes to identify the nessie service among other tracing sources. \ + See https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/#service. \ + If left empty, traces will be attached to a service named after the Nessie CRD name; \ + to change this, provide a service.name attribute here.""") + @Default("{}") + Map attributes) { + + public NessieTelemetry() { + this(false, null, "1.0d", Map.of()); + } +} diff --git a/operator/src/main/java/org/projectnessie/operator/resource/NessieVersionStore.java b/operator/src/main/java/org/projectnessie/operator/resource/NessieVersionStore.java new file mode 100644 index 00000000000..22c2b1f7b36 --- /dev/null +++ b/operator/src/main/java/org/projectnessie/operator/resource/NessieVersionStore.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.resource; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.crd.generator.annotation.PrinterColumn; +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Nullable; +import io.fabric8.generator.annotation.Required; +import io.fabric8.kubernetes.api.model.Quantity; +import io.sundr.builder.annotations.Buildable; +import java.util.List; +import java.util.Map; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder", editableEnabled = false) +@JsonInclude(Include.NON_NULL) +public record NessieVersionStore( + @JsonPropertyDescription("The type of version store to use.") + @Default("InMemory") + @PrinterColumn(name = "VERSION_STORE") + VersionStoreType type, + @JsonPropertyDescription( + "RocksDB settings. Only required when using RocksDb version store type; ignored otherwise.") + RocksDb rocksdb, + @JsonPropertyDescription( + "DynamoDB settings. Only required when using DynamoDb version store type; ignored otherwise.") + DynamoDb dynamodb, + @JsonPropertyDescription( + "MongoDB settings. Only required when using MongoDb version store type; ignored otherwise.") + MongoDb mongodb, + @JsonPropertyDescription( + "Cassandra settings. Only required when using Cassandra version store type; ignored otherwise.") + Cassandra cassandra, + @JsonPropertyDescription( + "JDBC settings. Only required when using Jdbc version store type; ignored otherwise.") + Jdbc jdbc, + @JsonPropertyDescription( + "BigTable settings. Only required when using BigTable version store type; ignored otherwise.") + BigTable bigtable) { + + public NessieVersionStore() { + this(VersionStoreType.InMemory, null, null, null, null, null, null); + } + + public enum VersionStoreType { + InMemory, + RocksDb, + DynamoDb, + MongoDb, + Cassandra, + Jdbc, + BigTable; + + @JsonIgnore + public boolean supportsMultipleReplicas() { + return this != InMemory && this != RocksDb; + } + } + + @JsonInclude(Include.NON_NULL) + public record RocksDb( + @JsonPropertyDescription("The storage class name of the persistent volume claim to create.") + @Default("standard") + String storageClassName, + @JsonPropertyDescription("The size of the persistent volume claim to create.") @Default("1Gi") + Quantity storageSize, + @JsonPropertyDescription( + """ + Labels to add to the persistent volume claim spec selector; \ + a persistent volume with matching labels must exist. \ + Leave empty if using dynamic provisioning.""") + @Default("{}") + Map selectorLabels) {} + + @JsonInclude(Include.NON_NULL) + public record Jdbc( + @JsonPropertyDescription("The JDBC connection string.") @Required String jdbcUrl, + @JsonPropertyDescription("The JDBC credentials.") @Nullable @jakarta.annotation.Nullable + NessieCredentials credentials) {} + + @JsonInclude(Include.NON_NULL) + public record Cassandra( + @JsonPropertyDescription("The Cassandra keyspace to use.") @Default("nessie") String keyspace, + @JsonPropertyDescription("The Cassandra contact points to use.") @Required + List contactPoints, + @JsonPropertyDescription("The Cassandra local datacenter to use.") @Required + String localDatacenter, + @JsonPropertyDescription("The Cassandra credentials.") @Nullable @jakarta.annotation.Nullable + NessieCredentials credentials) {} + + @JsonInclude(Include.NON_NULL) + public record MongoDb( + @JsonPropertyDescription("The MongoDB database name.") @Default("nessie") String name, + @JsonPropertyDescription("The MongoDB connection string.") @Required String connectionString, + @JsonPropertyDescription("The MongoDB credentials.") @Nullable @jakarta.annotation.Nullable + NessieCredentials credentials) {} + + @JsonInclude(Include.NON_NULL) + public record BigTable( + @JsonPropertyDescription("The Google Cloud project ID.") @Required String projectId, + @JsonPropertyDescription("The Google Cloud Bigtable instance ID.") @Default("nessie-bigtable") + String instanceId, + @JsonPropertyDescription("The Google Cloud Bigtable app profile ID.") @Default("default") + String appProfileId, + @JsonPropertyDescription("The BigTable credentials.") @Required + NessieBigTableCredentials credentials) {} + + @JsonInclude(Include.NON_NULL) + public record DynamoDb( + @JsonPropertyDescription("The AWS region to use.") @Required String region, + @JsonPropertyDescription("The AWS credentials.") @Required + NessieAwsCredentials credentials) {} +} diff --git a/operator/src/main/kubernetes/nessie.svg b/operator/src/main/kubernetes/nessie.svg new file mode 100644 index 00000000000..f752c157cd8 --- /dev/null +++ b/operator/src/main/kubernetes/nessie.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/operator/src/main/resources/application.properties b/operator/src/main/resources/application.properties new file mode 100644 index 00000000000..3b227557946 --- /dev/null +++ b/operator/src/main/resources/application.properties @@ -0,0 +1,65 @@ +# Application settings +# Quarkus settings +## Visit here for all configs: https://quarkus.io/guides/all-config +## some parameters are only configured at build time. These have been marked as such https://quarkus.io/guides/config#overriding-properties-at-runtime + +quarkus.application.name=nessie-operator +quarkus.banner.path=nessie-banner.txt + +# Operator image +quarkus.container-image.build=true +quarkus.container-image.group=ghcr.io/projectnessie +quarkus.container-image.name=nessie-operator +quarkus.container-image.tag=${nessie.version} +quarkus.container-image.builder=jib + +# Kubernetes resources +quarkus.kubernetes.version=${nessie.version} +quarkus.kubernetes.namespace=nessie-operator +quarkus.kubernetes.image-pull-policy=IfNotPresent +# Since Quarkus 3.7.0; fixed at build time; if true, requires monitoring.coreos.com/v1 to be installed +quarkus.kubernetes.prometheus.generate-service-monitor=false +quarkus.kubernetes.prometheus.annotations=true + +# Quarkus Operator SDK settings +quarkus.operator-sdk.enable-ssa=true +quarkus.operator-sdk.crd.generate=true +quarkus.operator-sdk.crd.apply=true +quarkus.operator-sdk.helm.enabled=true +quarkus.operator-sdk.bundle.enabled=true +quarkus.operator-sdk.bundle.package-name=nessie-operator +# https://olm.operatorframework.io/docs/best-practices/channel-naming/#channels +quarkus.operator-sdk.bundle.channels=fast,stable + +# Logging +# Available MDC keys: +# resource.apiVersion .apiVersion +# resource.kind .kind +# resource.name .metadata.name +# resource.namespace .metadata.namespace +# resource.resourceVersion .metadata.resourceVersion +# resource.generation .metadata.generation +# resource.uid .metadata.uid +quarkus.log.level=INFO +quarkus.log.min-level=DEBUG +quarkus.log.console.level=DEBUG +quarkus.log.file.level=DEBUG +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%X{resource.namespace} %X{resource.name}] [%c{3.}] (%t) %s%e%n +quarkus.log.file.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%X{resource.namespace} %X{resource.name}] [%c{3.}] (%t) %s%e%n +quarkus.log.category."io.fabric8.kubernetes".level=INFO +quarkus.log.category."io.javaoperatorsdk.operator".level=INFO +quarkus.log.category."io.quarkiverse.operatorsdk".level=INFO +quarkus.log.category."io.quarkus.kubernetes".level=INFO +quarkus.log.category."org.projectnessie".level=DEBUG + +# Testing + +quarkus.kubernetes-client.devservices.enabled=true +quarkus.kubernetes-client.devservices.override-kubeconfig=true +quarkus.kubernetes-client.devservices.flavor=k3s +%test.quarkus.operator-sdk.start-operator=true +%test.quarkus.operator-sdk.close-client-on-stop=true +%test.quarkus.log.category."okhttp3.mockwebserver".level=WARN +%test.quarkus.log.category."io.quarkus.test.kubernetes".level=INFO +%test.quarkus.http.test-port=0 + diff --git a/operator/src/main/resources/nessie-banner.txt b/operator/src/main/resources/nessie-banner.txt new file mode 100644 index 00000000000..4f6ef4947ed --- /dev/null +++ b/operator/src/main/resources/nessie-banner.txt @@ -0,0 +1,8 @@ + _ _ _ ____ _ +| \ | | (_) / __ \ | | +| \| | ___ ___ ___ _ ___ | | | |_ __ ___ _ __ __ _| |_ ___ _ __ +| . ` |/ _ \/ __/ __| |/ _ \ | | | | '_ \ / _ \ '__/ _` | __/ _ \| '__| +| |\ | __/\__ \__ \ | __/ | |__| | |_) | __/ | | (_| | || (_) | | +|_| \_|\___||___/___/_|\___| \____/| .__/ \___|_| \__,_|\__\___/|_| + | | + |_| https://projectnessie.org/ diff --git a/operator/src/test/java/org/projectnessie/operator/TestNessieReconciler.java b/operator/src/test/java/org/projectnessie/operator/TestNessieReconciler.java new file mode 100644 index 00000000000..d32ece609ef --- /dev/null +++ b/operator/src/test/java/org/projectnessie/operator/TestNessieReconciler.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import io.fabric8.kubernetes.api.model.APIGroupBuilder; +import io.fabric8.kubernetes.api.model.GroupVersionForDiscovery; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.PersistentVolumeClaim; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.ServiceAccount; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.server.mock.KubernetesServer; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.kubernetes.client.WithKubernetesTestServer; +import jakarta.inject.Inject; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.projectnessie.operator.resource.Nessie; + +@WithKubernetesTestServer(setup = TestNessieReconciler.Setup.class) +@QuarkusTest +@TestProfile(TestNessieReconciler.Profile.class) +class TestNessieReconciler { + + @Inject KubernetesClient client; + + @Test + void reconcileShouldWork() { + + Nessie nessie = + ReconcilerUtils.loadYaml(Nessie.class, TestNessieReconciler.class, "/nessie-test.yaml"); + ObjectMeta metadata = nessie.getMetadata(); + metadata.setNamespace(client.getNamespace()); + client.resource(nessie).create(); + + await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted( + () -> { + ServiceAccount sa = get(client.serviceAccounts(), "nessie-test-sa"); + checkServiceAccount(sa); + PersistentVolumeClaim pvc = get(client.persistentVolumeClaims(), "nessie-test"); + checkPvc(pvc); + Deployment deployment = get(client.apps().deployments(), "nessie-test"); + checkDeployment(deployment); + Service service = get(client.services(), "nessie-test"); + checkService(service); + Ingress ingress = get(client.network().v1().ingresses(), "nessie-test"); + checkIngress(ingress); + }); + + client.resource(nessie).delete(); + + // Garbage collection of dependent resources is not implemented in + // KubernetesTestServer, see + // https://github.com/fabric8io/kubernetes-client/issues/5607 + await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(client.resource(nessie).get()).isNull()); + } + + private T get(MixedOperation> resources, String name) { + return resources.inNamespace(client.getNamespace()).withName(name).get(); + } + + private void checkServiceAccount(ServiceAccount sa) { + assertThat(sa).isNotNull(); + ServiceAccount expected = + ReconcilerUtils.loadYaml( + ServiceAccount.class, TestNessieReconciler.class, "/nessie-test-service-account.yaml"); + assertThat(sa.getMetadata().getName()).isEqualTo(expected.getMetadata().getName()); + assertThat(sa.getMetadata().getLabels()).isEqualTo(expected.getMetadata().getLabels()); + assertThat(sa.getMetadata().getAnnotations()) + .containsAllEntriesOf(expected.getMetadata().getAnnotations()); + } + + private void checkPvc(PersistentVolumeClaim pvc) { + assertThat(pvc).isNotNull(); + PersistentVolumeClaim expected = + ReconcilerUtils.loadYaml( + PersistentVolumeClaim.class, TestNessieReconciler.class, "/nessie-test-pvc.yaml"); + assertThat(pvc.getMetadata().getName()).isEqualTo(expected.getMetadata().getName()); + assertThat(pvc.getMetadata().getLabels()).isEqualTo(expected.getMetadata().getLabels()); + assertThat(pvc.getSpec()) + .usingRecursiveComparison() + .ignoringFields("volumeName") + .isEqualTo(expected.getSpec()); + if (pvc.getStatus() == null) { + pvc.getSpec().setVolumeName(expected.getSpec().getVolumeName()); + pvc.setStatus(expected.getStatus()); + client.persistentVolumeClaims().resource(pvc).patch(); + } + } + + private void checkDeployment(Deployment actual) { + assertThat(actual).isNotNull(); + Deployment expected = + ReconcilerUtils.loadYaml( + Deployment.class, TestNessieReconciler.class, "/nessie-test-deployment.yaml"); + assertThat(actual.getMetadata().getName()).isEqualTo(expected.getMetadata().getName()); + assertThat(actual.getMetadata().getLabels()).isEqualTo(expected.getMetadata().getLabels()); + assertThat(actual.getSpec().getReplicas()).isEqualTo(expected.getSpec().getReplicas()); + assertThat(actual.getSpec().getSelector()).isEqualTo(expected.getSpec().getSelector()); + PodTemplateSpec actualTemplate = actual.getSpec().getTemplate(); + PodTemplateSpec expectedTemplate = expected.getSpec().getTemplate(); + assertThat(actualTemplate.getMetadata().getLabels()) + .isEqualTo(expectedTemplate.getMetadata().getLabels()); + assertThat(actualTemplate.getSpec()) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .isEqualTo(expectedTemplate.getSpec()); + if (actual.getStatus() == null) { + actual.setStatus(expected.getStatus()); + client.apps().deployments().resource(actual).patchStatus(); + } + } + + private void checkService(Service actual) { + assertThat(actual).isNotNull(); + Service expected = + ReconcilerUtils.loadYaml( + Service.class, TestNessieReconciler.class, "/nessie-test-service.yaml"); + assertThat(actual.getMetadata().getName()).isEqualTo(expected.getMetadata().getName()); + assertThat(actual.getMetadata().getLabels()).isEqualTo(expected.getMetadata().getLabels()); + assertThat(actual.getSpec().getPorts()).isEqualTo(expected.getSpec().getPorts()); + assertThat(actual.getSpec().getSelector()).isEqualTo(expected.getSpec().getSelector()); + assertThat(actual.getSpec().getType()).isEqualTo(expected.getSpec().getType()); + if (actual.getStatus() == null) { + actual.getSpec().setClusterIP(expected.getSpec().getClusterIP()); + actual.setStatus(expected.getStatus()); + client.services().resource(actual).patch(); + } + } + + private void checkIngress(Ingress actual) { + assertThat(actual).isNotNull(); + Ingress expected = + ReconcilerUtils.loadYaml( + Ingress.class, TestNessieReconciler.class, "/nessie-test-ingress.yaml"); + assertThat(actual.getMetadata().getName()).isEqualTo(expected.getMetadata().getName()); + assertThat(actual.getMetadata().getLabels()).isEqualTo(expected.getMetadata().getLabels()); + assertThat(actual.getMetadata().getAnnotations()) + .containsAllEntriesOf(expected.getMetadata().getAnnotations()); + assertThat(actual.getSpec().getRules()).isEqualTo(expected.getSpec().getRules()); + assertThat(actual.getSpec().getTls()).isEqualTo(expected.getSpec().getTls()); + if (actual.getStatus() == null) { + actual.getSpec().setIngressClassName(expected.getSpec().getIngressClassName()); + actual.setStatus(expected.getStatus()); + client.network().v1().ingresses().resource(actual).patch(); + } + } + + public static class Profile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + // Disable SSA for tests, see + // https://github.com/fabric8io/kubernetes-client/issues/5337 + return Map.of("quarkus.operator-sdk.enable-ssa", "false"); + } + } + + public static class Setup implements Consumer { + + @Override + public void accept(KubernetesServer server) { + server + .expect() + .get() + .withPath("/apis/networking.k8s.io") + .andReturn( + 200, + new APIGroupBuilder() + .withApiVersion("v1") + .withKind("APIGroup") + .withVersions(new GroupVersionForDiscovery("networking.k8s.io/v1", "v1")) + .build()) + .always(); + } + } +} diff --git a/operator/src/test/java/org/projectnessie/operator/helper/TestTimeUtils.java b/operator/src/test/java/org/projectnessie/operator/helper/TestTimeUtils.java new file mode 100644 index 00000000000..a47e5529552 --- /dev/null +++ b/operator/src/test/java/org/projectnessie/operator/helper/TestTimeUtils.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 Dremio + * + * 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 org.projectnessie.operator.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; + +class TestTimeUtils { + + @Test + void formatTime() { + assertThat(TimeUtils.formatTime(ZonedDateTime.parse("2006-01-02T15:04:05Z"))) + .isEqualTo("2006-01-02T15:04:05Z"); + assertThat(TimeUtils.formatTime(ZonedDateTime.parse("2006-01-02T15:04:05+07:00"))) + .isEqualTo("2006-01-02T15:04:05+07:00"); + assertThat(TimeUtils.formatTime(ZonedDateTime.parse("2006-01-02T15:04:05.999999Z"))) + .isEqualTo("2006-01-02T15:04:05Z"); + assertThat(TimeUtils.formatTime(ZonedDateTime.parse("2006-01-02T15:04:05.999999+07:00"))) + .isEqualTo("2006-01-02T15:04:05+07:00"); + } + + @Test + void formatMicroTime() { + assertThat(TimeUtils.formatMicroTime(ZonedDateTime.parse("2006-01-02T15:04:05Z"))) + .isEqualTo("2006-01-02T15:04:05.000000Z"); + assertThat(TimeUtils.formatMicroTime(ZonedDateTime.parse("2006-01-02T15:04:05+07:00"))) + .isEqualTo("2006-01-02T15:04:05.000000+07:00"); + assertThat(TimeUtils.formatMicroTime(ZonedDateTime.parse("2006-01-02T15:04:05.999999Z"))) + .isEqualTo("2006-01-02T15:04:05.999999Z"); + assertThat(TimeUtils.formatMicroTime(ZonedDateTime.parse("2006-01-02T15:04:05.999999+07:00"))) + .isEqualTo("2006-01-02T15:04:05.999999+07:00"); + } +} diff --git a/operator/src/test/resources/nessie-test-deployment.yaml b/operator/src/test/resources/nessie-test-deployment.yaml new file mode 100644 index 00000000000..c53ba4a4297 --- /dev/null +++ b/operator/src/test/resources/nessie-test-deployment.yaml @@ -0,0 +1,140 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nessie-test + labels: + app.kubernetes.io/component: nessie + app.kubernetes.io/instance: nessie-test + app.kubernetes.io/managed-by: nessie-operator + app.kubernetes.io/name: nessie + app.kubernetes.io/part-of: nessie + # noinspection KubernetesUnknownValues + app.kubernetes.io/version: "@projectVersion@" + foo: bar +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: nessie + app.kubernetes.io/instance: nessie-test + template: + metadata: + labels: + app.kubernetes.io/name: nessie + app.kubernetes.io/instance: nessie-test + foo: bar + annotations: + foo: bar + spec: + volumes: + - name: rocks-storage + persistentVolumeClaim: + claimName: nessie-test + securityContext: + fsGroup: 1000 + nodeSelector: + foo: bar + tolerations: + - key: "key" + operator: "Equal" + value: "value" + effect: "NoSchedule" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: "key" + operator: "In" + values: + - "value" + containers: + - name: nessie + image: projectnessie/nessie:1.2.3 + imagePullPolicy: Never + ports: + - name: nessie + containerPort: 19120 + protocol: TCP + volumeMounts: + - mountPath: /rocks-nessie + name: rocks-storage + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + env: + - name: QUARKUS_LOG_LEVEL + value: DEBUG + - name: QUARKUS_LOG_CONSOLE_LEVEL + value: DEBUG + - name: QUARKUS_LOG_FILE_LEVEL + value: DEBUG + - name: QUARKUS_LOG_MIN_LEVEL + value: DEBUG + - name: NESSIE_VERSION_STORE_PERSIST_ROCKS_DATABASE_PATH + value: /rocks-nessie + - name: NESSIE_SERVER_AUTHENTICATION_ENABLED + value: 'true' + - name: QUARKUS_OIDC_AUTH_SERVER_URL + value: http://keycloak:8080/auth/realms/nessie + - name: QUARKUS_OIDC_CLIENT_ID + value: quarkus-app + - name: NESSIE_SERVER_AUTHORIZATION_ENABLED + value: 'true' + - name: NESSIE_SERVER_AUTHORIZATION_RULES_ALLOWVIEWINGBRANCH + value: >- + op=='VIEW_REFERENCE' && role.startsWith('test_user') && + ref.startsWith('allowedBranch') + - name: QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + value: https://otlp-collector:4317 + - name: QUARKUS_OTEL_TRACER_RESOURCE_ATTRIBUTES + value: foo=bar,service.name=nessie-test + - name: QUARKUS_OTEL_TRACER_SAMPLER + value: parentbased_traceidratio + - name: QUARKUS_OTEL_TRACER_SAMPLER_ARG + value: '0.5d' + - name: NESSIE_SERVER_DEFAULT_BRANCH + value: my-branch + - name: NESSIE_VERSION_STORE_PERSIST_REPOSITORY_ID + value: my-repository + - name: NESSIE_VERSION_STORE_PERSIST_CACHE_CAPACITY_FRACTION_ADJUST_MB + value: '256' + - name: NESSIE_VERSION_STORE_PERSIST_CACHE_CAPACITY_FRACTION_MIN_SIZE_MB + value: '64' + - name: NESSIE_VERSION_STORE_PERSIST_CACHE_CAPACITY_FRACTION_OF_HEAP + value: '0.7' + - name: NESSIE_VERSION_STORE_PERSIST_CACHE_CAPACITY_MB + value: '1024' + - name: QUARKUS_LOG_CATEGORY__ORG_PROJECTNESSIE__LEVEL + value: TRACE + - name: QUARKUS_LOG_CONSOLE_FORMAT + value: '%d{HH:mm:ss} %s%e%n' + - name: NESSIE_QUARKUS_PROFILE + value: prod + serviceAccountName: nessie-test-sa +status: + observedGeneration: 1 + replicas: 1 + updatedReplicas: 1 + readyReplicas: 1 + availableReplicas: 1 + conditions: + - type: Available + status: 'True' + lastUpdateTime: '2024-01-22T14:16:21Z' + lastTransitionTime: '2024-01-22T14:16:21Z' + reason: MinimumReplicasAvailable + message: Deployment has minimum availability. + - type: Progressing + status: 'True' + lastUpdateTime: '2024-01-22T14:16:21Z' + lastTransitionTime: '2024-01-22T14:16:18Z' + reason: NewReplicaSetAvailable + message: ReplicaSet "nessie-test-abcdefg" has successfully progressed. diff --git a/operator/src/test/resources/nessie-test-ingress.yaml b/operator/src/test/resources/nessie-test-ingress.yaml new file mode 100644 index 00000000000..896e5b66903 --- /dev/null +++ b/operator/src/test/resources/nessie-test-ingress.yaml @@ -0,0 +1,35 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: nessie-test + labels: + app.kubernetes.io/component: nessie + app.kubernetes.io/instance: nessie-test + app.kubernetes.io/managed-by: nessie-operator + app.kubernetes.io/name: nessie + app.kubernetes.io/part-of: nessie + # noinspection KubernetesUnknownValues + app.kubernetes.io/version: "@projectVersion@" + annotations: + kubernetes.io/ingress.class: nginx +status: + loadBalancer: + ingress: + - ip: 192.168.49.2 +spec: + ingressClassName: nginx + rules: + - host: nessie-test.example.com + http: + paths: + - path: / + pathType: ImplementationSpecific + backend: + service: + name: nessie-test + port: + number: 19120 + tls: + - hosts: + - nessie-test.example.com + secretName: nessie-test-tls diff --git a/operator/src/test/resources/nessie-test-pvc.yaml b/operator/src/test/resources/nessie-test-pvc.yaml new file mode 100644 index 00000000000..7922b9bed51 --- /dev/null +++ b/operator/src/test/resources/nessie-test-pvc.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: nessie-test + labels: + app.kubernetes.io/component: nessie + app.kubernetes.io/instance: nessie-test + app.kubernetes.io/managed-by: nessie-operator + app.kubernetes.io/name: nessie + app.kubernetes.io/part-of: nessie + # noinspection KubernetesUnknownValues + app.kubernetes.io/version: "@projectVersion@" +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard-rwo + selector: + matchLabels: + foo: bar + volumeName: pvc-600ce745-6f74-4048-84af-0d9d18263e0e +status: + phase: Bound + accessModes: + - ReadWriteOnce + capacity: + storage: 128Mi diff --git a/operator/src/test/resources/nessie-test-service-account.yaml b/operator/src/test/resources/nessie-test-service-account.yaml new file mode 100644 index 00000000000..dc1a6b88be7 --- /dev/null +++ b/operator/src/test/resources/nessie-test-service-account.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nessie-test-sa + namespace: nessie-ns + labels: + app.kubernetes.io/component: nessie + app.kubernetes.io/instance: nessie-test + app.kubernetes.io/managed-by: nessie-operator + app.kubernetes.io/name: nessie + app.kubernetes.io/part-of: nessie + # noinspection KubernetesUnknownValues + app.kubernetes.io/version: "@projectVersion@" + annotations: + foo: bar diff --git a/operator/src/test/resources/nessie-test-service.yaml b/operator/src/test/resources/nessie-test-service.yaml new file mode 100644 index 00000000000..f809b85737e --- /dev/null +++ b/operator/src/test/resources/nessie-test-service.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Service +metadata: + name: nessie-test + namespace: nessie-ns + labels: + app.kubernetes.io/component: nessie + app.kubernetes.io/instance: nessie-test + app.kubernetes.io/managed-by: nessie-operator + app.kubernetes.io/name: nessie + app.kubernetes.io/part-of: nessie + # noinspection KubernetesUnknownValues + app.kubernetes.io/version: "@projectVersion@" +spec: + ports: + - name: nessie-server + protocol: TCP + port: 19120 + targetPort: 19120 + selector: + app.kubernetes.io/name: nessie + app.kubernetes.io/instance: nessie-test + type: ClusterIP + clusterIP: 10.110.247.254 + clusterIPs: + - 10.110.247.254 diff --git a/operator/src/test/resources/nessie-test.yaml b/operator/src/test/resources/nessie-test.yaml new file mode 100644 index 00000000000..d0b38d9f590 --- /dev/null +++ b/operator/src/test/resources/nessie-test.yaml @@ -0,0 +1,105 @@ +apiVersion: nessie.projectnessie.org/v1alpha1 +kind: Nessie +metadata: + name: nessie-test +spec: + # Note: KubernetesTestServer does not have admission webhooks, so default values are not + # applied. This is why we have to specify all non-leaf values here. + size: 1 + logLevel: DEBUG + image: + repository: projectnessie/nessie + tag: 1.2.3 + pullPolicy: Never + service: + type: ClusterIP + port: 19120 + serviceMonitor: + enabled: false + podLabels: + foo: bar + podAnnotations: + foo: bar + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + serviceAccount: + create: true + name: nessie-test-sa + annotations: + foo: bar + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + podSecurityContext: + fsGroup: 1000 + nodeSelector: + foo: bar + tolerations: + - key: "key" + operator: "Equal" + value: "value" + effect: "NoSchedule" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: "key" + operator: "In" + values: + - "value" + versionStore: + type: RocksDb + rocksdb: + storageClassName: standard-rwo + storageSize: 1Gi + selectorLabels: + foo: bar + ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + rules: + - host: nessie-test.example.com + paths: + - / + tls: + - secretRef: + name: nessie-test-tls + hosts: + - nessie-test.example.com + authentication: + enabled: true + oidcAuthServerUrl: http://keycloak:8080/auth/realms/nessie + oidcClientId: quarkus-app + authorization: + enabled: true + rules: + allowViewingBranch: op=='VIEW_REFERENCE' && role.startsWith('test_user') && ref.startsWith('allowedBranch') + telemetry: + enabled: true + endpoint: https://otlp-collector:4317 + sample: "0.5d" + attributes: + foo: "bar" + autoscaling: + enabled: false + extraEnv: + - name: NESSIE_QUARKUS_PROFILE + value: "prod" + advancedConfig: + nessie.server.default-branch: my-branch + nessie.version.store.persist.repository-id: my-repository + nessie.version.store.persist.cache-capacity-mb: 1024 + nessie.version.store.persist.cache-capacity-fraction-of-heap: 0.7 + nessie.version.store.persist.cache-capacity-fraction-adjust-mb: 256 + nessie.version.store.persist.cache-capacity-fraction-min-size-mb: 64 + quarkus: + log: + console.format: "%d{HH:mm:ss} %s%e%n" + category."org.projectnessie".level: "TRACE"