Skip to content
Draft
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 @@ -361,4 +361,16 @@ private static int validateResourceVersion(String v1) {
}
return v1Length;
}

public static String getGroup(String apiVersion) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This method should probably eventually move to the client code itself

if (apiVersion.startsWith("/")) {
apiVersion = apiVersion.substring(1);
}
var index = apiVersion.indexOf("/");
if (index < 0) {
return "";
Comment thread
csviri marked this conversation as resolved.
} else {
return apiVersion.substring(0, index);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;

import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getGroup;

public class Mappers {

public static final String DEFAULT_ANNOTATION_FOR_NAME = "io.javaoperatorsdk/primary-name";
Expand Down Expand Up @@ -98,10 +100,12 @@ public static <T extends HasMetadata> SecondaryToPrimaryMapper<T> fromOwnerRefer

public static <T extends HasMetadata> SecondaryToPrimaryMapper<T> fromOwnerReferences(
String apiVersion, String kind, boolean clusterScope) {
String correctApiVersion = apiVersion.startsWith("/") ? apiVersion.substring(1) : apiVersion;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

cc @metacosm see this part. Also if you check the related commit, that explains the issue here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm, that's weird, I wonder why we have that code here to start with… Let me look into it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Indeed, this was a workaround for a bug in the client which has been addressed in fabric8io/kubernetes-client@84f46da so we should actually remove this workaround as well now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

great, thank you for checking! Will update the PR.

return resource ->
resource.getMetadata().getOwnerReferences().stream()
.filter(r -> r.getKind().equals(kind) && r.getApiVersion().equals(correctApiVersion))
.filter(
r ->
r.getKind().equals(kind)
&& getGroup(r.getApiVersion()).equals(getGroup(apiVersion)))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure this is the correct behavior: shouldn't there be a conversion hook instead? It seems dangerous to potentially match resources with differing versions…

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Conversion hooks, does not update the owner references of the resource. Maybe we should have an integration test also this, to see how exactly and what happens. I will add one.

.map(or -> ResourceID.fromOwnerReference(resource, or, clusterScope))
.collect(Collectors.toSet());
Comment on lines 101 to 110
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

This change intentionally relaxes ownerReference matching from full apiVersion to just group. Please add a focused unit test in the existing MappersTest that demonstrates the new behavior (same group + kind but different versions should still map), so future refactors don’t accidentally revert to strict apiVersion matching.

Copilot uses AI. Check for mistakes.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultFinalizerName;
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultNameFor;
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultReconcilerName;
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getGroup;
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException;
import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.isFinalizerValid;
import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -308,6 +309,14 @@ void compareResourceVersionsWithHasMetadata() {
assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive();
}

@Test
void extractsGroupFromApiVersion() {
assertThat(getGroup("v1")).isEqualTo("");
assertThat(getGroup("/v1")).isEqualTo("");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This isn't a valid apiVersion String

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

see conversation here: #3302 (comment)

assertThat(getGroup("apps/v1")).isEqualTo("apps");
assertThat(getGroup("/apps/v1")).isEqualTo("apps");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This isn't a valid apiVersion so I don't think we should cover this case, apart maybe to throw an IllegalArgumentException

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I made this so we are backwards compatible with the current implementation marked you on the related code part. But I tend to agree that we should make it throw such exception in next minor version.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since the client does not have the underlying issue anymore, I don't think that we should implement these backwards compatible checks since they shouldn't be needed anymore.

}

private HasMetadata createResourceWithVersion(String resourceVersion) {
return new PodBuilder()
.withMetadata(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.javaoperatorsdk.operator.TestUtils;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
Expand Down Expand Up @@ -83,6 +84,30 @@ void secondaryToPrimaryMapperFromOwnerReferenceFiltersByType() {
assertThat(res).isEmpty();
}

@Test
void fromOwnerReferenceIgnoresVersionFromApiVersion() {
var primary = TestUtils.testCustomResource();
primary.getMetadata().setUid(UUID.randomUUID().toString());
var secondary =
new ConfigMapBuilder()
.withMetadata(
new ObjectMetaBuilder()
.withName("test1")
.withNamespace(primary.getMetadata().getNamespace())
.build())
.build();
secondary.addOwnerReference(primary);

var res =
Mappers.fromOwnerReferences(
HasMetadata.getGroup(TestCustomResource.class) + "/v2",
HasMetadata.getKind(TestCustomResource.class),
false)
.toPrimaryResourceIDs(secondary);

assertThat(res).contains(ResourceID.fromResource(primary));
}

private static ConfigMap getConfigMap(TestCustomResource primary) {
return new ConfigMapBuilder()
.withMetadata(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.baseapi.ownerreferencemultiversion;

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("OwnerRefMultiVersionCR")
public class OwnerRefMultiVersionCR1
extends CustomResource<OwnerRefMultiVersionSpec, OwnerRefMultiVersionStatus>
implements Namespaced {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.baseapi.ownerreferencemultiversion;

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(value = "v2", storage = false)
@Kind("OwnerRefMultiVersionCR")
public class OwnerRefMultiVersionCR2
extends CustomResource<OwnerRefMultiVersionSpec, OwnerRefMultiVersionStatus>
implements Namespaced {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* 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.baseapi.ownerreferencemultiversion;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionVersion;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

/**
* Integration test verifying that {@code Mappers.fromOwnerReferences} correctly maps secondary
* resources back to primary resources even after a CRD version change. The mapper compares only the
* group part of the apiVersion (ignoring the version), so owner references created under v1 should
* still work when the CRD storage version switches to v2.
*/
class OwnerRefMultiVersionIT {

private static final Logger log = LoggerFactory.getLogger(OwnerRefMultiVersionIT.class);

private static final String CR_NAME = "test-ownerref-mv";
private static final String CRD_NAME = "ownerrefmultiversioncrs.sample.javaoperatorsdk";

@RegisterExtension
LocallyRunOperatorExtension operator =
LocallyRunOperatorExtension.builder()
.withReconciler(new OwnerRefMultiVersionReconciler())
.withBeforeStartHook(
ext -> {
// The auto-generated CRD has both v1 (storage) and v2. Remove v2 so the
// cluster initially only knows about v1.
var client = ext.getKubernetesClient();
var crd =
client
.apiextensions()
.v1()
.customResourceDefinitions()
.withName(CRD_NAME)
.get();
if (crd != null) {
crd.getSpec().getVersions().removeIf(v -> "v2".equals(v.getName()));
crd.getMetadata().setResourceVersion(null);
crd.getMetadata().setManagedFields(null);
client.resource(crd).serverSideApply();
log.info("Applied CRD with v1 only");
}
})
.build();

@Test
void mapperWorksAcrossVersionChange() {
var reconciler = operator.getReconcilerOfType(OwnerRefMultiVersionReconciler.class);

// 1. Create a v1 custom resource
var cr = createCR();
operator.create(cr);

// 2. Wait for initial reconciliation: ConfigMap is created with owner ref to v1
await()
.atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofMillis(300))
.untilAsserted(
() -> {
var current = operator.get(OwnerRefMultiVersionCR1.class, CR_NAME);
assertThat(current.getStatus()).isNotNull();
assertThat(current.getStatus().getReconcileCount()).isPositive();

var cm = operator.get(ConfigMap.class, CR_NAME);
assertThat(cm).isNotNull();
assertThat(cm.getMetadata().getOwnerReferences()).hasSize(1);
assertThat(cm.getMetadata().getOwnerReferences().get(0).getApiVersion())
.isEqualTo("sample.javaoperatorsdk/v1");
});

int countBeforeUpdate = reconciler.getReconcileCount();
log.info("Reconcile count before CRD update: {}", countBeforeUpdate);

// 3. Update CRD to add v2 as the new storage version
updateCrdWithV2AsStorage(operator.getKubernetesClient());

// 4. Modify the ConfigMap to trigger the informer event source.
// The mapper should still map the ConfigMap (with v1 owner ref) to the primary CR.
var cm = operator.get(ConfigMap.class, CR_NAME);
cm.getData().put("updated", "true");
operator.replace(cm);

// 5. Verify reconciliation was triggered again
await()
.atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofMillis(300))
.untilAsserted(
() -> {
assertThat(reconciler.getReconcileCount()).isGreaterThan(countBeforeUpdate);
});

log.info("Reconcile count after CRD update: {}", reconciler.getReconcileCount());
}

private void updateCrdWithV2AsStorage(KubernetesClient client) {
var crd = client.apiextensions().v1().customResourceDefinitions().withName(CRD_NAME).get();

// Set v1 to non-storage
for (var version : crd.getSpec().getVersions()) {
if ("v1".equals(version.getName())) {
version.setStorage(false);
}
}

// Add v2 as storage version, reusing v1's schema (specs are compatible)
var v1 =
crd.getSpec().getVersions().stream()
.filter(v -> "v1".equals(v.getName()))
.findFirst()
.orElseThrow();

var v2 = new CustomResourceDefinitionVersion();
v2.setName("v2");
v2.setServed(true);
v2.setStorage(true);
v2.setSchema(v1.getSchema());
v2.setSubresources(v1.getSubresources());
crd.getSpec().getVersions().add(v2);

crd.getMetadata().setResourceVersion(null);
crd.getMetadata().setManagedFields(null);
client.resource(crd).serverSideApply();
log.info("Updated CRD with v2 as storage version");
}

private OwnerRefMultiVersionCR1 createCR() {
var cr = new OwnerRefMultiVersionCR1();
cr.setMetadata(new ObjectMeta());
cr.getMetadata().setName(CR_NAME);
cr.setSpec(new OwnerRefMultiVersionSpec());
cr.getSpec().setValue("initial");
return cr;
}
}
Loading
Loading