From d492c508180b778d4f77a96fb7d62e2518d3da44 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Tue, 24 Mar 2026 13:57:10 +1300 Subject: [PATCH 1/3] Add failing IT reproducing NoEventSourceForClassException in NodeDeleteExecutor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a BulkDependentResource has an activationCondition and its parent dependent has a failing reconcilePrecondition, JOSDK's markDependentsForDelete() cascades to the bulk dependent and fires NodeDeleteExecutor for it. However, NodeDeleteExecutor does not call registerOrDeregisterEventSourceBasedOnActivation() before invoking delete(), so if NodeReconcileExecutor has never run for that node (e.g. on first reconciliation) the event source is never registered. The delete() path calls getSecondaryResources() → eventSourceRetriever .getEventSourceFor() → NoEventSourceForClassException. This IT demonstrates the bug with a minimal workflow: ConfigMapDependentResource (reconcilePrecondition = ALWAYS_FALSE) └── SecretBulkDependentResource (activationCondition = ALWAYS_TRUE) The fix is to call registerOrDeregisterEventSourceBasedOnActivation() in NodeDeleteExecutor.doRun() before calling dependent.delete(), mirroring what NodeReconcileExecutor already does. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Sam Barker --- .../AlwaysFailingPrecondition.java | 34 +++++++ .../AlwaysTrueActivation.java | 34 +++++++ ...BulkActivationConditionCustomResource.java | 28 ++++++ .../BulkActivationConditionIT.java | 99 +++++++++++++++++++ .../BulkActivationConditionReconciler.java | 67 +++++++++++++ .../ConfigMapDependentResource.java | 43 ++++++++ .../SecretBulkDependentResource.java | 79 +++++++++++++++ 7 files changed, 384 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysFailingPrecondition.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysTrueActivation.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/ConfigMapDependentResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysFailingPrecondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysFailingPrecondition.java new file mode 100644 index 0000000000..4c21fc019d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysFailingPrecondition.java @@ -0,0 +1,34 @@ +/* + * 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.workflow.bulkactivationcondition; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +/** Reconcile precondition that always fails, simulating e.g. a missing prerequisite resource. */ +public class AlwaysFailingPrecondition + implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + BulkActivationConditionCustomResource primary, + Context context) { + return false; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysTrueActivation.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysTrueActivation.java new file mode 100644 index 0000000000..bc3b70bbb1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/AlwaysTrueActivation.java @@ -0,0 +1,34 @@ +/* + * 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.workflow.bulkactivationcondition; + +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +/** Activation condition that always returns true — event source should always be registered. */ +public class AlwaysTrueActivation + implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + BulkActivationConditionCustomResource primary, + Context context) { + return true; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionCustomResource.java new file mode 100644 index 0000000000..da5d22f482 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionCustomResource.java @@ -0,0 +1,28 @@ +/* + * 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.workflow.bulkactivationcondition; + +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.Kind; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("BulkActivationCondition") +public class BulkActivationConditionCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java new file mode 100644 index 0000000000..cc019a0bd9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java @@ -0,0 +1,99 @@ +/* + * 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.workflow.bulkactivationcondition; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Reproducer for the bug where NodeDeleteExecutor fires for a BulkDependentResource whose + * activationCondition-gated event source has never been registered. + * + *

Workflow under test: + * + *

+ * ConfigMapDependentResource  (reconcilePrecondition = AlwaysFailingPrecondition)
+ *   └── SecretBulkDependentResource  (activationCondition = AlwaysTrueActivation)
+ * 
+ * + *

On first reconciliation with only a primary resource present: the ConfigMap precondition fails + * → JOSDK calls markDependentsForDelete() → NodeDeleteExecutor fires for SecretBulkDependent → + * SecretBulkDependent.getSecondaryResources() calls eventSourceRetriever.getEventSourceFor() → the + * Secret event source was never registered (NodeReconcileExecutor never ran) → + * NoEventSourceForClassException. + * + *

