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 @@ -45,6 +45,13 @@ public class ReconcilerUtilsInternal {
private static final String GET_STATUS = "getStatus";
private static final Pattern API_URI_PATTERN =
Pattern.compile(".*http(s?)://[^/]*/api(s?)/(\\S*).*"); // NOSONAR: input is controlled
private static final String DOT = ".";
private static final char DOT_CHAR = '.';
private static final String SLASH = "/";
private static final char SLASH_CHAR = '/';
private static final String EMPTY = "";
private static final char ZERO = '0';
private static final char NINE = '9';

// prevent instantiation of util class
private ReconcilerUtilsInternal() {}
Expand All @@ -55,7 +62,7 @@ public static boolean isFinalizerValid(String finalizer) {

public static String getResourceTypeNameWithVersion(Class<? extends HasMetadata> resourceClass) {
final var version = HasMetadata.getVersion(resourceClass);
return getResourceTypeName(resourceClass) + "/" + version;
return getResourceTypeName(resourceClass) + SLASH + version;
}

public static String getResourceTypeName(Class<? extends HasMetadata> resourceClass) {
Expand All @@ -69,7 +76,7 @@ public static String getDefaultFinalizerName(Class<? extends HasMetadata> resour
public static String getDefaultFinalizerName(String resourceName) {
// resource names for historic resources such as Pods are missing periods and therefore do not
// constitute valid domain names as mandated by Kubernetes so generate one that does
if (resourceName.indexOf('.') < 0) {
if (resourceName.indexOf(DOT_CHAR) < 0) {
resourceName = resourceName + MISSING_GROUP_SUFFIX;
}
return resourceName + FINALIZER_NAME_SUFFIX;
Expand Down Expand Up @@ -102,7 +109,7 @@ public static String getDefaultNameFor(Class<? extends Reconciler> reconcilerCla

public static String getDefaultReconcilerName(String reconcilerClassName) {
// if the name is fully qualified, extract the simple class name
final var lastDot = reconcilerClassName.lastIndexOf('.');
final var lastDot = reconcilerClassName.lastIndexOf(DOT_CHAR);
if (lastDot > 0) {
reconcilerClassName = reconcilerClassName.substring(lastDot + 1);
}
Expand Down Expand Up @@ -228,15 +235,15 @@ private static boolean matchesResourceType(
final var regex = API_URI_PATTERN.matcher(message);
if (regex.matches()) {
var group = regex.group(3);
if (group.endsWith(".")) {
if (group.endsWith(DOT)) {
group = group.substring(0, group.length() - 1);
}
final var segments =
Arrays.stream(group.split("/")).filter(Predicate.not(String::isEmpty)).toList();
Arrays.stream(group.split(SLASH)).filter(Predicate.not(String::isEmpty)).toList();
if (segments.size() != 3) {
return false;
}
final var targetResourceName = segments.get(2) + "." + segments.get(0);
final var targetResourceName = segments.get(2) + DOT_CHAR + segments.get(0);
return resourceTypeName.equals(targetResourceName);
}
}
Expand Down Expand Up @@ -349,16 +356,25 @@ private static int validateResourceVersion(String v1) {
}
for (int i = 0; i < v1Length; i++) {
char char1 = v1.charAt(i);
if (char1 == '0') {
if (char1 == ZERO) {
if (i == 0) {
throw new NonComparableResourceVersionException(
"Resource version cannot begin with 0: " + v1);
}
} else if (char1 < '0' || char1 > '9') {
} else if (char1 < ZERO || char1 > NINE) {
throw new NonComparableResourceVersionException(
"Non numeric characters in resource version: " + v1);
}
}
return v1Length;
}

public static String getGroup(String apiVersion) {
Comment thread
csviri marked this conversation as resolved.
var index = apiVersion.indexOf(SLASH_CHAR);
if (index < 0) {
return EMPTY;
} 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;
Comment thread
csviri marked this conversation as resolved.
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,12 @@ void compareResourceVersionsWithHasMetadata() {
assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive();
}

@Test
void extractsGroupFromApiVersion() {
assertThat(getGroup("v1")).isEqualTo("");
assertThat(getGroup("apps/v1")).isEqualTo("apps");
}

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