Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just nit: instead of linking the issue we might want to describe the case when it is needed. (Actually this is quite an obvious why it should be there so might not even need a description, but maybe it just me).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the issue explains the issue way better than I could do in a short comment and that provides historical context for the change that might be useful later on because that method call here is quite unexpected, imo.

registerOrDeregisterEventSourceBasedOnActivation(true, dependentResourceNode);
createOrGetResultFor(dependentResourceNode).markForDelete();
if (dependents.isEmpty()) {
bottomNodes.add(dependentResourceNode);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConfigMap, BulkActivationConditionCustomResource> {

@Override
public boolean isMet(
DependentResource<ConfigMap, BulkActivationConditionCustomResource> dependentResource,
BulkActivationConditionCustomResource primary,
Context<BulkActivationConditionCustomResource> context) {
return false;
}
}
Original file line number Diff line number Diff line change
@@ -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<Secret, BulkActivationConditionCustomResource> {

@Override
public boolean isMet(
DependentResource<Secret, BulkActivationConditionCustomResource> dependentResource,
BulkActivationConditionCustomResource primary,
Context<BulkActivationConditionCustomResource> context) {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -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<Void, Void>
implements Namespaced {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* 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.annotation.Sample;
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;

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.
*
* <p>Workflow under test:
*
* <pre>
* ConfigMapDependentResource (reconcilePrecondition = AlwaysFailingPrecondition)
* └── SecretBulkDependentResource (activationCondition = AlwaysTrueActivation)
* </pre>
*
* <p>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.
*
* <p>This test FAILS on unfixed JOSDK, demonstrating the bug.
*/
Comment on lines +48 to +49
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds an integration test that is expected to fail on current code (“This test FAILS on unfixed JOSDK”). If this is intended as a reproducer-only PR, it should be marked @Disabled (with a link to #3249) or otherwise excluded from the default test suite; otherwise it will break CI for any branch that includes it. Alternatively, include the corresponding production fix in the same PR so the test passes on merge.

Copilot uses AI. Check for mistakes.
@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(reconciler).build();

@BeforeEach
void reset() {
reconciler.lastError.set(null);
reconciler.callCount.set(0);
}

@Test
void nodeDeleteExecutorShouldNotThrowWhenEventSourceNotYetRegistered() {
var primary = new BulkActivationConditionCustomResource();
primary.setMetadata(
new ObjectMetaBuilder()
.withName("test-primary")
.withNamespace(extension.getNamespace())
.build());
extension.create(primary);

// 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()).isNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import io.javaoperatorsdk.operator.api.reconciler.*;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
import io.javaoperatorsdk.operator.processing.retry.GradualRetry;

/**
* Workflow:
*
* <pre>
* ConfigMapDependentResource (reconcilePrecondition = AlwaysFailingPrecondition)
* └── SecretBulkDependentResource (activationCondition = AlwaysTrueActivation)
* </pre>
*
* <p>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(
name = SecretBulkDependentResource.NAME,
type = SecretBulkDependentResource.class,
activationCondition = AlwaysTrueActivation.class,
dependsOn = "configmap")
},
handleExceptionsInReconciler = true)
@GradualRetry(maxAttempts = 0)
@ControllerConfiguration(maxReconciliationInterval = @MaxReconciliationInterval(interval = 0))
public class BulkActivationConditionReconciler
implements Reconciler<BulkActivationConditionCustomResource> {

/** 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<Exception> lastError = new AtomicReference<>();

@Override
public UpdateControl<BulkActivationConditionCustomResource> reconcile(
BulkActivationConditionCustomResource primary,
Context<BulkActivationConditionCustomResource> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<ConfigMap, BulkActivationConditionCustomResource> {

@Override
protected ConfigMap desired(
BulkActivationConditionCustomResource primary,
Context<BulkActivationConditionCustomResource> context) {
var cm = new ConfigMap();
cm.setMetadata(
new ObjectMetaBuilder()
.withName(primary.getMetadata().getName())
.withNamespace(primary.getMetadata().getNamespace())
.build());
return cm;
}
}
Loading
Loading