From fb0ecdb561b4dc36edaa4b6f5cbfedc5d92e650a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 17 Jul 2026 12:54:14 +0000 Subject: [PATCH 01/29] Set new SNAPSHOT version into pom files. --- bootstrapper-maven-plugin/pom.xml | 2 +- caffeine-bounded-cache-support/pom.xml | 2 +- micrometer-support/pom.xml | 2 +- migration/pom.xml | 2 +- operator-framework-bom/pom.xml | 2 +- operator-framework-core/pom.xml | 2 +- operator-framework-junit/pom.xml | 2 +- operator-framework/pom.xml | 2 +- pom.xml | 2 +- sample-operators/controller-namespace-deletion/pom.xml | 2 +- sample-operators/leader-election/pom.xml | 2 +- sample-operators/mysql-schema/pom.xml | 2 +- sample-operators/operations/pom.xml | 2 +- sample-operators/pom.xml | 2 +- sample-operators/tomcat-operator/pom.xml | 2 +- sample-operators/webpage/pom.xml | 2 +- test-index-processor/pom.xml | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index 9959c62396..b0c3cae39a 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT bootstrapper diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index 8fe7f736df..97ea417a47 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT caffeine-bounded-cache-support diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 9de7303b24..733bc4528c 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT micrometer-support diff --git a/migration/pom.xml b/migration/pom.xml index 76a1c7902c..efca89274b 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT migration diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 6e21c9c756..ef9e408aec 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index 9104c6893c..5763c5490a 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT ../pom.xml diff --git a/operator-framework-junit/pom.xml b/operator-framework-junit/pom.xml index f7a94a4d68..2a81b65582 100644 --- a/operator-framework-junit/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT operator-framework-junit diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 7561294cb5..9f05153390 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT operator-framework diff --git a/pom.xml b/pom.xml index 69417d9c75..22b3535e3b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index b546b8dafe..034027a521 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index 1b888cd40b..cfc5dff12a 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 1ac5a83768..8cadad3786 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/operations/pom.xml b/sample-operators/operations/pom.xml index bcd011665a..fb0726d2de 100644 --- a/sample-operators/operations/pom.xml +++ b/sample-operators/operations/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-operations diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 2e81c85e5b..765feea520 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 7c03468850..26f2b5e1a5 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 96ad6bff99..c3d13d11fe 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT sample-webpage-operator diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index 4f2dcb6cc5..574a388bf7 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.0-SNAPSHOT + 5.5.1-SNAPSHOT test-index-processor From 584f7598081383f5329aae79fb035131b9f2d190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 17 Jul 2026 15:04:32 +0200 Subject: [PATCH 02/29] docs: blog post 5.5 wording (#3505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor change from "more correct" ti "correct". Which is the correct phrase. Signed-off-by: Attila Mészáros --- docs/content/en/blog/releases/v5-5-release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/blog/releases/v5-5-release.md b/docs/content/en/blog/releases/v5-5-release.md index 0dcda16cb4..3ce4a83f48 100644 --- a/docs/content/en/blog/releases/v5-5-release.md +++ b/docs/content/en/blog/releases/v5-5-release.md @@ -58,7 +58,7 @@ operation type; when one does not fit, supply your own via `Options.matchAndFilt > locking; otherwise a concurrent external update inside the filter window may be missed until the > next resync. -### More correct own-event filtering (no-op update edge case) +### Correct own-event filtering (no-op update edge case) The read-cache-after-write own-event filter has been reworked to fix an edge case where a legitimate external change could be filtered out together with a controller's own **no-op** update — for From aafd7e5f55aebcbb8bcc76fc5f90e0f1574de8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 19 Jul 2026 11:03:10 +0200 Subject: [PATCH 03/29] improve: deflake WorkflowMultipleActivationIT (#3503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../WorkflowMultipleActivationIT.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java index 93cd2c5e06..6de1bc43a7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java @@ -76,6 +76,9 @@ void deactivatingAndReactivatingDependent() { () -> { var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); assertThat(cm).isNull(); + var cr = + extension.get(WorkflowMultipleActivationCustomResource.class, TEST_RESOURCE1); + assertThat(cr).isNull(); }); ActivationCondition.MET = false; From d6887e036ccf4c288c92bf7cdd23cb44c800bcfc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:17:41 +0200 Subject: [PATCH 04/29] chore(deps): bump openrewrite.version from 8.87.2 to 8.87.4 (#3506) Bumps `openrewrite.version` from 8.87.2 to 8.87.4. Updates `org.openrewrite:rewrite-java` from 8.87.2 to 8.87.4 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.2...v8.87.4) Updates `org.openrewrite:rewrite-maven` from 8.87.2 to 8.87.4 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.2...v8.87.4) Updates `org.openrewrite:rewrite-test` from 8.87.2 to 8.87.4 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.2...v8.87.4) Updates `org.openrewrite:rewrite-java-17` from 8.87.2 to 8.87.4 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.2...v8.87.4) --- updated-dependencies: - dependency-name: org.openrewrite:rewrite-java dependency-version: 8.87.4 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-maven dependency-version: 8.87.4 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-test dependency-version: 8.87.4 dependency-type: direct:development update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-java-17 dependency-version: 8.87.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- migration/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration/pom.xml b/migration/pom.xml index efca89274b..346935d360 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -29,7 +29,7 @@ OpenRewrite migration recipes for Java Operator SDK - 8.87.2 + 8.87.4 From 51d5c889e645d30dbbde7f83f9f00eb89c715c9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:38:06 +0200 Subject: [PATCH 05/29] chore(deps): bump openrewrite.version from 8.87.4 to 8.87.5 (#3508) Bumps `openrewrite.version` from 8.87.4 to 8.87.5. Updates `org.openrewrite:rewrite-java` from 8.87.4 to 8.87.5 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.4...v8.87.5) Updates `org.openrewrite:rewrite-maven` from 8.87.4 to 8.87.5 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.4...v8.87.5) Updates `org.openrewrite:rewrite-test` from 8.87.4 to 8.87.5 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.4...v8.87.5) Updates `org.openrewrite:rewrite-java-17` from 8.87.4 to 8.87.5 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.4...v8.87.5) --- updated-dependencies: - dependency-name: org.openrewrite:rewrite-java dependency-version: 8.87.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-maven dependency-version: 8.87.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-test dependency-version: 8.87.5 dependency-type: direct:development update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-java-17 dependency-version: 8.87.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- migration/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration/pom.xml b/migration/pom.xml index 346935d360..d0be1d99b4 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -29,7 +29,7 @@ OpenRewrite migration recipes for Java Operator SDK - 8.87.4 + 8.87.5 From eb978f9951490f26e490337ef7022a8aa279741d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:27:28 +0200 Subject: [PATCH 06/29] chore(deps-dev): bump com.mycila:license-maven-plugin (#3509) Bumps [com.mycila:license-maven-plugin](https://github.com/mathieucarbou/license-maven-plugin) from 5.0.0 to 5.1.1. - [Release notes](https://github.com/mathieucarbou/license-maven-plugin/releases) - [Changelog](https://github.com/mathieucarbou/license-maven-plugin/blob/master/docs/releases.md) - [Commits](https://github.com/mathieucarbou/license-maven-plugin/compare/v5.0.0...v5.1.1) --- updated-dependencies: - dependency-name: com.mycila:license-maven-plugin dependency-version: 5.1.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22b3535e3b..8b450204e3 100644 --- a/pom.xml +++ b/pom.xml @@ -390,7 +390,7 @@ com.mycila license-maven-plugin - 5.0.0 + 5.1.1 true From 8fed9bba7bd88f2a33bf8230635f6c1838bdd044 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:34:07 +0200 Subject: [PATCH 07/29] chore(deps): bump org.apache.maven.plugins:maven-jar-plugin (#3511) Bumps [org.apache.maven.plugins:maven-jar-plugin](https://github.com/apache/maven-jar-plugin) from 3.5.0 to 3.5.1. - [Release notes](https://github.com/apache/maven-jar-plugin/releases) - [Commits](https://github.com/apache/maven-jar-plugin/compare/maven-jar-plugin-3.5.0...maven-jar-plugin-3.5.1) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-jar-plugin dependency-version: 3.5.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8b450204e3..429e59d58d 100644 --- a/pom.xml +++ b/pom.xml @@ -94,7 +94,7 @@ 3.12.0 3.5.0 3.4.0 - 3.5.0 + 3.5.1 3.5.0 3.2.8 1.7.0 From 67c47c7490068418b2c4d0b539d145cfe6de7862 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:34:13 +0200 Subject: [PATCH 08/29] chore(deps): bump openrewrite.version from 8.87.5 to 8.87.7 (#3510) Bumps `openrewrite.version` from 8.87.5 to 8.87.7. Updates `org.openrewrite:rewrite-java` from 8.87.5 to 8.87.7 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.5...v8.87.7) Updates `org.openrewrite:rewrite-maven` from 8.87.5 to 8.87.7 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.5...v8.87.7) Updates `org.openrewrite:rewrite-test` from 8.87.5 to 8.87.7 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.5...v8.87.7) Updates `org.openrewrite:rewrite-java-17` from 8.87.5 to 8.87.7 - [Release notes](https://github.com/openrewrite/rewrite/releases) - [Commits](https://github.com/openrewrite/rewrite/compare/v8.87.5...v8.87.7) --- updated-dependencies: - dependency-name: org.openrewrite:rewrite-java dependency-version: 8.87.7 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-maven dependency-version: 8.87.7 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-test dependency-version: 8.87.7 dependency-type: direct:development update-type: version-update:semver-patch - dependency-name: org.openrewrite:rewrite-java-17 dependency-version: 8.87.7 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- migration/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration/pom.xml b/migration/pom.xml index d0be1d99b4..a7526c800d 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -29,7 +29,7 @@ OpenRewrite migration recipes for Java Operator SDK - 8.87.5 + 8.87.7 From c9302c6c807654bb0046be8f01c47f6a903177de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:43:03 +0200 Subject: [PATCH 09/29] chore(deps): bump com.diffplug.spotless:spotless-maven-plugin from 3.8.0 to 3.9.0 (#3515) Signed-off-by: dependabot[bot] --- operator-framework-bom/pom.xml | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index ef9e408aec..cc13b17b75 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -54,7 +54,7 @@ 3.2.8 3.4.0 3.12.0 - 3.8.0 + 3.9.0 0.11.0 diff --git a/pom.xml b/pom.xml index 429e59d58d..f205805471 100644 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ 3.1.4 10.0.0 3.5.2 - 3.8.0 + 3.9.0 4.10.3.0 From 4fab8b1c32e9369a08956ba6bfba57b1ebd027a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 28 Jul 2026 15:46:57 +0200 Subject: [PATCH 10/29] fix: use explicit JSON merge patch type in merge patch operations (#3514) Fabric8's no-context patch() fetches the resource from the server and computes an RFC 6902 JSON Patch diff, which removes the fields omitted from a partial resource (including the whole spec). Pass an explicit JSON_MERGE PatchContext so the non-status merge patch operations really send application/merge-patch+json. --- .../api/reconciler/ResourceOperations.java | 12 +++- .../reconciler/ResourceOperationsTest.java | 64 +++++++++++++++++-- .../ResourceOperationsIT.java | 27 ++++++++ .../ResourceOperationsReconciler.java | 16 +++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index c4532aa284..603548e33d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -751,7 +751,10 @@ public R jsonMergePatch(R desired) { * @return the patched resource as returned by the API server */ public R jsonMergePatch(R desired, Options options) { - return resourcePatch(desired, r -> context.getClient().resource(r).patch(), options); + return resourcePatch( + desired, + r -> context.getClient().resource(r).patch(PatchContext.of(PatchType.JSON_MERGE)), + options); } /** @@ -767,7 +770,10 @@ public R jsonMergePatch(R desired, Options options) { public R jsonMergePatch( R desired, InformerEventSource informerEventSource, Options options) { return resourcePatch( - desired, r -> context.getClient().resource(r).patch(), informerEventSource, options); + desired, + r -> context.getClient().resource(r).patch(PatchContext.of(PatchType.JSON_MERGE)), + informerEventSource, + options); } /** @@ -838,7 +844,7 @@ public P jsonMergePatchPrimary(P resource) { public P jsonMergePatchPrimary(P resource, Options options) { return resourcePatch( resource, - r -> context.getClient().resource(r).patch(), + r -> context.getClient().resource(r).patch(PatchContext.of(PatchType.JSON_MERGE)), context.eventSourceRetriever().getControllerEventSource(), options); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index f5b010d21c..95c1b1815e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.stubbing.Answer; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -31,6 +32,7 @@ import io.fabric8.kubernetes.client.dsl.NamespaceableResource; import io.fabric8.kubernetes.client.dsl.Resource; import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.TestUtils; import io.javaoperatorsdk.operator.api.config.Cloner; @@ -40,6 +42,7 @@ import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; @@ -471,15 +474,20 @@ private TestCustomResource wireVerbMocks() { when(verbClientResource.patch(any(PatchContext.class))).thenReturn(updated); // both cache paths execute the update operation so the underlying client verb runs - Answer runOperation = - invocation -> ((UnaryOperator) invocation.getArgument(1)).apply(invocation.getArgument(0)); when(verbEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) - .thenAnswer(runOperation); + .thenAnswer(runUpdateOperation()); when(verbEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) - .thenAnswer(runOperation); + .thenAnswer(runUpdateOperation()); return updated; } + /** Answer that executes the update operation passed to the caching methods of an event source. */ + @SuppressWarnings("rawtypes") + private static Answer runUpdateOperation() { + return invocation -> + ((UnaryOperator) invocation.getArgument(1)).apply(invocation.getArgument(0)); + } + // ---- update ------------------------------------------------------------- @Test @@ -610,7 +618,7 @@ void createWithNullEventSourceFallsBackToForceFilter() { // ---- patch / status verbs ---------------------------------------------- @Test - void jsonMergePatchCacheOnlyCallsClientPatch() { + void jsonMergePatchCacheOnlyCallsClientMergePatch() { var resource = TestUtils.testCustomResource1(); var updated = wireVerbMocks(); @@ -620,7 +628,51 @@ void jsonMergePatchCacheOnlyCallsClientPatch() { assertThat(result).isSameAs(updated); verify(verbEventSource, times(1)) .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); - verify(verbClientResource, times(1)).patch(); + assertThat(capturedPatchType()).isEqualTo(PatchType.JSON_MERGE); + } + + @Test + void jsonMergePatchWithEventSourceCallsClientMergePatch() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + InformerEventSource informerEventSource = + mock(InformerEventSource.class); + when(informerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) + .thenAnswer(runUpdateOperation()); + + var result = + resourceOperations.jsonMergePatch( + resource, informerEventSource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + assertThat(capturedPatchType()).isEqualTo(PatchType.JSON_MERGE); + } + + @Test + void jsonMergePatchPrimaryCallsClientMergePatch() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + when(controllerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) + .thenAnswer(runUpdateOperation()); + + var result = + resourceOperations.jsonMergePatchPrimary(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + assertThat(capturedPatchType()).isEqualTo(PatchType.JSON_MERGE); + } + + /** + * Asserts that a merge patch operation went through Fabric8 with an explicit patch context and + * returns the used patch type. The no-context {@code patch()} must never be used for merge + * patches: it computes an RFC 6902 diff against the resource fetched from the server, which + * removes the fields missing from a partial resource. + */ + private PatchType capturedPatchType() { + verify(verbClientResource, never()).patch(); + var captor = ArgumentCaptor.forClass(PatchContext.class); + verify(verbClientResource, times(1)).patch(captor.capture()); + return captor.getValue().getPatchType(); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java index d655ef7e4f..e8875bbefd 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java @@ -24,6 +24,8 @@ import io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.Operation; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.LABEL_VALUE; import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.SPEC_MARKER; import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.STATUS_VALUE; import static org.assertj.core.api.Assertions.assertThat; @@ -86,6 +88,31 @@ void jsonMergePatch() { assertSpecOperation(Operation.JSON_MERGE_PATCH); } + /** + * A merge patch applied with a partial resource must only change what the partial resource + * contains; fields omitted from it - like the whole {@code spec} - have to survive on the server. + */ + @Test + void jsonMergePatchWithPartialResourceKeepsOmittedFields() { + var operation = Operation.JSON_MERGE_PATCH_PARTIAL_METADATA; + runOperation(operation); + + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> + assertThat(actual().getMetadata().getLabels()) + .as("the partial metadata merge patch should be applied") + .containsEntry(LABEL_KEY, LABEL_VALUE)); + + assertThat(actual().getSpec()) + .as("the spec omitted from the partial merge patch should not be removed") + .isNotNull() + .extracting(ResourceOperationsSpec::getValue) + .isEqualTo(INITIAL_SPEC_VALUE); + assertConvergesWithoutLooping(operation); + } + @Test void jsonMergePatchStatus() { assertStatusOperation(Operation.JSON_MERGE_PATCH_STATUS); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java index bbfb7cd098..493c06826b 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.baseapi.resourceoperations; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; @@ -40,6 +41,8 @@ public class ResourceOperationsReconciler implements Reconciler reconcile( markResource(resource); ops.jsonMergePatchPrimary(resource); } + // a partial resource, holding only the metadata to change: a merge patch must not touch the + // fields omitted from it (like spec) + case JSON_MERGE_PATCH_PARTIAL_METADATA -> { + ResourceOperationsCustomResource partial = new ResourceOperationsCustomResource(); + partial.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .withLabels(Map.of(LABEL_KEY, LABEL_VALUE)) + .build()); + ops.jsonMergePatchPrimary(partial); + } case JSON_MERGE_PATCH_STATUS -> { resource.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); ops.jsonMergePatchPrimaryStatus(resource); From 8da41431a30fb2fcccd72e7678c7e8a7c147505b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=9D=98=EC=A4=80?= Date: Wed, 29 Jul 2026 20:56:35 +0900 Subject: [PATCH 11/29] test: verify Kotlin interop with a unit test and a Kotlin sample operator E2E test (#3513) Adds a Maven-compiled Kotlin test source set to operator-framework-core and a smoke test verifying that a checked (non-RuntimeException) Exception thrown from a Kotlin DependentResource is properly caught and reported by the workflow executor, so that retries are triggered as expected. Kotlin does not have checked exceptions, so Kotlin code can throw a checked Exception from an overridden method without declaring it, even though the Java DependentResource#reconcile signature does not declare `throws Exception`. Before #2965 this exception would not have been caught by NodeExecutor, since it only handled RuntimeException, silently swallowing the error and preventing retries. Closes #2967 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: close class brace in Kotlin test; add Kotlin sample operator E2E Fixes a missing closing brace left in KotlinCheckedExceptionDependentResourceTest.kt. Adds a new sample-operators/kotlin-operator module: a minimalist Kotlin operator (ConfigMapCopyReconciler) that copies a value from a custom resource's spec into a ConfigMap, with a dual-mode (local/remote) E2E test mirroring the other samples. This verifies deserialization via the fabric8 client and the full reconciliation runtime when both the CR and reconciler are implemented in Kotlin, addressing the maintainer's request for broader coverage than the existing unit-level smoke test. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- .github/workflows/e2e-test.yml | 1 + operator-framework-core/pom.xml | 43 +++++ ...inCheckedExceptionDependentResourceTest.kt | 94 ++++++++++ sample-operators/kotlin-operator/README.md | 15 ++ .../kotlin-operator/k8s/operator.yaml | 71 +++++++ sample-operators/kotlin-operator/pom.xml | 175 ++++++++++++++++++ .../operator/sample/ConfigMapCopy.kt | 25 +++ .../operator/sample/ConfigMapCopyOperator.kt | 24 +++ .../sample/ConfigMapCopyReconciler.kt | 64 +++++++ .../operator/sample/ConfigMapCopySpec.kt | 20 ++ .../operator/sample/ConfigMapCopyStatus.kt | 21 +++ .../src/main/resources/log4j2.xml | 30 +++ .../operator/sample/ConfigMapCopyE2E.kt | 112 +++++++++++ .../src/test/resources/log4j2.xml | 30 +++ sample-operators/pom.xml | 1 + 15 files changed, 726 insertions(+) create mode 100644 operator-framework-core/src/test/kotlin/io/javaoperatorsdk/operator/processing/dependent/workflow/KotlinCheckedExceptionDependentResourceTest.kt create mode 100644 sample-operators/kotlin-operator/README.md create mode 100644 sample-operators/kotlin-operator/k8s/operator.yaml create mode 100644 sample-operators/kotlin-operator/pom.xml create mode 100644 sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopy.kt create mode 100644 sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyOperator.kt create mode 100644 sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyReconciler.kt create mode 100644 sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopySpec.kt create mode 100644 sample-operators/kotlin-operator/src/main/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyStatus.kt create mode 100644 sample-operators/kotlin-operator/src/main/resources/log4j2.xml create mode 100644 sample-operators/kotlin-operator/src/test/kotlin/io/javaoperatorsdk/operator/sample/ConfigMapCopyE2E.kt create mode 100644 sample-operators/kotlin-operator/src/test/resources/log4j2.xml 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 From cd6759def0fb83e3cfc534de7ccb48bde10dc930 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:52:51 +0200 Subject: [PATCH 12/29] chore(deps): bump openrewrite.version from 8.87.7 to 8.88.0 (#3516) Signed-off-by: dependabot[bot] --- migration/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration/pom.xml b/migration/pom.xml index a7526c800d..771cc67cf4 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -29,7 +29,7 @@ OpenRewrite migration recipes for Java Operator SDK - 8.87.7 + 8.88.0 From 4e9008bc346a29d69330429625e35f3f67ffe620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Aug 2026 11:06:50 +0200 Subject: [PATCH 13/29] docs: facelift of operator list in README.md (#3534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 987750324a..be946e9090 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,21 @@ projects want to advertise that fact here. For this reason, we ask that if you'd to be featured in this section, please open a PR, adding a link to and short description of your project, as shown below: -- [kroxylicious](https://github.com/kroxylicious/kroxylicious/tree/main/kroxylicious-kubernetes/kroxylicious-operator) Kafka proxy operator -- [ExposedApp operator](https://github.com/halkyonio/exposedapp-rhdblog): a sample operator - written to illustrate JOSDK concepts and its Quarkus extension in the ["Write Kubernetes - Operators in Java with the Java Operator SDK" blog series](https://developers.redhat.com/articles/2022/02/15/write-kubernetes-java-java-operator-sdk#). -- [Keycloak operator](https://github.com/keycloak/keycloak/tree/main/operator): the official - Keycloak operator, built with Quarkus and JOSDK. -- [Apache Flink Kubernetes operator](https://github.com/apache/flink-kubernetes-operator) is the market leader among Flink operators. +- [Apache Flink Kubernetes Operator](https://github.com/apache/flink-kubernetes-operator) is the market leader among Flink operators. - [Apache Spark Kubernetes Operator](https://github.com/apache/spark-kubernetes-operator) emerging operator for Spark. +- [kroxylicious](https://github.com/kroxylicious/kroxylicious/tree/main/kroxylicious-kubernetes/kroxylicious-operator) Kafka proxy operator +- [Debezium Operator](https://github.com/debezium/debezium-operator): Debezium Operator adds Change-Data-Capture capabilities to your Kubernetes or OpenShift cluster by providing an easy way to run and manage [Debezium Server](https://debezium.io/documentation/reference/stable/operations/debezium-server.html) instances. +- [Keycloak operator](https://github.com/keycloak/keycloak/tree/main/operator): the official Keycloak operator, built with Quarkus and JOSDK. +- [Apache Hive Kubernetes Operator](https://github.com/apache/hive/tree/master/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator) +- [OpenMetadata Operator](https://github.com/open-metadata/OpenMetadata/tree/main/openmetadata-k8s-operator) for AI context, data cataloging, and metadata management service - [Strimzi Access operator](https://github.com/strimzi/kafka-access-operator). While the core Strimzi operator development predates JOSDK, but new components like the Access operator is using the framework. +- [Wanaku](https://github.com/wanaku-ai/wanaku/tree/main/apps/wanaku-operator) MCP Router Operator +- [Apicurio Registry Operator](https://github.com/Apicurio/apicurio-registry/tree/main/operator) +- [Kaap](https://github.com/datastax/kaap) autoscaling Operator for Apache Pulsar - [EureKubeOperator](https://medium.com/@heesuk.dev/implementing-kubernetes-operator-for-eureka-service-discovery-integration-by-java-operator-sdk-d21d8087c38e): integrates service discovery of Eureka and Kubernetes using the framework - developed by 11street. It is not released as an open source yet but is very interesting to read about this problem and how it is solved by an operator written with JOSDK. - [Locust k8s operator](https://github.com/AbdelrhmanHamouda/locust-k8s-operator): Cloud native solution to run performance tests on any Kubernetes cluster. -- [Strimzi Schema Registry Operator](https://github.com/shangyuantech/strimzi-registry-ksql-operator): A Schema Registry Operator based on JOSDK for running the Confluent Schema Registry with a Strimzi-based Kafka cluster. -- [Airflow Dag Operator](https://github.com/cdmikechen/airflow-dag-operator): Use JOSDK(Quarkus Extension) to replace Airflow Git Sync strategy. The main idea of the project is to start a synchronization container on each airflow pod to synchronize the DAG/files into the DAG folder. +- [ExposedApp operator](https://github.com/halkyonio/exposedapp-rhdblog): a sample operator + written to illustrate JOSDK concepts and its Quarkus extension in the ["Write Kubernetes + Operators in Java with the Java Operator SDK" blog series](https://developers.redhat.com/articles/2022/02/15/write-kubernetes-java-java-operator-sdk#). - [Glasskube Operator](https://github.com/glasskube/operator): simplifies the deployment, maintenance and upgrade of popular open source business tools. It is written in Kotlin and uses the JOSDK and fabric8 Kubernetes client with Kotlin-based DSL. -- [Debezium Operator](https://github.com/debezium/debezium-operator): Debezium Operator adds Change-Data-Capture capabilities to your Kubernetes or Openshift cluster by providing an easy way to run and manage [Debezium Server](https://debezium.io/documentation/reference/stable/operations/debezium-server.html) instances. From 778e42b8a9647001d139f1c864a1afc2a4d22a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 09:41:00 +0200 Subject: [PATCH 14/29] fix: remove dangling placeholder from event source close warning (#3532) `log.warn("Error closing {} -> {}", eventSource.name(), e)` supplies two placeholders but only one non-throwable argument. SLF4J trims a trailing `Throwable` and reports it as the exception, so the stack trace is logged correctly, but the second placeholder is left unfilled and the message renders with a literal brace pair: Error closing myEventSource -> {} Drops the redundant placeholder and names what is being logged. Note: the superficially similar calls in `InformerManager.stop` and `InformerWrapper.start` are correct as they stand - they pass more non-throwable arguments than placeholders, so nothing is left dangling. --- .../operator/processing/event/EventSourceManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java index 441d3cf178..9419ebde9a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -143,7 +143,7 @@ private Void stopEventSource(EventSource eventSource) { eventSource.stop(); logEventSourceEvent(eventSource, "Stopped"); } catch (Exception e) { - log.warn("Error closing {} -> {}", eventSource.name(), e); + log.warn("Error closing event source {}", eventSource.name(), e); } return null; } From 77c15317dd35348fed7dedcb3c5b7285a251d857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 09:42:53 +0200 Subject: [PATCH 15/29] fix: use the cached desired state when selecting an external secondary resource (#3528) `AbstractExternalDependentResource.selectTargetSecondaryResource` called `desired(primary, context)` directly instead of going through `getOrComputeDesired(context)`. `getOrComputeDesired` exists precisely so that `desired` is invoked at most once per reconciliation; its javadoc states that the SDK should call it exclusively and that `desired` should never be called directly, because this "supports scenarios where idempotent computation of the desired state is not feasible". Calling `desired` directly here means it is computed an extra time on every reconciliation of an external dependent resource: once to pick the target secondary and again from `match`/`reconcile` via the cache. That is wasted work in all cases, and produces an inconsistent desired state for any implementation whose `desired` is not idempotent - exactly the case the cache is there to support. `KubernetesDependentResource` already routes through `getOrComputeDesired` via `targetSecondaryResourceID`; this brings the external variant in line. No unit test is added: the code path is only covered by the external dependent integration tests under `operator-framework`, which need a cluster. --- .../processing/dependent/AbstractExternalDependentResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java index c391092422..9a2847bcc0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java @@ -126,7 +126,7 @@ protected InformerEventSource getExternalStateEventSource() { @Override protected Optional selectTargetSecondaryResource( Set secondaryResources, P primary, Context

context) { - R desired = desired(primary, context); + R desired = getOrComputeDesired(context); List targetResources; var desiredID = resourceIDMapper.idFor(desired); targetResources = From 6ff8b156b079ed0430a3f10deeb19eb53d57959b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 10:17:50 +0200 Subject: [PATCH 16/29] fix: do not eagerly evaluate the cluster-scoped fallback in KubernetesResourceFetcher (#3527) `fetchResource` selected between a namespaced and a cluster-scoped lookup with `Optional.orElse`: return resourceId.getNamespace() .map(ns -> client.resources(rClass).inNamespace(ns).withName(name).get()) .orElse(client.resources(rClass).withName(name).get()); `orElse` evaluates its argument unconditionally, so every namespaced lookup also performed the cluster-scoped GET. For a namespaced resource type that second request either queries whichever namespace the client happens to default to or fails outright, and its result is then thrown away. This runs on the `BoundedItemStore` cache-miss path (`refreshMissingStateFromServer`), so it doubles the API server requests for every bounded-cache refresh of a namespaced resource. Switches to `orElseGet` so the fallback is only evaluated for cluster-scoped resource ids. --- .../event/source/cache/KubernetesResourceFetcher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java index f0f4557701..90b65f1bcb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java @@ -45,7 +45,7 @@ public R fetchResource(String key) { return resourceId .getNamespace() .map(ns -> client.resources(rClass).inNamespace(ns).withName(resourceId.getName()).get()) - .orElse(client.resources(rClass).withName(resourceId.getName()).get()); + .orElseGet(() -> client.resources(rClass).withName(resourceId.getName()).get()); } public static Function inverseNamespaceKeyFunction() { From aa8ac882bc09808fc5d1409e8cead5aeb53c54bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 10:26:17 +0200 Subject: [PATCH 17/29] fix: create the PollingEventSource timer on start and make it a daemon (#3523) `PollingEventSource` held its timer in a final field initialised at construction: private final Timer timer = new Timer(); Two problems follow from that. The event source cannot be restarted. `stop()` calls `timer.cancel()` and a cancelled `java.util.Timer` cannot be reused, so a subsequent `start()` fails with `IllegalStateException: Timer already cancelled`. Restart is a supported lifecycle - `Operator.stop()` / `start()` recreates the thread pools for exactly this reason, and `TimerEventSource` creates a new `Timer` inside `start()`. The timer thread is not a daemon and is created eagerly. Merely constructing a `PollingEventSource` therefore starts a non-daemon thread that keeps the JVM from exiting, even if the event source is never started, and it outlives an operator that is stopped without stopping its event sources. `TimerEventSource` uses `new Timer(true)`. The timer is now created in `start()` as a daemon and cleared in `stop()`, matching `TimerEventSource`. Adds regression tests for restart and for the daemon flag; the restart one fails with `IllegalStateException: Timer already cancelled` without this change. Note: `PerResourcePollingEventSource` has a related restart limitation because `stop()` calls `shutdownNow()` on the `ScheduledExecutorService` from its configuration. That executor is supplied by the caller, so changing its ownership semantics is a separate discussion and is left out of this change. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../source/polling/PollingEventSource.java | 11 +++++-- .../polling/PollingEventSourceTest.java | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java index f5e5a79430..5c85c24f90 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java @@ -62,7 +62,7 @@ public class PollingEventSource private static final Logger log = LoggerFactory.getLogger(PollingEventSource.class); - private final Timer timer = new Timer(); + private Timer timer; private final GenericResourceFetcher genericResourceFetcher; private final Duration period; private final AtomicBoolean healthy = new AtomicBoolean(true); @@ -75,8 +75,12 @@ public PollingEventSource(Class resourceClass, PollingConfiguration co @Override public void start() throws OperatorException { + if (timer != null) { + return; + } super.start(); getStateAndFillCache(); + timer = new Timer(true); timer.schedule( new TimerTask() { @Override @@ -111,7 +115,10 @@ public interface GenericResourceFetcher { @Override public void stop() throws OperatorException { super.stop(); - timer.cancel(); + if (timer != null) { + timer.cancel(); + timer = null; + } } @Override diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java index 12dbf17be7..92400df4de 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java @@ -60,6 +60,38 @@ public void setup() { setUpSource(pollingEventSource, false); } + @Test + void canBeRestartedAfterStop() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + pollingEventSource.stop(); + + // a cancelled java.util.Timer cannot be reused, a new one has to be created on start + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + assertThat(pollingEventSource.getStatus()).isEqualTo(Status.HEALTHY); + } + + @Test + void timerThreadIsADaemonSoItDoesNotKeepTheJvmAlive() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + + var threadsBeforeStart = Thread.getAllStackTraces().keySet(); + + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + var newTimerThreads = + Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().startsWith("Timer-")) + .filter(t -> !threadsBeforeStart.contains(t)) + .toList(); + + assertThat(newTimerThreads).isNotEmpty().allMatch(Thread::isDaemon); + } + @Test void pollsAndProcessesEvents() throws InterruptedException { when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); From 3e85d04580490f6b4019c701d2217e0cd11826a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 10:26:42 +0200 Subject: [PATCH 18/29] fix: NPE in external resource caching event source when only a generic filter is set (#3518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `acceptedByFiler` guards each of its three filter branches with `onXFilter != null || genericFilter != null`, but the branch body dereferences `onXFilter` unconditionally: if (onAddFilter != null || genericFilter != null) { ... .anyMatch(r -> acceptedByGenericFiler(r) && onAddFilter.accept(r)); So configuring only a generic filter via `setGenericFilter(...)` throws a NullPointerException as soon as a resource is added, deleted or updated. All three branches (add / delete / update) are affected, which means `PollingEventSource`, `PerResourcePollingEventSource` and `CachingInboundEventSource` all break when used with a generic filter only. The existing `genericFilteringEvents` test missed this because it uses a filter that returns `false`: `&&` short-circuits before the null dereference. Only a generic filter that accepts a resource reaches the NPE. Each filter check is now null-safe (an absent filter accepts), which preserves the previous behaviour whenever the specific filter is set. Adds three regression tests, one per branch; they fail with NullPointerException without this change. Signed-off-by: Attila Mészáros --- .../ExternalResourceCachingEventSource.java | 22 ++++++++--- ...xternalResourceCachingEventSourceTest.java | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java index 8a4c476443..6180656e55 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java @@ -163,7 +163,7 @@ private boolean acceptedByFiler(Map cachedResourceMap, Map newReso if (onAddFilter != null || genericFilter != null) { var anyAddAccepted = addedResources.values().stream() - .anyMatch(r -> acceptedByGenericFiler(r) && onAddFilter.accept(r)); + .anyMatch(r -> acceptedByGenericFilter(r) && acceptedByOnAddFilter(r)); if (anyAddAccepted) { return true; } @@ -176,7 +176,7 @@ private boolean acceptedByFiler(Map cachedResourceMap, Map newReso if (onDeleteFilter != null || genericFilter != null) { var anyDeleteAccepted = deletedResource.values().stream() - .anyMatch(r -> acceptedByGenericFiler(r) && onDeleteFilter.accept(r, false)); + .anyMatch(r -> acceptedByGenericFilter(r) && acceptedByOnDeleteFilter(r)); if (anyDeleteAccepted) { return true; } @@ -196,13 +196,25 @@ private boolean acceptedByFiler(Map cachedResourceMap, Map newReso .anyMatch( entry -> { var newResource = newResourcesMap.get(entry.getKey()); - return acceptedByGenericFiler(newResource) - && onUpdateFilter.accept(newResource, entry.getValue()); + return acceptedByGenericFilter(newResource) + && acceptedByOnUpdateFilter(newResource, entry.getValue()); }); } else return !possibleUpdatedResources.isEmpty(); } - private boolean acceptedByGenericFiler(R resource) { + private boolean acceptedByOnAddFilter(R resource) { + return onAddFilter == null || onAddFilter.accept(resource); + } + + private boolean acceptedByOnDeleteFilter(R resource) { + return onDeleteFilter == null || onDeleteFilter.accept(resource, false); + } + + private boolean acceptedByOnUpdateFilter(R newResource, R oldResource) { + return onUpdateFilter == null || onUpdateFilter.accept(newResource, oldResource); + } + + private boolean acceptedByGenericFilter(R resource) { return genericFilter == null || genericFilter.accept(resource); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java index 889cc4da75..6fe97b2fdd 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java @@ -211,6 +211,43 @@ void genericFilteringEvents() { verify(eventHandler, times(0)).handleEvent(any()); } + @Test + void onlyGenericFilterSetDoesNotFailOnAdd() { + var eventSource = new TestExternalCachingEventSource(); + eventSource.setGenericFilter(res -> true); + setUpSource(eventSource); + + source.handleResources(primaryID1(), Set.of(testResource1())); + + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void onlyGenericFilterSetDoesNotFailOnDelete() { + var eventSource = new TestExternalCachingEventSource(); + eventSource.setGenericFilter(res -> true); + setUpSource(eventSource); + + source.handleResources(primaryID1(), Set.of(testResource1())); + source.handleResources(primaryID1(), Set.of()); + + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void onlyGenericFilterSetDoesNotFailOnUpdate() { + var eventSource = new TestExternalCachingEventSource(); + eventSource.setGenericFilter(res -> true); + setUpSource(eventSource); + + source.handleResources(primaryID1(), Set.of(testResource1())); + var changed = testResource1(); + changed.setValue("changedValue"); + source.handleResources(primaryID1(), Set.of(changed)); + + verify(eventHandler, times(2)).handleEvent(any()); + } + public static class TestExternalCachingEventSource extends ExternalResourceCachingEventSource { public TestExternalCachingEventSource() { From 2bc85a0cfb8321dd7c3f5a14d82ace27727970c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 10:34:28 +0200 Subject: [PATCH 19/29] fix: report remaining, not elapsed, time from LinearRateLimiter (#3521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ] `RateLimiter.isLimited` is documented to return the "minimal duration until a permission could be acquired again", but `LinearRateLimiter` returned the time *elapsed* since the current period started: Duration.between(actualState.getLastRefreshTime(), LocalDateTime.now()) The two are inverted. Measured with a 1000ms refresh period: moment reported correct right after limit hit 19ms ~1000ms 800ms into the period 804ms ~200ms `EventProcessor.handleRateLimitedSubmission` feeds this value straight into `TimerEventSource.scheduleOnce`, so a rate-limited resource is rescheduled almost immediately after the limit is reached (floored at MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION), gets rate-limited again, and repeats — producing a burst of pointless timer events. Conversely, a resource limited near the end of a period waits roughly a full extra period after a permission was already available. Now returns the time until the current period ends, clamped at zero. `returnsMinimalDurationToAcquirePermission` only asserted `isLessThan(REFRESH_PERIOD)`, which held for both the correct and the inverted value; it now also asserts the reported wait is close to the full period. A second test asserts the reported duration shrinks as the period elapses, which is what distinguishes remaining from elapsed. Both fail without this change. Signed-off-by: Attila Mészáros --- .../event/rate/LinearRateLimiter.java | 9 ++++----- .../event/rate/LinearRateLimiterTest.java | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java index 25af46b5d8..52425f815a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java @@ -53,18 +53,17 @@ public Optional isLimited(RateLimitState rateLimitState) { if (!isActivated() || !(rateLimitState instanceof RateState actualState)) { return Optional.empty(); } - + var now = LocalDateTime.now(); if (actualState.getCount() < limitForPeriod) { actualState.increaseCount(); return Optional.empty(); - } else if (actualState - .getLastRefreshTime() - .isBefore(LocalDateTime.now().minus(refreshPeriod))) { + } else if (actualState.getLastRefreshTime().isBefore(now.minus(refreshPeriod))) { actualState.reset(); actualState.increaseCount(); return Optional.empty(); } else { - return Optional.of(Duration.between(actualState.getLastRefreshTime(), LocalDateTime.now())); + var remaining = Duration.between(now, actualState.getLastRefreshTime().plus(refreshPeriod)); + return Optional.of(remaining.isNegative() ? Duration.ZERO : remaining); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java index 43b30f9de7..7c5bdac479 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java @@ -53,7 +53,22 @@ void returnsMinimalDurationToAcquirePermission() { res = rl.isLimited(state); assertThat(res).isPresent(); - assertThat(res.get()).isLessThan(REFRESH_PERIOD); + assertThat(res.get()).isLessThanOrEqualTo(REFRESH_PERIOD); + // the whole period is still ahead of us, so the reported wait must be close to it + assertThat(res.get()).isGreaterThan(REFRESH_PERIOD.dividedBy(2)); + } + + @Test + void reportedDurationIsTheTimeRemainingNotTheTimeElapsed() throws InterruptedException { + var rl = new LinearRateLimiter(REFRESH_PERIOD, 1); + assertThat(rl.isLimited(state)).isEmpty(); + + var justAfterLimit = rl.isLimited(state).orElseThrow(); + Thread.sleep(REFRESH_PERIOD.toMillis() / 2); + var halfWayThroughPeriod = rl.isLimited(state).orElseThrow(); + + // as the refresh period elapses, less time remains until a permission is available again + assertThat(halfWayThroughPeriod).isLessThan(justAfterLimit); } @Test From 7c8e197a44c6ad0bf0ef1af622b6c62834a229fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 10:39:41 +0200 Subject: [PATCH 20/29] fix: use the null-guarded metrics field in the Controller constructor (#3531) The constructor deliberately guards against a null `Metrics`: this.metrics = Optional.ofNullable(configurationService.getMetrics()).orElse(Metrics.NOOP); but its last statement bypasses that guard and calls the getter again: configurationService.getMetrics().controllerRegistered(this); so a `ConfigurationService` whose `getMetrics()` returns null fails with a NullPointerException during controller construction, which is exactly what the guard three lines up is meant to prevent. `EventProcessor` applies the same `!= null ? metrics : Metrics.NOOP` guard, so the codebase does treat a null `Metrics` as reachable even though the interface default returns `Metrics.NOOP`. Uses the already-resolved `metrics` field instead. --- .../java/io/javaoperatorsdk/operator/processing/Controller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java index 5df4a86f57..612c3d36f9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -138,7 +138,7 @@ public Controller( kubernetesClient, configuration.getResourceClass()); initAndRegisterEventSources(eventSourceContext); - configurationService.getMetrics().controllerRegistered(this); + metrics.controllerRegistered(this); } @Override From 4a12a39288bac6ca92c2e6366531e77d1654693e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 11:10:28 +0200 Subject: [PATCH 21/29] fix: use a monotonic clock for the cache polling timeout (#3529) `pollLocalCache` computed its deadline with `LocalTime`: var startTime = LocalTime.now(); final var timeoutTime = startTime.plus(timeoutMillis, ChronoUnit.MILLIS); while (timeoutTime.isAfter(LocalTime.now())) { `LocalTime` is a time of day and wraps at midnight, so for any call made within `timeoutMillis` of 00:00 the computed deadline is *earlier* than the current time. The loop body never runs and the method throws `OperatorException("Timeout of resource polling from cache for resource")` immediately, failing the update it was retrying even though the resource would have appeared in cache. With the default 10s timeout that is a 10-second window each day. `LocalTime.now()` is also wall-clock based, so an NTP step or a DST change can shorten or extend the timeout. Switches to `System.nanoTime()`, which is monotonic and has no wrap-around concern, using overflow-safe subtraction for the comparison. No test is added: reproducing this requires controlling the clock, and the class is already deprecated for removal. --- .../api/reconciler/PrimaryUpdateAndCacheUtils.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java index f74cd49ee7..dc6d65aa34 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java @@ -16,8 +16,7 @@ package io.javaoperatorsdk.operator.api.reconciler; import java.lang.reflect.InvocationTargetException; -import java.time.LocalTime; -import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.function.UnaryOperator; @@ -232,9 +231,8 @@ private static

P pollLocalCache( Context

context, P staleResource, long timeoutMillis, long pollDelayMillis) { try { var resourceId = ResourceID.fromResource(staleResource); - var startTime = LocalTime.now(); - final var timeoutTime = startTime.plus(timeoutMillis, ChronoUnit.MILLIS); - while (timeoutTime.isAfter(LocalTime.now())) { + final var deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (System.nanoTime() - deadlineNanos < 0) { log.debug("Polling cache for resource: {}", resourceId); var cachedResource = context.getPrimaryCache().get(resourceId).orElseThrow(); if (!cachedResource From 96e59cd5869cbb8c94d2c0997f03504cef1a8784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 11:13:09 +0200 Subject: [PATCH 22/29] fix: do not NPE when adding a finalizer to a resource deleted mid-retry (#3530) * fix: do not NPE when adding a finalizer to a resource deleted mid-retry `conflictRetryingPatchPrimary` / `conflictRetryingPatch` re-read the resource from the API server after a 409 or 422 and then re-evaluate the precondition: resource = operation.inNamespace(ns).withName(name).get(); `get()` returns null if the resource was deleted in the meantime, so the next iteration calls the precondition with null. `removeFinalizer` anticipates this: r -> { if (r == null) { log.warn("Cannot remove finalizer since resource not exists."); return false; } return r.hasFinalizer(finalizerName); } but `addFinalizer` passes `r -> !r.hasFinalizer(finalizerName)`, which throws a NullPointerException instead of exiting cleanly. Gives `addFinalizer` the same null guard, in both `ResourceOperations` and the deprecated `PrimaryUpdateAndCacheUtils`. No test is added: reaching the retry path requires stubbing the client to answer 409/422 and then 404 through the whole fabric8 DSL chain, which the existing unit tests are not set up for. * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../api/reconciler/PrimaryUpdateAndCacheUtils.java | 8 +++++++- .../operator/api/reconciler/ResourceOperations.java | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java index dc6d65aa34..053bde9e3d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java @@ -287,7 +287,13 @@ public static

P addFinalizer( r.addFinalizer(finalizerName); return r; }, - r -> !r.hasFinalizer(finalizerName)); + r -> { + if (r == null) { + log.warn("Cannot add finalizer since resource no longer exists."); + return false; + } + return !r.hasFinalizer(finalizerName); + }); } /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 603548e33d..e7ce790e13 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -1085,7 +1085,13 @@ public P addFinalizer(String finalizerName, boolean cacheOnly) { r.addFinalizer(finalizerName); return r; }, - r -> !r.hasFinalizer(finalizerName), + r -> { + if (r == null) { + log.warn("Cannot add finalizer since resource no longer exists."); + return false; + } + return !r.hasFinalizer(finalizerName); + }, cacheOnly); } From b8a477df652acdbac1b16f02749ddd1ef7170d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 11:14:06 +0200 Subject: [PATCH 23/29] fix: NPE in handleRecentResourceUpdate for an unknown secondary resource (#3519) `ExternalResourceCachingEventSource.handleRecentResourceUpdate` checks that the primary has an entry in the cache, but then dereferences the per-secondary lookup without checking it: R actualResource = actualValues.get(resourceId); if (actualResource.equals(previousVersionOfResource)) { `actualValues.get(resourceId)` returns null whenever the primary has a cache entry but that particular secondary id is not in it, which throws a NullPointerException. This is reachable from `AbstractEventSourceHolderDependentResource.onUpdated` for any `RecentOperationCacheFiller` event source, e.g. after an update whose resource id is not the one currently cached for that primary. Skips the cache update when the resource is not tracked, which matches the intent of the surrounding "only overwrite if we still hold the version the caller saw" check. Adds a regression test that fails with NullPointerException without this change. --- .../source/ExternalResourceCachingEventSource.java | 2 +- .../source/ExternalResourceCachingEventSourceTest.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java index 6180656e55..6dbf5b7fb4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java @@ -238,7 +238,7 @@ public synchronized void handleRecentResourceUpdate( if (actualValues != null) { var resourceId = resourceIDMapper.idFor(resource); R actualResource = actualValues.get(resourceId); - if (actualResource.equals(previousVersionOfResource)) { + if (actualResource != null && actualResource.equals(previousVersionOfResource)) { actualValues.put(resourceId, resource); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java index 6fe97b2fdd..024bd95dfc 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java @@ -212,6 +212,16 @@ void genericFilteringEvents() { } @Test + void recentResourceUpdateIsIgnoredForUnknownSecondaryResource() { + source.handleResources(primaryID1(), Set.of(testResource1())); + + // testResource2 has a different id, so it is not present in the cache for primaryID1 + var unknown = testResource2(); + source.handleRecentResourceUpdate(primaryID1(), unknown, unknown); + + assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1()); + } + void onlyGenericFilterSetDoesNotFailOnAdd() { var eventSource = new TestExternalCachingEventSource(); eventSource.setGenericFilter(res -> true); From 43bbdf460bda7eaba77ba0abe6800dba99176a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 3 Aug 2026 12:44:05 +0200 Subject: [PATCH 24/29] fix: assorted robustness and clarity fixes (#3533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: assorted robustness and clarity fixes Three unrelated small items found while auditing. ManagedInformerEventSource.get could throw instead of falling back to the informer cache. It compared a temporary-cache hit against `manager().lastSyncResourceVersion(namespace)` without guarding either failure mode: * `lastSyncResourceVersion` is null until an informer has completed its initial list, and `compareResourceVersions` dereferences it, so this threw a NullPointerException. * `InformerManager.lastSyncResourceVersion` does `getSource(ns) .orElseThrow()`, which throws `NoSuchElementException` for a namespace that is no longer watched after a dynamic namespace change - a window that only closes on the next re-list, when `checkGhostResources` prunes the entry. `TemporaryResourceCache.putResource` already guards the null case; the comparison is now extracted into a helper that guards both and falls back to the informer cache, which is the safe answer in either case. ExpectationResult.name() threw a bare NullPointerException when no expectation was registered, which is a normal result state produced by `ExpectationManager.checkExpectation`. It now throws `IllegalStateException` pointing at `isExpectationPresent()`, and says so in the javadoc. ResourceOperations cleanups: drops the `options` parameter of `desiredForJsonPatch`, which was never read at any of its six call sites, and rewrites the comment on `create` - it claimed the operation "check if the resource already exists", which it does not; the actual reason filtering is safe is that the API server rejects a duplicate create. * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix Signed-off-by: Attila Mészáros --------- Signed-off-by: Attila Mészáros Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../api/reconciler/ResourceOperations.java | 20 +++++++------ .../informer/ManagedInformerEventSource.java | 30 ++++++++++++++++--- .../expectation/ExpectationResult.java | 9 ++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index e7ce790e13..eb0b0cf144 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -406,7 +406,9 @@ public R update( * @return the created resource as returned by the API server */ public R create(R resource) { - // it is safe to do event filtering for create since check if the resource already exists. + // filtering the own event is safe here without optimistic locking or a matcher: a create either + // succeeds, in which case the event we filter is unambiguously ours, or the API server rejects + // it because the resource already exists. return create(resource, Options.forceFilterEvents()); } @@ -445,7 +447,7 @@ public R create( if (informerEventSource == null) { return create(resource); } - // it is safe to do event filtering for create since check if the resource already exists. + // see the note on create(R) about why filtering the own event is safe here return resourcePatch( resource, r -> context.getClient().resource(r).create(), informerEventSource, options); } @@ -556,7 +558,7 @@ public R jsonPatch(R actualResource, UnaryOperator un */ public R jsonPatch( R actualResource, UnaryOperator unaryOperator, Options options) { - R desired = desiredForJsonPatch(actualResource, unaryOperator, options); + R desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -580,7 +582,7 @@ public R jsonPatch( UnaryOperator unaryOperator, InformerEventSource informerEventSource, Options options) { - R desired = desiredForJsonPatch(actualResource, unaryOperator, options); + R desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -620,7 +622,7 @@ public R jsonPatchStatus( */ public R jsonPatchStatus( R actualResource, UnaryOperator unaryOperator, Options options) { - R desired = desiredForJsonPatch(actualResource, unaryOperator, options); + R desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -645,7 +647,7 @@ public R jsonPatchStatus( UnaryOperator unaryOperator, InformerEventSource informerEventSource, Options options) { - R desired = desiredForJsonPatch(actualResource, unaryOperator, options); + R desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -680,7 +682,7 @@ public P jsonPatchPrimary(P actualResource, UnaryOperator

unaryOperator) { * @return the patched resource as returned by the API server */ public P jsonPatchPrimary(P actualResource, UnaryOperator

unaryOperator, Options options) { - P desired = desiredForJsonPatch(actualResource, unaryOperator, options); + P desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -717,7 +719,7 @@ public P jsonPatchPrimaryStatus(P actualResource, UnaryOperator

unaryOperator */ public P jsonPatchPrimaryStatus( P actualResource, UnaryOperator

unaryOperator, Options options) { - P desired = desiredForJsonPatch(actualResource, unaryOperator, options); + P desired = desiredForJsonPatch(actualResource, unaryOperator); return resourcePatch( desired, actualResource, @@ -1447,7 +1449,7 @@ public enum Mode { } private T desiredForJsonPatch( - T actualResource, UnaryOperator unaryOperator, Options options) { + T actualResource, UnaryOperator unaryOperator) { var cloned = context .getControllerConfiguration() diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 8352bef665..1a4dc9fe00 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -224,10 +224,7 @@ public Optional get(ResourceID resourceID) { Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); if (comparableResourceVersions && resource.isPresent() - && ReconcilerUtilsInternal.compareResourceVersions( - resource.get().getMetadata().getResourceVersion(), - manager().lastSyncResourceVersion(resource.get().getMetadata().getNamespace())) - > 0) { + && isLaterThanLastSyncResourceVersion(resource.orElseThrow())) { log.debug("Latest resource found in temporary cache for Resource ID: {}", resourceID); return resource; } else { @@ -242,6 +239,31 @@ public Optional get(ResourceID resourceID) { } } + /** + * A resource from the temporary cache is only preferred over the informer cache if we can tell + * that it is newer. The last sync resource version is not available before an informer has + * completed its initial list, and the namespace might not be watched anymore after a dynamic + * namespace change, in both of which cases the informer cache is used instead. + */ + private boolean isLaterThanLastSyncResourceVersion(R resource) { + var namespace = resource.getMetadata().getNamespace(); + if (!manager().isWatchingNamespace(namespace)) { + return false; + } + final String lastSyncResourceVersion; + try { + lastSyncResourceVersion = manager().lastSyncResourceVersion(namespace); + } catch (java.util.NoSuchElementException e) { + return false; + } + if (lastSyncResourceVersion == null) { + return false; + } + return ReconcilerUtilsInternal.compareResourceVersions( + resource.getMetadata().getResourceVersion(), lastSyncResourceVersion) + > 0; + } + /** * @deprecated Use {@link #get(ResourceID)} instead. */ diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java index 5b4081f7b3..e2981d7834 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -36,7 +36,16 @@ public boolean isNotPresentOrFulfilled() { return !isExpectationPresent() || isFulfilled(); } + /** + * @return the name of the expectation this result refers to + * @throws IllegalStateException if there is no expectation, check {@link #isExpectationPresent()} + * first + */ public String name() { + if (expectation == null) { + throw new IllegalStateException( + "No expectation present for this result, check isExpectationPresent() first."); + } return expectation.name(); } } From db8468d68d21bef9659aad37df8614b8ce339def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 5 Aug 2026 09:06:13 +0200 Subject: [PATCH 25/29] fix: complete the submission when the primary is missing from cache at execution time (#3525) `ReconcilerExecutor.run` re-reads the primary from the cache and bails out if it is gone. On the non-triggerOnAllEvents path that bail-out just returned: } else { log.debug("Skipping execution; primary resource missing from cache"); return; } Two consequences. The resource stays wedged. `submitReconciliationExecution` has already called `state.setUnderProcessing(true)` and `state.unMarkEventReceived()`, so returning without `eventProcessingFinished` leaves the state marked as under processing with no event pending. `isControllerUnderExecution` then suppresses every future submission for that resource. Usually a DELETED event drops the whole `ResourceState` and hides this, but if the resource leaves the informer cache without a delete event reaching the processor (a narrowed label selector, `changeNamespaces` dropping a namespace, a re-list whose synthetic delete is filtered by an `onDeleteFilter`) the resource is never reconciled again. Metrics drift. The `return` is inside the `try`, so the `finally` still reported `reconciliationFinished` even though `reconciliationStarted` was never reached. With `MicrometerMetricsV2` that decrements `reconciliations.active` without a matching increment, and leaves the `reconciliations.queue` increment from `reconciliationSubmitted` outstanding, so both gauges drift monotonically. The same applied to the "delete event received meanwhile" branch. Now completes the submission via `eventProcessingFinished` (as the sibling branch already did) and only reports `reconciliationFinished` when `reconciliationStarted` was reported. Adds regression tests for both; they fail without this change. Note: `reconciliations.queue` is incremented by `reconciliationSubmitted` but only decremented by `reconciliationStarted`, so a submission that is abandoned still leaves the queue gauge one too high. Pairing those properly needs a decision on the `Metrics` contract (decrement in `reconciliationFinished`, or add an explicit "submission abandoned" callback) and is deliberately not changed here. --- .../processing/event/EventProcessor.java | 13 ++++- .../processing/event/EventProcessorTest.java | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index ddc0f73a27..374beb91e9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -543,6 +543,7 @@ public void run() { // change thread name for easier debugging final var thread = Thread.currentThread(); final var name = thread.getName(); + boolean reconciliationStarted = false; try { // we try to get the most up-to-date resource from cache var actualResource = cache.get(resourceID); @@ -567,19 +568,27 @@ public void run() { } } else { log.debug("Skipping execution; primary resource missing from cache"); + // the submission has to be completed, otherwise the resource stays marked as under + // processing and no further reconciliation would ever be submitted for it + eventProcessingFinished(executionScope, PostExecutionControl.defaultDispatch()); return; } } actualResource.ifPresent(executionScope::setResource); MDCUtils.addResourceInfo(executionScope.getResource()); metrics.reconciliationStarted(executionScope.getResource(), metricsMetadata); + reconciliationStarted = true; thread.setName("ReconcilerExecutor-" + controllerName() + "-" + thread.getId()); PostExecutionControl

postExecutionControl = reconciliationDispatcher.handleExecution(executionScope); eventProcessingFinished(executionScope, postExecutionControl); } finally { - metrics.reconciliationFinished( - executionScope.getResource(), executionScope.getRetryInfo(), metricsMetadata); + // only report the reconciliation as finished if it was reported as started, otherwise + // gauges tracking in-flight reconciliations drift on every skipped execution + if (reconciliationStarted) { + metrics.reconciliationFinished( + executionScope.getResource(), executionScope.getRetryInfo(), metricsMetadata); + } // restore original name thread.setName(name); MDCUtils.removeResourceInfo(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java index f7864f2f16..941da8be4c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java @@ -134,6 +134,53 @@ void skipProcessingIfLatestCustomResourceNotInCache() { verify(reconciliationDispatcherMock, timeout(50).times(0)).handleExecution(any()); } + @Test + void completesSubmissionIfResourceDisappearsFromCacheBeforeExecution() { + var resourceID = new ResourceID(UUID.randomUUID().toString(), TEST_NAMESPACE); + var customResource = testCustomResource(resourceID); + // present when the execution is submitted, but gone by the time the executor thread runs + when(controllerEventSourceMock.get(eq(resourceID))) + .thenReturn(Optional.of(customResource)) + .thenReturn(Optional.empty()); + + eventProcessor.handleEvent( + new ResourceEvent(ResourceAction.UPDATED, resourceID, customResource)); + + await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(eventProcessor.isUnderProcessing(resourceID)).isFalse()); + verify(reconciliationDispatcherMock, times(0)).handleExecution(any()); + } + + @Test + void doesNotReportReconciliationFinishedIfItNeverStarted() { + var processorWithMetrics = + spy( + new EventProcessor( + controllerConfiguration(null, rateLimiterMock), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + processorWithMetrics.start(); + when(processorWithMetrics.retryEventSource()).thenReturn(retryTimerEventSourceMock); + + var resourceID = new ResourceID(UUID.randomUUID().toString(), TEST_NAMESPACE); + var customResource = testCustomResource(resourceID); + when(controllerEventSourceMock.get(eq(resourceID))) + .thenReturn(Optional.of(customResource)) + .thenReturn(Optional.empty()); + + processorWithMetrics.handleEvent( + new ResourceEvent(ResourceAction.UPDATED, resourceID, customResource)); + + await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted( + () -> assertThat(processorWithMetrics.isUnderProcessing(resourceID)).isFalse()); + verify(metricsMock, times(0)).reconciliationStarted(any(), any()); + verify(metricsMock, times(0)).reconciliationFinished(any(), any(), any()); + } + @Test void ifExecutionInProgressWaitsUntilItsFinished() { ResourceID resourceUid = eventAlreadyUnderProcessing(); From e88128f62082483b469979649785d8139d788b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 5 Aug 2026 12:38:06 +0200 Subject: [PATCH 26/29] fix: honour bulk dependent resource capabilities during reconciliation (#3520) A `BulkDependentResource` may implement any subset of `Creator`, `Updater` and `Deleter`. `BulkDependentResourceInstance`, the internal per-item wrapper that reuses `AbstractDependentResource`'s reconcile logic, implements all three and delegates each call to the bulk resource with an unchecked cast. Its capabilities therefore have to be derived from the wrapped bulk resource. 0b8d8dd8e introduced the overridable `creatable()` / `updatable()` hooks for exactly this, but the wiring was never completed: * `BulkDependentResourceInstance` overrides `isCreatable()` / `isUpdatable()`, which `reconcile` never consults, so both overrides were dead code. * the inner create branch of `AbstractDependentResource.reconcile` still read the `creatable` field directly instead of calling `creatable()`. Since the wrapper implements all three interfaces, both fields are always true, so create and update were attempted regardless of what the bulk resource actually supports, failing with a ClassCastException in `BulkDependentResourceInstance.create` / `update`: java.lang.ClassCastException: class ...CreateAndDeleteOnlyBulk cannot be cast to class ...Updater at BulkDependentResourceInstance.update(BulkDependentResourceReconciler.java:115) at AbstractDependentResource.handleUpdate(AbstractDependentResource.java:204) A non-bulk dependent in the same situation just logs "implement Updater interface to modify it" and skips the operation. Fixes this by overriding `creatable()` / `updatable()` in the wrapper and by using `creatable()` in the create branch. `isCreatable()` / `isUpdatable()` now delegate to `creatable()` / `updatable()` so the two pairs cannot drift apart again. Adds regression tests for a create+delete-only and a delete-only bulk dependent; both fail without this change. --- .../dependent/AbstractDependentResource.java | 6 +- .../BulkDependentResourceReconciler.java | 4 +- ...BulkDependentResourceCapabilitiesTest.java | 145 ++++++++++++++++++ 3 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceCapabilitiesTest.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java index 8dc62b4ca7..a96c4b03ec 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java @@ -85,7 +85,7 @@ public ReconcileResult reconcile(P primary, Context

context) { protected ReconcileResult reconcile(P primary, R actualResource, Context

context) { if (creatable() || updatable()) { if (actualResource == null) { - if (creatable) { + if (creatable()) { var desired = getOrComputeDesired(context); throwIfNull(desired, primary, "Desired"); logForOperation("Creating", primary, desired); @@ -244,12 +244,12 @@ protected void handleDelete(P primary, R secondary, Context

context) { } protected boolean isCreatable() { - return creatable; + return creatable(); } @SuppressWarnings("unused") protected boolean isUpdatable() { - return updatable; + return updatable(); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java index 23135f81b1..827961b77f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java @@ -148,12 +148,12 @@ public R create(R desired, P primary, Context

context) { } @Override - protected boolean isCreatable() { + protected boolean creatable() { return bulkDependentResource instanceof Creator; } @Override - protected boolean isUpdatable() { + protected boolean updatable() { return bulkDependentResource instanceof Updater; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceCapabilitiesTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceCapabilitiesTest.java new file mode 100644 index 0000000000..bfd8873d9d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceCapabilitiesTest.java @@ -0,0 +1,145 @@ +/* + * 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; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult.Operation; +import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * A {@link BulkDependentResource} does not have to implement every one of {@link Creator}, {@link + * Updater} and {@link Deleter}. The capabilities of the bulk resource itself must therefore be + * honoured, not those of the internal per-item wrapper (which implements all three). + */ +@SuppressWarnings("unchecked") +class BulkDependentResourceCapabilitiesTest { + + private static final ConfigMap PRIMARY = + new ConfigMapBuilder().withNewMetadata().withName("primary").endMetadata().build(); + + @Test + void doesNotAttemptUpdateWhenBulkResourceIsNotUpdater() { + var result = new CreateAndDeleteOnlyBulk().reconcile(PRIMARY, mock(Context.class)); + + assertThat(result.getSingleOperation()).isEqualTo(Operation.NONE); + } + + @Test + void doesNotAttemptCreateWhenBulkResourceIsNotCreator() { + var result = new DeleteOnlyBulk().reconcile(PRIMARY, mock(Context.class)); + + assertThat(result.getResourceOperations()).isEmpty(); + } + + /** Can be created and deleted, but deliberately is not an {@link Updater}. */ + private static class CreateAndDeleteOnlyBulk extends BaseBulk + implements Creator, Deleter { + + @Override + public Map getSecondaryResources( + ConfigMap primary, Context context) { + // an actual resource exists, but differs from the desired one + var actual = new LinkedHashMap(); + actual.put("a", configMap(Map.of("key", "actual"))); + return actual; + } + + @Override + public ConfigMap create(ConfigMap desired, ConfigMap primary, Context context) { + throw new AssertionError("create must not be called, the resource already exists"); + } + } + + /** Can only be deleted: neither {@link Creator} nor {@link Updater}. */ + private static class DeleteOnlyBulk extends BaseBulk implements Deleter { + + @Override + public Map getSecondaryResources( + ConfigMap primary, Context context) { + return Map.of(); + } + } + + private abstract static class BaseBulk extends AbstractDependentResource + implements BulkDependentResource { + + @Override + public Map desiredResources(ConfigMap primary, Context context) { + var desired = new LinkedHashMap(); + desired.put("a", configMap(Map.of("key", "desired"))); + return desired; + } + + @Override + public void deleteTargetResource( + ConfigMap primary, ConfigMap resource, String key, Context context) {} + + @Override + public Result match( + ConfigMap actualResource, + ConfigMap desired, + ConfigMap primary, + Context context) { + return Result.computed(false, desired); + } + + @Override + public Result match( + ConfigMap resource, ConfigMap primary, Context context) { + return Result.computed(false, resource); + } + + @Override + protected Optional selectTargetSecondaryResource( + Set secondaryResources, ConfigMap primary, Context context) { + return Optional.empty(); + } + + @Override + protected void onCreated(ConfigMap primary, ConfigMap created, Context context) {} + + @Override + protected void onUpdated( + ConfigMap primary, ConfigMap updated, ConfigMap actual, Context context) {} + + @Override + public Class resourceType() { + return ConfigMap.class; + } + } + + private static ConfigMap configMap(Map data) { + return new ConfigMapBuilder() + .withNewMetadata() + .withName("a") + .endMetadata() + .withData(data) + .build(); + } +} From c5b4229ed48a13257d45d6f3ad1ca5835a7d73d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 5 Aug 2026 14:06:55 +0200 Subject: [PATCH 27/29] fix: read the external resource cache under the event source monitor (#3524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: read the external resource cache under the event source monitor `ExternalResourceCachingEventSource` mutates its cache from `synchronized` methods (`handleResources`, `handleDelete`, `handleRecentResourceCreate/Update`), but the read paths were not synchronized. The outer map is a `ConcurrentHashMap`; the nested per-primary maps are plain `HashMap`s that `handleDelete` mutates in place, so a reconciler thread reading them while a poll or informer thread writes can observe a corrupted map or throw. `getSecondaryResources(ResourceID)` additionally looked the primary up twice: var cachedValues = cache.get(primaryID); if (cachedValues == null) { return Collections.emptySet(); } else { return new HashSet<>(cache.get(primaryID).values()); } If a concurrent `handleDelete` removes the entry between the two calls, the second `get` returns null and this throws a NullPointerException. Adds a `cachedResourcesFor` helper that snapshots the cached resources while holding the monitor, and routes `getSecondaryResources` plus the `PerResourcePollingEventSource` and `CachingInboundEventSource` overrides (and `checkAndRegisterTask`) through it. The helper only copies, so the potentially slow `ResourceFetcher` calls in those overrides still run outside the lock and cannot block the informer or poll threads. `getCache()` still returns a live view for backwards compatibility, but now documents that iterating the nested maps requires synchronizing on the event source. Adds a test asserting `getSecondaryResources` returns a snapshot rather than a live view. * fix: NPE in external resource caching event source when only a generic filter is set (#3518) `acceptedByFiler` guards each of its three filter branches with `onXFilter != null || genericFilter != null`, but the branch body dereferences `onXFilter` unconditionally: if (onAddFilter != null || genericFilter != null) { ... .anyMatch(r -> acceptedByGenericFiler(r) && onAddFilter.accept(r)); So configuring only a generic filter via `setGenericFilter(...)` throws a NullPointerException as soon as a resource is added, deleted or updated. All three branches (add / delete / update) are affected, which means `PollingEventSource`, `PerResourcePollingEventSource` and `CachingInboundEventSource` all break when used with a generic filter only. The existing `genericFilteringEvents` test missed this because it uses a filter that returns `false`: `&&` short-circuits before the null dereference. Only a generic filter that accepts a resource reaches the NPE. Each filter check is now null-safe (an absent filter accepts), which preserves the previous behaviour whenever the specific filter is set. Adds three regression tests, one per branch; they fail with NullPointerException without this change. Signed-off-by: Attila Mészáros # Conflicts: # operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java --------- Signed-off-by: Attila Mészáros --- .../ExternalResourceCachingEventSource.java | 21 ++++++++++++++++-- .../inbound/CachingInboundEventSource.java | 7 +++--- .../PerResourcePollingEventSource.java | 12 +++++----- ...xternalResourceCachingEventSourceTest.java | 22 ++++++++++++++----- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java index 6dbf5b7fb4..61fb2c841a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java @@ -250,12 +250,23 @@ public Set getSecondaryResources(P primary) { } public Set getSecondaryResources(ResourceID primaryID) { + return cachedResourcesFor(primaryID); + } + + /** + * Snapshot of the currently cached secondary resources for the target primary. The per-primary + * maps held in the cache are not thread safe and are mutated while holding this object's monitor, + * so they must not be read outside of it. + * + * @param primaryID id of the primary resource + * @return a copy of the cached secondary resources, empty if none are cached + */ + protected synchronized Set cachedResourcesFor(ResourceID primaryID) { var cachedValues = cache.get(primaryID); if (cachedValues == null) { return Collections.emptySet(); - } else { - return new HashSet<>(cache.get(primaryID).values()); } + return new HashSet<>(cachedValues.values()); } public Optional getSecondaryResource(ResourceID primaryID) { @@ -269,6 +280,12 @@ public Optional getSecondaryResource(ResourceID primaryID) { } } + /** + * @return a live, unmodifiable view of the cache. Note that the nested per-primary maps are not + * thread safe and are mutated while holding this object's monitor, so iterating them without + * synchronizing on this event source is not safe. Prefer {@link + * #getSecondaryResources(ResourceID)}, which returns a snapshot. + */ public Map> getCache() { return Collections.unmodifiableMap(cache); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java index 44e0a684b6..8e7518dcb3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java @@ -16,7 +16,6 @@ package io.javaoperatorsdk.operator.processing.event.source.inbound; import java.util.Collections; -import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -76,9 +75,9 @@ private Set getAndCacheResource(P primary) { @Override public Set getSecondaryResources(P primary) { var primaryID = ResourceID.fromResource(primary); - var cachedValue = cache.get(primaryID); - if (cachedValue != null && !cachedValue.isEmpty()) { - return new HashSet<>(cachedValue.values()); + var cachedValues = cachedResourcesFor(primaryID); + if (!cachedValues.isEmpty()) { + return cachedValues; } else { if (fetchedForPrimaries.contains(primaryID)) { return Collections.emptySet(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java index 0f0eb78a69..886c0ecb05 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java @@ -17,7 +17,6 @@ import java.time.Duration; import java.util.Collections; -import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -123,9 +122,8 @@ private void checkAndRegisterTask(P resource) { var primaryID = ResourceID.fromResource(resource); if (scheduledFutures.get(primaryID) == null && (registerPredicate == null || registerPredicate.test(resource))) { - var cachedResources = cache.get(primaryID); - var actualResources = - cachedResources == null ? null : new HashSet<>(cachedResources.values()); + var cachedResources = cachedResourcesFor(primaryID); + var actualResources = cachedResources.isEmpty() ? null : cachedResources; // note that there is a delay, to not do two fetches when the resources first appeared // and getSecondaryResource is called on reconciliation. scheduleNextExecution(resource, actualResources); @@ -167,9 +165,9 @@ public void run() { @Override public Set getSecondaryResources(P primary) { var primaryID = ResourceID.fromResource(primary); - var cachedValue = cache.get(primaryID); - if (cachedValue != null && !cachedValue.isEmpty()) { - return new HashSet<>(cachedValue.values()); + var cachedValues = cachedResourcesFor(primaryID); + if (!cachedValues.isEmpty()) { + return cachedValues; } else { if (fetchedForPrimaries.contains(primaryID)) { return Collections.emptySet(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java index 024bd95dfc..efd48bb6a2 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java @@ -212,16 +212,17 @@ void genericFilteringEvents() { } @Test - void recentResourceUpdateIsIgnoredForUnknownSecondaryResource() { + void getSecondaryResourcesReturnsASnapshotNotALiveView() { source.handleResources(primaryID1(), Set.of(testResource1())); - // testResource2 has a different id, so it is not present in the cache for primaryID1 - var unknown = testResource2(); - source.handleRecentResourceUpdate(primaryID1(), unknown, unknown); + var snapshot = source.getSecondaryResources(primaryID1()); + source.handleDelete(primaryID1()); - assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1()); + assertThat(snapshot).containsExactly(testResource1()); + assertThat(source.getSecondaryResources(primaryID1())).isEmpty(); } + @Test void onlyGenericFilterSetDoesNotFailOnAdd() { var eventSource = new TestExternalCachingEventSource(); eventSource.setGenericFilter(res -> true); @@ -232,6 +233,17 @@ void onlyGenericFilterSetDoesNotFailOnAdd() { verify(eventHandler, times(1)).handleEvent(any()); } + @Test + void recentResourceUpdateIsIgnoredForUnknownSecondaryResource() { + source.handleResources(primaryID1(), Set.of(testResource1())); + + // testResource2 has a different id, so it is not present in the cache for primaryID1 + var unknown = testResource2(); + source.handleRecentResourceUpdate(primaryID1(), unknown, unknown); + + assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1()); + } + @Test void onlyGenericFilterSetDoesNotFailOnDelete() { var eventSource = new TestExternalCachingEventSource(); From 574718b224b1679db122a42d4f382e30c720fc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 5 Aug 2026 14:41:44 +0200 Subject: [PATCH 28/29] fix: always release executors and reset state when stopping the executor manager (#3526) * fix: always release executors and reset state when stopping the executor manager `ExecutorServiceManager.stop` had three problems, all on the interrupted path or affecting the scheduled executor. `scheduledExecutorService` was never shut down. It is created on every `start()` and exposed through a public accessor, but `stop()` only shut down the reconcile, workflow and caching executors, so the pool (and any non-daemon threads a caller created through the accessor) outlived the operator and leaked again on every restart. The helper pool leaked when interrupted. `Executors.newFixedThreadPool(3)` was created inside the `try` and only shut down on the success path, so an `InterruptedException` from `invokeAll` left three non-daemon threads behind - in a shutdown path, where they then keep the JVM alive. `started` was not reset when interrupted. It was only set to false on the success path, so after an interrupted `stop()` the executors were already shut down but `start()` would see `started == true` and do nothing. The operator then looked started while every `execute` on the terminated reconcile executor failed with `RejectedExecutionException`. Moves the cleanup into a `finally`, includes the scheduled executor in the graceful shutdown, and clears both nullable references there. Adds regression tests for the scheduled executor shutdown and for restartability; the former fails without this change. * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../api/config/ExecutorServiceManager.java | 15 +++-- .../config/ExecutorServiceManagerTest.java | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManagerTest.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java index 58928b1302..cdcafcaa46 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java @@ -143,20 +143,25 @@ public synchronized void start(ConfigurationService configurationService) { } public synchronized void stop(Duration gracefulShutdownTimeout) { + var parallelExec = Executors.newFixedThreadPool(4); try { log.debug("Closing executor"); - var parallelExec = Executors.newFixedThreadPool(3); parallelExec.invokeAll( List.of( shutdown(executor, gracefulShutdownTimeout), shutdown(workflowExecutor, gracefulShutdownTimeout), - shutdown(cachingExecutorService, gracefulShutdownTimeout))); - workflowExecutor = null; - parallelExec.shutdownNow(); - started = false; + shutdown(cachingExecutorService, gracefulShutdownTimeout), + shutdown(scheduledExecutorService, gracefulShutdownTimeout))); } catch (InterruptedException e) { log.debug("Exception closing executor: {}", e.getLocalizedMessage()); Thread.currentThread().interrupt(); + } finally { + // this has to happen even if we were interrupted, otherwise the helper pool leaks its + // (non-daemon) threads, and leaving started == true would make a subsequent start() a no-op, + // silently leaving the manager with already terminated executors + parallelExec.shutdownNow(); + workflowExecutor = null; + started = false; } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManagerTest.java new file mode 100644 index 0000000000..40ffded241 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManagerTest.java @@ -0,0 +1,59 @@ +/* + * 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.api.config; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExecutorServiceManagerTest { + + private static final Duration SHUTDOWN_TIMEOUT = Duration.ofMillis(100); + + @Test + void stopShutsDownTheScheduledExecutorService() { + ConfigurationService configurationService = new BaseConfigurationService(); + var manager = configurationService.getExecutorServiceManager(); + var scheduled = manager.scheduledExecutorService(); + + try { + assertThat(scheduled.isShutdown()).isFalse(); + } finally { + manager.stop(SHUTDOWN_TIMEOUT); + } + + assertThat(scheduled.isShutdown()).isTrue(); + } + + @Test + void canBeRestartedAfterStop() { + ConfigurationService configurationService = new BaseConfigurationService(); + var manager = configurationService.getExecutorServiceManager(); + + manager.stop(SHUTDOWN_TIMEOUT); + manager.start(configurationService); + + // start() is a no-op unless stop() reset the started flag, which would leave the manager + // handing out already terminated executors + assertThat(manager.reconcileExecutorService().isShutdown()).isFalse(); + assertThat(manager.cachingExecutorService().isShutdown()).isFalse(); + assertThat(manager.scheduledExecutorService().isShutdown()).isFalse(); + + manager.stop(SHUTDOWN_TIMEOUT); + } +} From 87248d7849f97c356e5e86f2bd01158464c0750a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 5 Aug 2026 22:26:12 +0200 Subject: [PATCH 29/29] fix: exclude samples from Maven Central publishing (#3537) * fix: exclude the Kotlin sample from Maven Central publishing The module has no Java sources, so maven-javadoc-plugin attaches no javadoc jar to it and Central rejects the whole deployment with "Javadocs must be provided but not found in entries", which is what broke the 5.5.1 release. It only exists as a Kotlin interop E2E test, so keep it out of the published bundle. * fix: exclude all samples from Maven Central publishing Replaces the skipPublishing property on the Kotlin sample with an excludeArtifacts list on central-publishing-maven-plugin, so the exclusion lives next to the publishing configuration, and extends it to every sample module: they are demos and end-to-end tests, not artifacts to depend on. --- pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pom.xml b/pom.xml index f205805471..ecd3a7bbaf 100644 --- a/pom.xml +++ b/pom.xml @@ -642,6 +642,20 @@ true true published + + + sample-operators + sample-controller-namespace-deletion + sample-kotlin-operator + sample-leader-election + sample-mysql-schema-operator + sample-operations + sample-tomcat-operator + sample-webpage-operator +