From cb174136e92356e11cb1fc4ee61f7e429380accf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 15:03:26 +0000 Subject: [PATCH 1/4] Initial plan From 9563d83a62079c6d7ddff94ccf560774793605f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 15:10:36 +0000 Subject: [PATCH 2/4] Fix informer list items missing kind/apiVersion by populating them from list metadata or ModelMapper Agent-Logs-Url: https://github.com/kubernetes-client/java/sessions/717d58ee-9dd8-47b6-ba67-22c111fcaa80 Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- .../informer/cache/ReflectorRunnable.java | 60 +++++++++++++ .../informer/cache/ReflectorRunnableTest.java | 90 +++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java b/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java index 6d62ae1537..b285cacf9a 100644 --- a/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java +++ b/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java @@ -12,6 +12,7 @@ */ package io.kubernetes.client.informer.cache; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.common.KubernetesListObject; import io.kubernetes.client.common.KubernetesObject; import io.kubernetes.client.informer.EventType; @@ -21,9 +22,11 @@ import io.kubernetes.client.openapi.models.V1ListMeta; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ModelMapper; import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.Watchable; import java.io.IOException; +import java.lang.reflect.Method; import java.net.ConnectException; import java.net.HttpURLConnection; import java.time.Duration; @@ -94,6 +97,7 @@ public void run() { V1ListMeta listMeta = list.getMetadata(); String resourceVersion = listMeta.getResourceVersion(); List items = list.getItems(); + populateTypeMeta(items, list.getApiVersion(), list.getKind()); if (log.isDebugEnabled()) { log.debug("{}#Extract resourceVersion {} list meta", apiTypeClass, resourceVersion); @@ -229,6 +233,62 @@ private String getRelistResourceVersion() { return lastSyncResourceVersion; } + /* visible for testing */ void populateTypeMeta( + List items, String listApiVersion, String listKind) { + if (items == null || items.isEmpty()) { + return; + } + + // Determine kind and apiVersion to use for items + String kind = null; + String apiVersion = null; + + // First, derive from the list's own kind/apiVersion (strip "List" suffix) + if (!Strings.isNullOrEmpty(listApiVersion) + && !Strings.isNullOrEmpty(listKind) + && listKind.endsWith("List")) { + kind = listKind.substring(0, listKind.length() - 4); + apiVersion = listApiVersion; + } + + // Fall back to ModelMapper if list metadata is absent + if (kind == null) { + GroupVersionKind gvk = ModelMapper.getGroupVersionKindByClass(apiTypeClass); + if (gvk == null) { + Optional preBuiltGvk = + ModelMapper.preBuiltGetGroupVersionKindByClass(apiTypeClass); + gvk = preBuiltGvk.orElse(null); + } + if (gvk != null) { + kind = gvk.getKind(); + String group = gvk.getGroup(); + String version = gvk.getVersion(); + apiVersion = Strings.isNullOrEmpty(group) ? version : group + "/" + version; + } + } + + if (kind == null || apiVersion == null) { + return; + } + + final String resolvedKind = kind; + final String resolvedApiVersion = apiVersion; + try { + Method setKind = apiTypeClass.getMethod("setKind", String.class); + Method setApiVersion = apiTypeClass.getMethod("setApiVersion", String.class); + for (KubernetesObject item : items) { + if (Strings.isNullOrEmpty(item.getKind())) { + setKind.invoke(item, resolvedKind); + } + if (Strings.isNullOrEmpty(item.getApiVersion())) { + setApiVersion.invoke(item, resolvedApiVersion); + } + } + } catch (ReflectiveOperationException e) { + log.warn("{}#Failed to set kind/apiVersion on list items", apiTypeClass, e); + } + } + private void watchHandler(Watchable watch) { while (watch.hasNext()) { io.kubernetes.client.util.Watch.Response item = watch.next(); diff --git a/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java b/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java index 74c0d5e141..7054c79e97 100644 --- a/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java +++ b/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java @@ -24,6 +24,7 @@ import io.kubernetes.client.informer.ListerWatcher; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ListMeta; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Pod; import io.kubernetes.client.openapi.models.V1PodList; import io.kubernetes.client.openapi.models.V1Status; @@ -32,6 +33,8 @@ import io.kubernetes.client.util.Watchable; import java.net.HttpURLConnection; import java.time.Duration; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -373,4 +376,91 @@ void givemExceptionHandlerSet() { assertThat(reflector.exceptionHandler).isSameAs(exceptionHandler); } + + @Test + void populateTypeMetaSetsKindAndApiVersionFromListMetadata() { + ReflectorRunnable reflector = + new ReflectorRunnable<>(V1Pod.class, listerWatcher, deltaFIFO); + + V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test-pod")); + List items = Arrays.asList(pod); + + reflector.populateTypeMeta(items, "v1", "PodList"); + + assertThat(pod.getKind()).isEqualTo("Pod"); + assertThat(pod.getApiVersion()).isEqualTo("v1"); + } + + @Test + void populateTypeMetaDoesNotOverwriteExistingKindAndApiVersion() { + ReflectorRunnable reflector = + new ReflectorRunnable<>(V1Pod.class, listerWatcher, deltaFIFO); + + V1Pod pod = + new V1Pod() + .metadata(new V1ObjectMeta().name("test-pod")) + .kind("AlreadySet") + .apiVersion("already/set"); + List items = Arrays.asList(pod); + + reflector.populateTypeMeta(items, "v1", "PodList"); + + assertThat(pod.getKind()).isEqualTo("AlreadySet"); + assertThat(pod.getApiVersion()).isEqualTo("already/set"); + } + + @Test + void populateTypeMetaFallsBackToModelMapperWhenListMetadataAbsent() { + ReflectorRunnable reflector = + new ReflectorRunnable<>(V1Pod.class, listerWatcher, deltaFIFO); + + V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test-pod")); + List items = Arrays.asList(pod); + + // Pass null list kind/apiVersion to trigger ModelMapper fallback + reflector.populateTypeMeta(items, null, null); + + assertThat(pod.getKind()).isEqualTo("Pod"); + assertThat(pod.getApiVersion()).isEqualTo("v1"); + } + + @Test + void populateTypeMetaSetsKindAndApiVersionDuringInitialList() throws ApiException, InterruptedException { + V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test-pod").resourceVersion("1")); + when(listerWatcher.list(any())) + .thenReturn( + new V1PodList() + .apiVersion("v1") + .kind("PodList") + .metadata(new V1ListMeta().resourceVersion("1000")) + .addItemsItem(pod)); + CountDownLatch watchCalledLatch = new CountDownLatch(1); + CountDownLatch watchCanReturnLatch = new CountDownLatch(1); + when(listerWatcher.watch(any())) + .then( + (v) -> { + watchCalledLatch.countDown(); + try { + watchCanReturnLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return new MockWatch<>(); + }); + ReflectorRunnable reflectorRunnable = + new ReflectorRunnable<>(V1Pod.class, listerWatcher, deltaFIFO); + + try { + Thread thread = new Thread(reflectorRunnable::run); + thread.setDaemon(true); + thread.start(); + assertThat(watchCalledLatch.await(1, TimeUnit.SECONDS)).isTrue(); + } finally { + reflectorRunnable.stop(); + watchCanReturnLatch.countDown(); + } + + assertThat(pod.getKind()).isEqualTo("Pod"); + assertThat(pod.getApiVersion()).isEqualTo("v1"); + } } From ebeadf23737652244efd7ebaa2da38d119f07c18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 15:12:07 +0000 Subject: [PATCH 3/4] Cache reflection Methods in constructor and rename verbose test method Agent-Logs-Url: https://github.com/kubernetes-client/java/sessions/717d58ee-9dd8-47b6-ba67-22c111fcaa80 Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- .../informer/cache/ReflectorRunnable.java | 19 +++++++++++++++---- .../informer/cache/ReflectorRunnableTest.java | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java b/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java index b285cacf9a..d77e5afab7 100644 --- a/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java +++ b/util/src/main/java/io/kubernetes/client/informer/cache/ReflectorRunnable.java @@ -63,6 +63,9 @@ public class ReflectorRunnable< /* visible for testing */ final BiConsumer, Throwable> exceptionHandler; + private Method setKindMethod; + private Method setApiVersionMethod; + public ReflectorRunnable( Class apiTypeClass, ListerWatcher listerWatcher, @@ -80,6 +83,12 @@ public ReflectorRunnable( this.apiTypeClass = apiTypeClass; this.exceptionHandler = exceptionHandler == null ? ReflectorRunnable::defaultWatchErrorHandler : exceptionHandler; + try { + this.setKindMethod = apiTypeClass.getMethod("setKind", String.class); + this.setApiVersionMethod = apiTypeClass.getMethod("setApiVersion", String.class); + } catch (NoSuchMethodException e) { + log.warn("{}#setKind or setApiVersion method not found, type metadata will not be set on list items", apiTypeClass); + } } /** @@ -271,17 +280,19 @@ private String getRelistResourceVersion() { return; } + if (setKindMethod == null || setApiVersionMethod == null) { + return; + } + final String resolvedKind = kind; final String resolvedApiVersion = apiVersion; try { - Method setKind = apiTypeClass.getMethod("setKind", String.class); - Method setApiVersion = apiTypeClass.getMethod("setApiVersion", String.class); for (KubernetesObject item : items) { if (Strings.isNullOrEmpty(item.getKind())) { - setKind.invoke(item, resolvedKind); + setKindMethod.invoke(item, resolvedKind); } if (Strings.isNullOrEmpty(item.getApiVersion())) { - setApiVersion.invoke(item, resolvedApiVersion); + setApiVersionMethod.invoke(item, resolvedApiVersion); } } } catch (ReflectiveOperationException e) { diff --git a/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java b/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java index 7054c79e97..d4c81a26ca 100644 --- a/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java +++ b/util/src/test/java/io/kubernetes/client/informer/cache/ReflectorRunnableTest.java @@ -425,7 +425,7 @@ void populateTypeMetaFallsBackToModelMapperWhenListMetadataAbsent() { } @Test - void populateTypeMetaSetsKindAndApiVersionDuringInitialList() throws ApiException, InterruptedException { + void initialListPopulatesTypeMetaOnItems() throws ApiException, InterruptedException { V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test-pod").resourceVersion("1")); when(listerWatcher.list(any())) .thenReturn( From 4dced83c5040fbf49536960e3116ff517fcb570e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:19:15 +0000 Subject: [PATCH 4/4] Add e2e test: verify initial list items have kind/apiVersion populated Agent-Logs-Url: https://github.com/kubernetes-client/java/sessions/1eef68a6-942c-42a6-ab82-3d58e74eb0aa Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- .../informer/InformerListTypeMetaTest.java | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 e2e/src/test/java/io/kubernetes/client/e2e/informer/InformerListTypeMetaTest.java diff --git a/e2e/src/test/java/io/kubernetes/client/e2e/informer/InformerListTypeMetaTest.java b/e2e/src/test/java/io/kubernetes/client/e2e/informer/InformerListTypeMetaTest.java new file mode 100644 index 0000000000..8176f741b3 --- /dev/null +++ b/e2e/src/test/java/io/kubernetes/client/e2e/informer/InformerListTypeMetaTest.java @@ -0,0 +1,97 @@ +/* +Copyright 2024 The Kubernetes 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.kubernetes.client.e2e.informer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.informer.cache.Lister; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.models.V1Namespace; +import io.kubernetes.client.openapi.models.V1NamespaceList; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * E2E test verifying that initial list items delivered via the informer's onAdd callback contain + * the expected kind and apiVersion fields (not null), consistent with watch events. + */ +class InformerListTypeMetaTest { + + @Test + void initialListItemsShouldHaveKindAndApiVersionPopulated() throws Exception { + ApiClient client = ClientBuilder.defaultClient(); + SharedInformerFactory informerFactory = new SharedInformerFactory(client); + + GenericKubernetesApi api = + new GenericKubernetesApi<>( + V1Namespace.class, + V1NamespaceList.class, + "", + "v1", + "namespaces", + client); + + SharedIndexInformer nsInformer = + informerFactory.sharedIndexInformerFor(api, V1Namespace.class, 0); + Lister nsLister = new Lister<>(nsInformer.getIndexer()); + + // Collect items received via onAdd during the initial list phase + List addedItems = new ArrayList<>(); + nsInformer.addEventHandler( + new ResourceEventHandler() { + @Override + public void onAdd(V1Namespace obj) { + addedItems.add(obj); + } + + @Override + public void onUpdate(V1Namespace oldObj, V1Namespace newObj) {} + + @Override + public void onDelete(V1Namespace obj, boolean deletedFinalStateUnknown) {} + }); + + try { + informerFactory.startAllRegisteredInformers(); + + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(nsInformer.hasSynced()).isTrue(); + assertThat(nsLister.list()).isNotEmpty(); + }); + + // All items received via onAdd during the initial list must have kind and apiVersion set + assertThat(addedItems).isNotEmpty(); + for (V1Namespace ns : addedItems) { + assertThat(ns.getKind()) + .as("kind should be populated for namespace %s", ns.getMetadata().getName()) + .isEqualTo("Namespace"); + assertThat(ns.getApiVersion()) + .as("apiVersion should be populated for namespace %s", ns.getMetadata().getName()) + .isEqualTo("v1"); + } + } finally { + informerFactory.stopAllRegisteredInformers(true); + } + } +}