diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 5b09cf1037..6a86b16f69 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -25,6 +25,7 @@ jobs: - "sample-operators/webpage" - "sample-operators/leader-election" - "sample-operators/operations" + - "sample-operators/kotlin-operator" runs-on: ubuntu-latest steps: - name: Checkout diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index 5763c5490a..c2463fcee2 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -30,6 +30,11 @@ Operator SDK - Framework - Core Core framework for implementing Kubernetes operators + + + 2.4.10 + + io.github.java-diff-utils @@ -101,6 +106,13 @@ kube-api-test-client-inject test + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + test + @@ -147,6 +159,37 @@ + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${java.version} + + + + kotlin-test-compile + + test-compile + + process-test-sources + + + ${project.basedir}/src/test/kotlin + + ${project.basedir}/src/test/java + + + + + diff --git a/operator-framework-core/src/test/kotlin/io/javaoperatorsdk/operator/processing/dependent/workflow/KotlinCheckedExceptionDependentResourceTest.kt b/operator-framework-core/src/test/kotlin/io/javaoperatorsdk/operator/processing/dependent/workflow/KotlinCheckedExceptionDependentResourceTest.kt new file mode 100644 index 0000000000..20198540be --- /dev/null +++ b/operator-framework-core/src/test/kotlin/io/javaoperatorsdk/operator/processing/dependent/workflow/KotlinCheckedExceptionDependentResourceTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.dependent.workflow + +import io.javaoperatorsdk.operator.AggregatedOperatorException +import io.javaoperatorsdk.operator.api.reconciler.Context +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource +import java.util.concurrent.Executors +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +/** + * Smoke test verifying that JOSDK works properly when used from Kotlin. + * + * Unlike Java, Kotlin does not distinguish between checked and unchecked exceptions, so a Kotlin + * [DependentResource] can throw a plain [java.lang.Exception] from `reconcile` without declaring + * it, even though the Java `DependentResource.reconcile` method does not declare `throws + * Exception`. Before https://github.com/operator-framework/java-operator-sdk/pull/2965 such an + * exception was not caught by [NodeExecutor], since it only handled [RuntimeException], so it + * would not have been recorded as an error and retries would not have been triggered. This test + * makes sure such an exception is properly caught and reported, see + * https://github.com/operator-framework/java-operator-sdk/issues/2967. + */ +class KotlinCheckedExceptionDependentResourceTest { + + private class CheckedException(message: String) : Exception(message) + + private class ThrowingDependentResource : + DependentResource { + override fun reconcile( + primary: TestCustomResource, + context: Context + ): ReconcileResult { + throw CheckedException("checked exception thrown from Kotlin") + } + + override fun resourceType(): Class = String::class.java + } + + @Test + fun checkedExceptionThrownFromKotlinDependentResourceTriggersRetry() { + val dependentResource = ThrowingDependentResource() + + val workflow = + WorkflowBuilder() + .addDependentResource(dependentResource) + .withThrowExceptionFurther(false) + .build() + + @Suppress("UNCHECKED_CAST") + val context = mock(Context::class.java) as Context + val executorService = Executors.newSingleThreadExecutor() + try { + `when`(context.managedWorkflowAndDependentResourceContext()) + .thenReturn(mock(ManagedWorkflowAndDependentResourceContext::class.java)) + `when`(context.workflowExecutorService).thenReturn(executorService) + @Suppress("UNCHECKED_CAST") + `when`(context.eventSourceRetriever()) + .thenReturn(mock(EventSourceRetriever::class.java) as EventSourceRetriever) + + val result = workflow.reconcile(TestCustomResource(), context) + + // the checked exception was caught by the workflow executor, not left uncaught, so it is + // reported as an error for the dependent resource, which is what allows JOSDK to retry the + // reconciliation. + assertThat(result.erroredDependents).containsOnlyKeys(dependentResource) + assertThat(result.erroredDependents[dependentResource]) + .isInstanceOf(CheckedException::class.java) + assertThrows { result.throwAggregateExceptionIfErrorsPresent() } + } finally { + executorService.shutdownNow() + } + } +} diff --git a/sample-operators/kotlin-operator/README.md b/sample-operators/kotlin-operator/README.md new file mode 100644 index 0000000000..c54f1e8cac --- /dev/null +++ b/sample-operators/kotlin-operator/README.md @@ -0,0 +1,15 @@ +# Kotlin Operator Sample + +This module is a minimalist end-to-end test verifying that JOSDK works properly when both the +custom resource and the reconciler are implemented in Kotlin rather than Java. + +The `ConfigMapCopyReconciler` reads `spec.message` from a `ConfigMapCopy` custom resource and +copies it into a `ConfigMap`. This exercises: + +- deserialization of the custom resource from the Kubernetes API via the fabric8 client, +- a Kotlin-implemented `Reconciler`, and +- the rest of the reconciliation runtime (event sources, status updates, owner references). + +See [`KotlinCheckedExceptionDependentResourceTest`](../../operator-framework-core/src/test/kotlin/io/javaoperatorsdk/operator/processing/dependent/workflow/KotlinCheckedExceptionDependentResourceTest.kt) +for a narrower unit-level test covering checked-exception handling specifically (see +[#2967](https://github.com/operator-framework/java-operator-sdk/issues/2967)). diff --git a/sample-operators/kotlin-operator/k8s/operator.yaml b/sample-operators/kotlin-operator/k8s/operator.yaml new file mode 100644 index 0000000000..9e724a2860 --- /dev/null +++ b/sample-operators/kotlin-operator/k8s/operator.yaml @@ -0,0 +1,71 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kotlin-operator + +--- +apiVersion: v1 +kind: Pod +metadata: + name: kotlin-operator +spec: + serviceAccountName: kotlin-operator + containers: + - name: operator + image: kotlin-sample-operator + imagePullPolicy: Never + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kotlin-operator +subjects: + - kind: ServiceAccount + name: kotlin-operator +roleRef: + kind: ClusterRole + name: kotlin-operator + apiGroup: rbac.authorization.k8s.io + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kotlin-operator +rules: + - apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' + - apiGroups: + - "sample.javaoperatorsdk" + resources: + - configmapcopies + - configmapcopies/status + verbs: + - '*' + - apiGroups: + - "" + resources: + - configmaps + verbs: + - '*' diff --git a/sample-operators/kotlin-operator/pom.xml b/sample-operators/kotlin-operator/pom.xml new file mode 100644 index 0000000000..532aecbf1b --- /dev/null +++ b/sample-operators/kotlin-operator/pom.xml @@ -0,0 +1,175 @@ + + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.5.1-SNAPSHOT + + + sample-kotlin-operator + jar + Operator SDK - Samples - Kotlin Operator + Minimalist operator implemented in Kotlin, copying a value from a custom + resource's spec into a ConfigMap, verifying JOSDK works properly end-to-end when used from + Kotlin + + + 2.4.10 + + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.apache.logging.log4j + log4j-slf4j2-impl + compile + + + org.apache.logging.log4j + log4j-core + compile + + + org.awaitility + awaitility + test + + + io.javaoperatorsdk + operator-framework-junit + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${java.version} + + + + kotlin-compile + + compile + + process-sources + + + ${project.basedir}/src/main/kotlin + + + + + kotlin-test-compile + + test-compile + + process-test-sources + + + ${project.basedir}/src/test/kotlin + + + + + + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 0 + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + kotlin-sample-operator + + + io.javaoperatorsdk.operator.sample.ConfigMapCopyOperatorKt + + + + + + + diff --git a/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopy.kt b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopy.kt new file mode 100644 index 0000000000..dcf7605d6d --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopy.kt @@ -0,0 +1,25 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +import io.fabric8.kubernetes.api.model.Namespaced +import io.fabric8.kubernetes.client.CustomResource +import io.fabric8.kubernetes.model.annotation.Group +import io.fabric8.kubernetes.model.annotation.Version + +@Group("sample.javaoperatorsdk") +@Version("v1") +class ConfigMapCopy : CustomResource(), Namespaced diff --git a/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyOperator.kt b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyOperator.kt new file mode 100644 index 0000000000..4b764e580c --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyOperator.kt @@ -0,0 +1,24 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +import io.javaoperatorsdk.operator.Operator + +fun main() { + val operator = Operator() + operator.register(ConfigMapCopyReconciler()) + operator.start() +} diff --git a/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyReconciler.kt b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyReconciler.kt new file mode 100644 index 0000000000..8255d125d1 --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyReconciler.kt @@ -0,0 +1,64 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +import io.fabric8.kubernetes.api.model.ConfigMapBuilder +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder +import io.javaoperatorsdk.operator.api.reconciler.Context +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration +import io.javaoperatorsdk.operator.api.reconciler.Reconciler +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl + +/** + * Minimalist reconciler verifying that JOSDK works properly end-to-end when both the custom + * resource and the reconciler are implemented in Kotlin: it copies `spec.message` into a + * `ConfigMap`, exercising deserialization of the custom resource by the fabric8 client as well as + * the rest of the reconciliation runtime. + */ +@ControllerConfiguration +class ConfigMapCopyReconciler : Reconciler { + + companion object { + const val MESSAGE_KEY = "message" + + fun configMapName(resource: ConfigMapCopy): String = resource.metadata.name + "-config" + } + + override fun reconcile( + resource: ConfigMapCopy, + context: Context + ): UpdateControl { + val data: Map = mapOf(MESSAGE_KEY to resource.spec.message) + val configMap = + ConfigMapBuilder() + .withMetadata( + ObjectMetaBuilder() + .withName(configMapName(resource)) + .withNamespace(resource.metadata.namespace) + .build()) + .withData(data) + .build() + configMap.addOwnerReference(resource) + + context.client.resource(configMap).serverSideApply() + + resource.status = ConfigMapCopyStatus() + resource.status.configMapName = configMap.metadata.name + resource.status.observedMessage = resource.spec.message + + return UpdateControl.patchStatus(resource) + } +} diff --git a/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopySpec.kt b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopySpec.kt new file mode 100644 index 0000000000..84bd10acdf --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopySpec.kt @@ -0,0 +1,20 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +class ConfigMapCopySpec { + var message: String = "" +} diff --git a/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyStatus.kt b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyStatus.kt new file mode 100644 index 0000000000..49f9fe87a0 --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyStatus.kt @@ -0,0 +1,21 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +class ConfigMapCopyStatus { + var configMapName: String? = null + var observedMessage: String? = null +} diff --git a/sample-operators/kotlin-operator/src/main/resources/log4j2.xml b/sample-operators/kotlin-operator/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..147f494c1d --- /dev/null +++ b/sample-operators/kotlin-operator/src/main/resources/log4j2.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/sample-operators/kotlin-operator/src/test/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyE2E.kt b/sample-operators/kotlin-operator/src/test/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyE2E.kt new file mode 100644 index 0000000000..e0676528b3 --- /dev/null +++ b/sample-operators/kotlin-operator/src/test/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyE2E.kt @@ -0,0 +1,112 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample + +import io.fabric8.kubernetes.api.model.ConfigMap +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder +import io.fabric8.kubernetes.client.KubernetesClientBuilder +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension +import java.io.FileInputStream +import java.time.Duration +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.Awaitility.await +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Dual-mode (local and remote) E2E test exercising [ConfigMapCopyReconciler]: creates a + * [ConfigMapCopy] resource, asserts a [ConfigMap] is created with the message copied from spec, + * updates the message, and verifies the [ConfigMap] is garbage collected on deletion via the + * owner reference. + */ +class ConfigMapCopyE2E { + + companion object { + private val log: Logger = LoggerFactory.getLogger(ConfigMapCopyE2E::class.java) + + const val TEST_RESOURCE_NAME = "test-config-map-copy" + const val INITIAL_MESSAGE = "hello from kotlin" + const val UPDATED_MESSAGE = "updated from kotlin" + const val GARBAGE_COLLECTION_TIMEOUT_SECONDS = 90L + + private val client = KubernetesClientBuilder().build() + + fun isLocal(): Boolean { + val deployment = System.getProperty("test.deployment") + val remote = deployment != null && deployment == "remote" + log.info("Running the operator " + (if (remote) "remotely" else "locally")) + return !remote + } + } + + @RegisterExtension + val operator: AbstractOperatorExtension = + if (isLocal()) + LocallyRunOperatorExtension.builder().withReconciler(ConfigMapCopyReconciler()).build() + else + ClusterDeployedOperatorExtension.builder() + .withOperatorDeployment( + FileInputStream("k8s/operator.yaml").use { client.load(it).items() }) + .build() + + @Test + fun copiesMessageFromSpecIntoConfigMap() { + val resource = operator.create(testResource(INITIAL_MESSAGE)) + + await().untilAsserted { + val updated = operator.get(ConfigMapCopy::class.java, TEST_RESOURCE_NAME) + assertThat(updated.status).isNotNull() + assertThat(updated.status.observedMessage).isEqualTo(INITIAL_MESSAGE) + + val configMap = + operator.get(ConfigMap::class.java, ConfigMapCopyReconciler.configMapName(resource)) + assertThat(configMap).isNotNull() + assertThat(configMap.data[ConfigMapCopyReconciler.MESSAGE_KEY]).isEqualTo(INITIAL_MESSAGE) + } + + val toUpdate = operator.get(ConfigMapCopy::class.java, TEST_RESOURCE_NAME) + toUpdate.spec.message = UPDATED_MESSAGE + operator.replace(toUpdate) + + await().untilAsserted { + val configMap = + operator.get(ConfigMap::class.java, ConfigMapCopyReconciler.configMapName(resource)) + assertThat(configMap.data[ConfigMapCopyReconciler.MESSAGE_KEY]).isEqualTo(UPDATED_MESSAGE) + } + + operator.delete(toUpdate) + + await() + .atMost(Duration.ofSeconds(GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted { + assertThat( + operator.get(ConfigMap::class.java, ConfigMapCopyReconciler.configMapName(resource))) + .isNull() + } + } + + private fun testResource(message: String): ConfigMapCopy { + val resource = ConfigMapCopy() + resource.metadata = + ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).withNamespace(operator.namespace).build() + resource.spec = ConfigMapCopySpec().apply { this.message = message } + return resource + } +} diff --git a/sample-operators/kotlin-operator/src/test/resources/log4j2.xml b/sample-operators/kotlin-operator/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..8b1c5ca270 --- /dev/null +++ b/sample-operators/kotlin-operator/src/test/resources/log4j2.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 765feea520..1d35c908f2 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -36,5 +36,6 @@ leader-election controller-namespace-deletion operations + kotlin-operator