This test FAILS on unfixed JOSDK, demonstrating the bug. + */ +public class BulkActivationConditionIT { + + @RegisterExtension + static LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new BulkActivationConditionReconciler()) + .build(); + + @BeforeEach + void resetError() { + BulkActivationConditionReconciler.lastError.set(null); + } + + @Test + void nodeDeleteExecutorShouldNotThrowWhenEventSourceNotYetRegistered() { + var primary = new BulkActivationConditionCustomResource(); + primary.setMetadata( + new ObjectMetaBuilder() + .withName("test-primary") + .withNamespace(extension.getNamespace()) + .build()); + extension.create(primary); + + // Wait for the error to arrive — the ConfigMap precondition always fails, + // so JOSDK should attempt NodeDeleteExecutor for the Secret bulk dependent. + await() + .atMost(Duration.ofSeconds(30)) + .until(() -> BulkActivationConditionReconciler.lastError.get() != null); + + // On unfixed JOSDK this fails: lastError is a NoEventSourceForClassException (or wraps one). + // The assertion below demonstrates the bug by asserting the exception should NOT be present. + Exception error = BulkActivationConditionReconciler.lastError.get(); + assertThat(error) + .as( + "NodeDeleteExecutor must not throw NoEventSourceForClassException when the" + + " activationCondition-gated event source was never registered." + + " Actual error: %s", + error) + .satisfies( + e -> { + Throwable t = e; + while (t != null) { + assertThat(t) + .as("Cause chain should not contain NoEventSourceForClassException") + .isNotInstanceOf(NoEventSourceForClassException.class); + t = t.getCause(); + } + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java new file mode 100644 index 0000000000..b2906787c5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java @@ -0,0 +1,67 @@ +/* + * 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.workflow.bulkactivationcondition; + +import java.util.concurrent.atomic.AtomicReference; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +/** + * Workflow: + * + *

+ * ConfigMapDependentResource  (reconcilePrecondition = AlwaysFailingPrecondition)
+ *   └── SecretBulkDependentResource  (activationCondition = AlwaysTrueActivation)
+ * 
+ * + *

On first reconciliation: ConfigMap precondition fails → markDependentsForDelete cascades to + * SecretBulkDependentResource → NodeDeleteExecutor fires for Secret before its event source is ever + * registered → NoEventSourceForClassException. + */ +@Workflow( + dependents = { + @Dependent( + name = "configmap", + type = ConfigMapDependentResource.class, + reconcilePrecondition = AlwaysFailingPrecondition.class), + @Dependent( + type = SecretBulkDependentResource.class, + activationCondition = AlwaysTrueActivation.class, + dependsOn = "configmap") + }) +@ControllerConfiguration +public class BulkActivationConditionReconciler + implements Reconciler { + + static final AtomicReference lastError = new AtomicReference<>(); + + @Override + public UpdateControl reconcile( + BulkActivationConditionCustomResource primary, + Context context) { + return UpdateControl.noUpdate(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + BulkActivationConditionCustomResource primary, + Context context, + Exception e) { + lastError.set(e); + return ErrorStatusUpdateControl.noStatusUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/ConfigMapDependentResource.java new file mode 100644 index 0000000000..26b45af98a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/ConfigMapDependentResource.java @@ -0,0 +1,43 @@ +/* + * 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.workflow.bulkactivationcondition; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +/** + * Parent dependent resource. Its reconcile precondition always fails, which causes JOSDK to call + * markDependentsForDelete() on its children — triggering NodeDeleteExecutor for SecretBulkDependent + * before that resource's event source has ever been registered. + */ +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired( + BulkActivationConditionCustomResource primary, + Context context) { + var cm = new ConfigMap(); + cm.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + return cm; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java new file mode 100644 index 0000000000..c1c3ba1f9a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java @@ -0,0 +1,79 @@ +/* + * 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.workflow.bulkactivationcondition; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.ResourceIDMapper; +import io.javaoperatorsdk.operator.processing.dependent.CRUDKubernetesBulkDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * Child bulk dependent resource with an activationCondition. + * + *

The bug: when NodeDeleteExecutor fires for this resource (because its parent + * ConfigMapDependentResource has a failing reconcilePrecondition), the event source for Secret has + * never been registered — NodeReconcileExecutor never ran for this node. NodeDeleteExecutor does + * NOT call registerOrDeregisterEventSourceBasedOnActivation() before calling delete(), so + * getSecondaryResources() → eventSourceRetriever.getEventSourceFor(Secret.class) throws + * NoEventSourceForClassException. + * + *

This implementation intentionally has no try/catch — it exposes the raw bug. + */ +public class SecretBulkDependentResource + extends KubernetesDependentResource + implements CRUDKubernetesBulkDependentResource { + + public static final String LABEL_KEY = "reproducer"; + public static final String LABEL_VALUE = "bulk-activation-condition"; + + @Override + public Map desiredResources( + BulkActivationConditionCustomResource primary, + Context context) { + var secret = new Secret(); + secret.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .withLabels(Map.of(LABEL_KEY, LABEL_VALUE)) + .build()); + return Map.of(ResourceIDMapper.kubernetesResourceIdMapper().idFor(secret), secret); + } + + @Override + public Map getSecondaryResources( + BulkActivationConditionCustomResource primary, + Context context) { + // Deliberately uses getEventSourceFor (singular) — not getSecondaryResourcesAsStream — + // because the singular variant throws NoEventSourceForClassException when the source is absent. + // This mirrors the Kroxylicious ClusterRouteDependentResource pattern and exposes the bug: + // on first reconciliation NodeDeleteExecutor fires before the event source is registered. + var secrets = + context + .eventSourceRetriever() + .getEventSourceFor(Secret.class) + .getSecondaryResources(primary); + return secrets.stream() + .collect(Collectors.toMap(ResourceID::fromResource, Function.identity())); + } +} From c63f8d18b30bebc044fac7ce8910a7244036238c Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Tue, 24 Mar 2026 15:49:16 +1300 Subject: [PATCH 2/3] Address review comments on BulkActivationConditionIT - Make lastError and callCount instance fields on the reconciler; hold the reconciler instance as a static field in the IT so tests can access state without static leakage between tests - Add callCount so the test can wait for any reconciliation activity (reconcile() or updateErrorStatus()) then assert cleanly, rather than timing out if the bug is fixed - Add @Disabled linking to issue #3249 so this reproducer-only test does not break CI - Add @Sample annotation to match the pattern of other workflow ITs Assisted-by: Claude Sonnet 4.6 Signed-off-by: Sam Barker --- .../BulkActivationConditionIT.java | 47 +++++++++++-------- .../BulkActivationConditionReconciler.java | 9 +++- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java index cc019a0bd9..3b9d172c95 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java @@ -18,10 +18,12 @@ import java.time.Duration; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; @@ -39,25 +41,37 @@ * └── SecretBulkDependentResource (activationCondition = AlwaysTrueActivation) * * - *

On first reconciliation with only a primary resource present: the ConfigMap precondition fails - * → JOSDK calls markDependentsForDelete() → NodeDeleteExecutor fires for SecretBulkDependent → + *

On first reconciliation the ConfigMap precondition fails → JOSDK calls + * markDependentsForDelete() → NodeDeleteExecutor fires for SecretBulkDependent → * SecretBulkDependent.getSecondaryResources() calls eventSourceRetriever.getEventSourceFor() → the * Secret event source was never registered (NodeReconcileExecutor never ran) → * NoEventSourceForClassException. * *

This test FAILS on unfixed JOSDK, demonstrating the bug. */ +@Disabled("Reproducer for https://github.com/operator-framework/java-operator-sdk/issues/3249") +@Sample( + tldr = "Bulk Dependent Resource with Activation Condition Bug Reproducer", + description = + """ + Reproducer for a bug where NodeDeleteExecutor fires for a BulkDependentResource \ + with an activationCondition before its event source has been registered, \ + causing NoEventSourceForClassException. Triggered when a parent dependent \ + has a failing reconcilePrecondition on first reconciliation. + """) public class BulkActivationConditionIT { + static final BulkActivationConditionReconciler reconciler = + new BulkActivationConditionReconciler(); + @RegisterExtension static LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder() - .withReconciler(new BulkActivationConditionReconciler()) - .build(); + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); @BeforeEach - void resetError() { - BulkActivationConditionReconciler.lastError.set(null); + void reset() { + reconciler.lastError.set(null); + reconciler.callCount.set(0); } @Test @@ -70,21 +84,16 @@ void nodeDeleteExecutorShouldNotThrowWhenEventSourceNotYetRegistered() { .build()); extension.create(primary); - // Wait for the error to arrive — the ConfigMap precondition always fails, - // so JOSDK should attempt NodeDeleteExecutor for the Secret bulk dependent. - await() - .atMost(Duration.ofSeconds(30)) - .until(() -> BulkActivationConditionReconciler.lastError.get() != null); + // Wait for reconcile() or updateErrorStatus() to be called — whichever comes first. + // If the bug is present, updateErrorStatus() fires (no reconcile() call); if fixed, + // reconcile() runs cleanly. + await().atMost(Duration.ofSeconds(30)).until(() -> reconciler.callCount.get() > 0); - // On unfixed JOSDK this fails: lastError is a NoEventSourceForClassException (or wraps one). - // The assertion below demonstrates the bug by asserting the exception should NOT be present. - Exception error = BulkActivationConditionReconciler.lastError.get(); - assertThat(error) + // On unfixed JOSDK this fails: lastError contains NoEventSourceForClassException. + assertThat(reconciler.lastError.get()) .as( "NodeDeleteExecutor must not throw NoEventSourceForClassException when the" - + " activationCondition-gated event source was never registered." - + " Actual error: %s", - error) + + " activationCondition-gated event source was never registered") .satisfies( e -> { Throwable t = e; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java index b2906787c5..52061e17c9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.workflow.bulkactivationcondition; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import io.javaoperatorsdk.operator.api.reconciler.*; @@ -47,12 +48,17 @@ public class BulkActivationConditionReconciler implements Reconciler { - static final AtomicReference lastError = new AtomicReference<>(); + /** Tracks how many times reconcile() or updateErrorStatus() has been called. */ + final AtomicInteger callCount = new AtomicInteger(); + + /** Set when updateErrorStatus() is invoked; null means no error occurred. */ + final AtomicReference lastError = new AtomicReference<>(); @Override public UpdateControl reconcile( BulkActivationConditionCustomResource primary, Context context) { + callCount.incrementAndGet(); return UpdateControl.noUpdate(); } @@ -62,6 +68,7 @@ public ErrorStatusUpdateControl updateErr Context context, Exception e) { lastError.set(e); + callCount.incrementAndGet(); return ErrorStatusUpdateControl.noStatusUpdate(); } } From ae0eb4a062e3021a4a396a0775ab879cbfd91c69 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Tue, 24 Mar 2026 15:55:45 +0100 Subject: [PATCH 3/3] fix: register event source when dependents are marked for deletion Fixes #3249 Signed-off-by: Chris Laprun --- .../workflow/WorkflowReconcileExecutor.java | 4 +++ .../BulkActivationConditionIT.java | 25 +++------------ .../BulkActivationConditionReconciler.java | 32 ++++++++++++------- .../SecretBulkDependentResource.java | 5 +-- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java index 9121cf4e07..d0d2435347 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java @@ -246,6 +246,10 @@ private void markDependentsForDelete( // so if the activation condition was false, this node is not meant to be deleted. var dependents = dependentResourceNode.getParents(); if (activationConditionMet) { + // make sure we register the dependent's event source if it hasn't been added already + // this might be needed in corner cases such as + // https://github.com/operator-framework/java-operator-sdk/issues/3249 + registerOrDeregisterEventSourceBasedOnActivation(true, dependentResourceNode); createOrGetResultFor(dependentResourceNode).markForDelete(); if (dependents.isEmpty()) { bottomNodes.add(dependentResourceNode); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java index 3b9d172c95..d0370927fb 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionIT.java @@ -18,14 +18,12 @@ import java.time.Duration; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.annotation.Sample; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; -import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -49,7 +47,6 @@ * *

This test FAILS on unfixed JOSDK, demonstrating the bug. */ -@Disabled("Reproducer for https://github.com/operator-framework/java-operator-sdk/issues/3249") @Sample( tldr = "Bulk Dependent Resource with Activation Condition Bug Reproducer", description = @@ -84,25 +81,11 @@ void nodeDeleteExecutorShouldNotThrowWhenEventSourceNotYetRegistered() { .build()); extension.create(primary); - // Wait for reconcile() or updateErrorStatus() to be called — whichever comes first. - // If the bug is present, updateErrorStatus() fires (no reconcile() call); if fixed, - // reconcile() runs cleanly. - await().atMost(Duration.ofSeconds(30)).until(() -> reconciler.callCount.get() > 0); + // Wait for reconcile() to be called. + // If the bug is present, SecretBulkDependentResource will be in error and lastError will be set + await().atMost(Duration.ofSeconds(10)).until(() -> reconciler.callCount.get() == 1); // On unfixed JOSDK this fails: lastError contains NoEventSourceForClassException. - assertThat(reconciler.lastError.get()) - .as( - "NodeDeleteExecutor must not throw NoEventSourceForClassException when the" - + " activationCondition-gated event source was never registered") - .satisfies( - e -> { - Throwable t = e; - while (t != null) { - assertThat(t) - .as("Cause chain should not contain NoEventSourceForClassException") - .isNotInstanceOf(NoEventSourceForClassException.class); - t = t.getCause(); - } - }); + assertThat(reconciler.lastError.get()).isNull(); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java index 52061e17c9..0f658942ef 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/BulkActivationConditionReconciler.java @@ -20,6 +20,7 @@ import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.retry.GradualRetry; /** * Workflow: @@ -40,11 +41,14 @@ type = ConfigMapDependentResource.class, reconcilePrecondition = AlwaysFailingPrecondition.class), @Dependent( + name = SecretBulkDependentResource.NAME, type = SecretBulkDependentResource.class, activationCondition = AlwaysTrueActivation.class, dependsOn = "configmap") - }) -@ControllerConfiguration + }, + handleExceptionsInReconciler = true) +@GradualRetry(maxAttempts = 0) +@ControllerConfiguration(maxReconciliationInterval = @MaxReconciliationInterval(interval = 0)) public class BulkActivationConditionReconciler implements Reconciler { @@ -58,17 +62,21 @@ public class BulkActivationConditionReconciler public UpdateControl reconcile( BulkActivationConditionCustomResource primary, Context context) { + final var workflowResult = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult() + .orElseThrow(); + final var erroredDependents = workflowResult.getErroredDependents(); + if (!erroredDependents.isEmpty()) { + final var exception = + erroredDependents.get( + workflowResult + .getDependentResourceByName(SecretBulkDependentResource.NAME) + .orElseThrow()); + lastError.set(exception); + } callCount.incrementAndGet(); return UpdateControl.noUpdate(); } - - @Override - public ErrorStatusUpdateControl updateErrorStatus( - BulkActivationConditionCustomResource primary, - Context context, - Exception e) { - lastError.set(e); - callCount.incrementAndGet(); - return ErrorStatusUpdateControl.noStatusUpdate(); - } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java index c1c3ba1f9a..d710a5a583 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/bulkactivationcondition/SecretBulkDependentResource.java @@ -43,8 +43,9 @@ public class SecretBulkDependentResource extends KubernetesDependentResource implements CRUDKubernetesBulkDependentResource { - public static final String LABEL_KEY = "reproducer"; - public static final String LABEL_VALUE = "bulk-activation-condition"; + static final String NAME = "secret"; + static final String LABEL_KEY = "reproducer"; + static final String LABEL_VALUE = "bulk-activation-condition"; @Override public Map desiredResources(