Repository: kroxylicious/kroxylicious
Pull request #1994: Include namespace in ResolveRefs message
Author: login: robobario
Created: 2025-03-25T02:12:01Z | Merged: 2025-03-28T04:35:33Z


### Type of change

- Enhancement / new feature

### Description

Adds namespace to the message to help users understand what the Operator was looking for.

Also use a less verbose form for the group/kind/name coordinates

Closes #1961 

### Checklist

_Please go through this checklist and make sure all applicable tasks have been done_

- [ ] PR raised from a fork of this repository and made from a branch rather than main. 
- [ ] Write tests
- [ ] Update documentation
- [ ] Make sure all unit/integration tests pass
- [ ] Make sure all Sonarcloud warnings are addressed or are justifiably ignored.
- [ ] If applicable to the change, [trigger the system test suite](../blob/main/DEV_GUIDE.md#jenkins-pipeline-for-system-tests).  Make sure tests pass.
- [ ] If applicable to the change, [trigger the performance test suite](../blob/main/PERFORMANCE.md#jenkins-pipeline-for-performance). Ensure that any degradations to performance numbers are understood and justified.
- [ ] Ensure the PR references relevant issue(s) so they are closed on merging.
- [ ] For user facing changes, update CHANGELOG.md (remember to include changes affecting the API of the test artefacts too).

> **_NOTE:_**  You must be a member of `@kroxylicious/developers` to trigger the system test and performance test suites.  If you are not part of this group, comment on the PR requesting a trigger, tagging `@kroxylicious/developers`.


Linked issue #1961: always include resource namespace in condition messages
              Maybe I was miss reading the diff but we weren't including kind or group either... 

I was actually musing if it would make mistakes easier to spot for users if we logged the full co-ordinates of what we were looking for. E.g. we can't find `kind: X in group: Y named: Z in namespace: A` makes it really obvious whats going on if you have created `kind: X in group: Y named: Z in namespace: B`. 

I guess what I'm saying its obvious to us but is it obvious to users? Is it actionable?

_Originally posted by @SamBarker in https://github.com/kroxylicious/kroxylicious/pull/1957#discussion_r1999968786_
            
Linked issue comments: user: login: robobario
created_at: 2025-03-20T23:48:12Z
body: +1 to including namespace. It might be the hint users need to see that resource named `my-cool-filter` is in a mismatched namespace.
author_association: MEMBER
user: login: k-wall
created_at: 2025-03-21T09:31:18Z
body: +1, but I'd like to see something less verbose.  Kubernetes uses the convention `<singular>[.<group>]/<name>` which I think should be well understood.    Could we use that? 


```
kwall@Oslo kafka-native % oc get kafka,all --all-namespaces --output=name
kafka.kafka.strimzi.io/my-cluster
pod/my-cluster-dual-role-0
pod/my-cluster-entity-operator-59b5f4f6db-nbftv
pod/strimzi-cluster-operator-76b947897f-9wp7c
pod/kroxylicious-operator-78dbcd9cf8-xzxnr
pod/coredns-668d6bf9bc-t8x2x
```

 I'm not sure if I have seen a generally accepted way to include the namespace coordinate, so I vote for something like: 

`name: kafka.kafka.strimzi.io/my-cluster in namespace: blah`

author_association: MEMBER

Diff:
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterCondition.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterCondition.java
index b5b5ff3ef0..03f8c10de1 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterCondition.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterCondition.java
@@ -8,6 +8,8 @@
 
 import java.util.Set;
 
+import io.fabric8.kubernetes.api.model.HasMetadata;
+
 import io.kroxylicious.kubernetes.api.common.Condition;
 import io.kroxylicious.kubernetes.api.common.LocalRef;
 import io.kroxylicious.kubernetes.operator.model.ingress.IngressConflictException;
@@ -15,6 +17,7 @@
 import edu.umd.cs.findbugs.annotations.NonNull;
 import edu.umd.cs.findbugs.annotations.Nullable;
 
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
 import static java.util.stream.Collectors.joining;
 
 public record ClusterCondition(@NonNull String cluster,
@@ -29,14 +32,9 @@ static ClusterCondition accepted(String cluster) {
         return new ClusterCondition(cluster, Condition.Type.Accepted, Condition.Status.TRUE, null, null);
     }
 
-    static ClusterCondition filterInvalid(String cluster, String filterName, String detail) {
-        return new ClusterCondition(cluster, Condition.Type.Accepted, Condition.Status.FALSE, INVALID,
-                String.format("Filter \"%s\" is invalid: %s", filterName, detail));
-    }
-
-    public static ClusterCondition refNotFound(String cluster, LocalRef<?> ref) {
-        return new ClusterCondition(cluster, Condition.Type.ResolvedRefs, Condition.Status.FALSE, INVALID,
-                String.format("Resource of kind \"%s\" in group \"%s\" named \"%s\" does not exist.", ref.getKind(), ref.getGroup(), ref.getName()));
+    public static ClusterCondition refNotFound(HasMetadata referrer, LocalRef<?> ref) {
+        return new ClusterCondition(name(referrer), Condition.Type.ResolvedRefs, Condition.Status.FALSE, INVALID,
+                String.format("Resource %s was not found.", ResourcesUtil.namespacedSlug(ref, referrer)));
     }
 
     public static ClusterCondition ingressConflict(String cluster, Set<IngressConflictException> ingressConflictExceptions) {
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
index db93fc8e37..4b74d28d8b 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
@@ -13,6 +13,7 @@
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -31,8 +32,6 @@
 import io.fabric8.kubernetes.api.model.OwnerReference;
 import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
 import io.fabric8.kubernetes.client.CustomResource;
-import io.fabric8.kubernetes.model.annotation.Group;
-import io.fabric8.kubernetes.model.annotation.Singular;
 import io.javaoperatorsdk.operator.api.reconciler.Context;
 import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
 import io.javaoperatorsdk.operator.processing.event.ResourceID;
@@ -54,6 +53,7 @@
 import edu.umd.cs.findbugs.annotations.NonNull;
 
 public class ResourcesUtil {
+
     private ResourcesUtil() {
     }
 
@@ -176,6 +176,10 @@ public static <T extends HasMetadata> LocalRef<T> toLocalRef(T ref) {
     }
 
     public static String group(HasMetadata resource) {
+        // core CustomResource classes like Secret, Deployment etc. have a group of empty string and their apiVersion is the String 'v1'
+        if (!resource.getApiVersion().contains("/")) {
+            return "";
+        }
         return resource.getApiVersion().substring(0, resource.getApiVersion().indexOf("/"));
     }
 
@@ -452,15 +456,15 @@ static Condition resolvedRefsUnknown(Clock clock,
         return newUnknownCondition(clock, observedGenerationSource, Condition.Type.ResolvedRefs, e);
     }
 
-    static String slug(String singular, String group, String name) {
-        return singular + "." + group + "/" + name;
+    public static @NonNull String namespacedSlug(LocalRef<?> ref, HasMetadata resource) {
+        return slug(ref) + " in namespace '" + namespace(resource) + "'";
     }
 
-    static String slug(Class<? extends CustomResource<?, ?>> annotatedCrdClass, String crName) {
-        return slug(
-                annotatedCrdClass.getAnnotation(Singular.class).value(),
-                annotatedCrdClass.getAnnotation(Group.class).value(),
-                crName);
+    private static @NonNull String slug(LocalRef<?> ref) {
+        String group = ref.getGroup();
+        String name = ref.getName();
+        String groupString = group.isEmpty() ? "" : "." + group;
+        return ref.getKind().toLowerCase(Locale.ROOT) + groupString + "/" + name;
     }
 
 }
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
index 0ed9ca9ffd..040ff94a8c 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
@@ -130,9 +130,9 @@ public UpdateControl<VirtualKafkaCluster> reconcile(VirtualKafkaCluster cluster,
                 condition = ResourcesUtil.newResolvedRefsTrue(clock, cluster);
             }
             else {
-                Stream<String> serviceMsg = refsMessage("spec.targetKafkaServiceRef references ", KafkaService.class, unresolvedServices);
-                Stream<String> ingressMsg = refsMessage("spec.ingressRefs references ", KafkaProxyIngress.class, unresolvedIngresses);
-                Stream<String> filterMsg = refsMessage("spec.filterRefs references ", KafkaProtocolFilter.class, unresolvedFilters);
+                Stream<String> serviceMsg = refsMessage("spec.targetKafkaServiceRef references ", cluster, unresolvedServices);
+                Stream<String> ingressMsg = refsMessage("spec.ingressRefs references ", cluster, unresolvedIngresses);
+                Stream<String> filterMsg = refsMessage("spec.filterRefs references ", cluster, unresolvedFilters);
                 condition = ResourcesUtil.newResolvedRefsFalse(clock,
                         cluster,
                         TRANSITIVELY_REFERENCED_RESOURCES_NOT_FOUND,
@@ -140,10 +140,10 @@ public UpdateControl<VirtualKafkaCluster> reconcile(VirtualKafkaCluster cluster,
             }
         }
         else {
-            Stream<String> proxyMsg = refsMessage("spec.proxyRef references ", KafkaProxy.class, missingProxies);
-            Stream<String> serviceMsg = refsMessage("spec.targetKafkaServiceRef references ", KafkaService.class, missingServices);
-            Stream<String> ingressMsg = refsMessage("spec.ingressRefs references ", KafkaProxyIngress.class, missingIngresses);
-            Stream<String> filterMsg = refsMessage("spec.filterRefs references ", KafkaProtocolFilter.class, missingFilters);
+            Stream<String> proxyMsg = refsMessage("spec.proxyRef references ", cluster, missingProxies);
+            Stream<String> serviceMsg = refsMessage("spec.targetKafkaServiceRef references ", cluster, missingServices);
+            Stream<String> ingressMsg = refsMessage("spec.ingressRefs references ", cluster, missingIngresses);
+            Stream<String> filterMsg = refsMessage("spec.filterRefs references ", cluster, missingFilters);
 
             condition = ResourcesUtil.newResolvedRefsFalse(clock,
                     cluster,
@@ -173,12 +173,12 @@ private static boolean hasAnyResolvedRefsFalse(List<Condition> conditions) {
     @NonNull
     private static <R extends CustomResource<?, ?>> Stream<String> refsMessage(
                                                                                String prefix,
-                                                                               Class<R> crdClass,
+                                                                               VirtualKafkaCluster cluster,
                                                                                TreeSet<? extends LocalRef<R>> refs) {
         return refs.isEmpty() ? Stream.of()
                 : Stream.of(
                         prefix + refs.stream()
-                                .map(ref -> ResourcesUtil.slug(crdClass, ref.getName()))
+                                .map(ref -> ResourcesUtil.namespacedSlug(ref, cluster))
                                 .collect(Collectors.joining(", ")));
     }
 
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporter.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporter.java
index 2c0b11d405..5f40e0b6d5 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporter.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporter.java
@@ -17,8 +17,6 @@
 import io.kroxylicious.kubernetes.operator.ClusterCondition;
 import io.kroxylicious.kubernetes.operator.SharedKafkaProxyContext;
 
-import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
-
 public record ClusterConditionUnresolvedDependencyReporter(Context<KafkaProxy> context) implements UnresolvedDependencyReporter {
 
     @Override
@@ -33,6 +31,6 @@ public void reportUnresolvedDependencies(VirtualKafkaCluster cluster, Set<LocalR
                 .sorted(comparator).findFirst()
                 .orElseThrow();
         SharedKafkaProxyContext.addClusterCondition(context, cluster,
-                ClusterCondition.refNotFound(name(cluster), firstUnresolvedDependency));
+                ClusterCondition.refNotFound(cluster, firstUnresolvedDependency));
     }
 }
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconcilerTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconcilerTest.java
index bf74d765c0..092292881a 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconcilerTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconcilerTest.java
@@ -329,9 +329,11 @@ void transitionToReadyFalseShouldChangeTransitionTime2() {
                 .build();
         // @formatter:on
         doReturn(mdrc).when(context).managedWorkflowAndDependentResourceContext();
-        doReturn(Set.of(new VirtualKafkaClusterBuilder().withNewMetadata().withName("my-cluster").withNamespace("my-ns").endMetadata().withNewSpec().withNewProxyRef()
-                .withName("my-proxy").endProxyRef().endSpec().build())).when(context).getSecondaryResources(VirtualKafkaCluster.class);
-        doReturn(Optional.of(Map.of("my-cluster", ClusterCondition.refNotFound("my-cluster", new FilterRefBuilder().withName("MissingFilter").build())))).when(mdrc).get(
+        VirtualKafkaCluster cluster = new VirtualKafkaClusterBuilder().withNewMetadata().withName("my-cluster").withNamespace("my-ns").endMetadata().withNewSpec()
+                .withNewProxyRef()
+                .withName("my-proxy").endProxyRef().endSpec().build();
+        doReturn(Set.of(cluster)).when(context).getSecondaryResources(VirtualKafkaCluster.class);
+        doReturn(Optional.of(Map.of("my-cluster", ClusterCondition.refNotFound(cluster, new FilterRefBuilder().withName("MissingFilter").build())))).when(mdrc).get(
                 SharedKafkaProxyContext.CLUSTER_CONDITIONS_KEY,
                 Map.class);
 
@@ -351,7 +353,7 @@ void transitionToReadyFalseShouldChangeTransitionTime2() {
         statusAssert.singleCluster()
                 .nameIsEqualTo("my-cluster")
                 .singleCondition()
-                .isResolvedRefsFalse("Invalid", "Resource of kind \"KafkaProtocolFilter\" in group \"filter.kroxylicious.io\" named \"MissingFilter\" does not exist.")
+                .isResolvedRefsFalse("Invalid", "Resource kafkaprotocolfilter.filter.kroxylicious.io/MissingFilter in namespace 'my-ns' was not found.")
                 .hasObservedGeneration(generation);
         // TODO .lastTransitionTimeIsEqualTo(time);
 
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ResourcesUtilTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ResourcesUtilTest.java
index 50e6c1b4cd..e4c8e79a1e 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ResourcesUtilTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ResourcesUtilTest.java
@@ -23,12 +23,14 @@
 import io.fabric8.kubernetes.api.model.Secret;
 import io.fabric8.kubernetes.api.model.SecretBuilder;
 
+import io.kroxylicious.kubernetes.api.common.AnyLocalRefBuilder;
 import io.kroxylicious.kubernetes.api.common.Condition;
 import io.kroxylicious.kubernetes.api.common.ConditionBuilder;
 import io.kroxylicious.kubernetes.api.common.FilterRefBuilder;
 import io.kroxylicious.kubernetes.api.common.IngressRefBuilder;
 import io.kroxylicious.kubernetes.api.common.KafkaServiceRefBuilder;
 import io.kroxylicious.kubernetes.api.common.ProxyRefBuilder;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyBuilder;
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngressBuilder;
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaServiceBuilder;
@@ -161,6 +163,30 @@ void generation() {
         assertThat(ResourcesUtil.generation(secret)).isEqualTo(generation);
     }
 
+    @Test
+    void toLocalRefNoGroup() {
+        assertThat(ResourcesUtil.toLocalRef(new SecretBuilder().withNewMetadata().withName("name").endMetadata().build()))
+                .isEqualTo(new AnyLocalRefBuilder().withName("name").withGroup("").withKind("Secret").build());
+    }
+
+    @Test
+    void toLocalRefWithGroup() {
+        assertThat(ResourcesUtil.toLocalRef(new KafkaProxyBuilder().withNewMetadata().withName("name").endMetadata().build()))
+                .isEqualTo(new AnyLocalRefBuilder().withName("name").withGroup("kroxylicious.io").withKind("KafkaProxy").build());
+    }
+
+    @Test
+    void slugWithNamespaceEmptyGroup() {
+        Secret secret = new SecretBuilder().withNewMetadata().withNamespace("my-ns").withName("secreto").endMetadata().build();
+        assertThat(ResourcesUtil.namespacedSlug(ResourcesUtil.toLocalRef(secret), secret)).isEqualTo("secret/secreto in namespace 'my-ns'");
+    }
+
+    @Test
+    void slugWithNamespaceNonEmptyGroup() {
+        KafkaProxy secret = new KafkaProxyBuilder().withNewMetadata().withNamespace("my-ns").withName("secreto").endMetadata().build();
+        assertThat(ResourcesUtil.namespacedSlug(ResourcesUtil.toLocalRef(secret), secret)).isEqualTo("kafkaproxy.kroxylicious.io/secreto in namespace 'my-ns'");
+    }
+
     @Test
     void toLocalRef() {
         assertThat(ResourcesUtil.toLocalRef(new KafkaProxyBuilder().withNewMetadata().withName("foo").endMetadata().build()))
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconcilerTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconcilerTest.java
index 4dc63e2e6d..0def3efd19 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconcilerTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconcilerTest.java
@@ -46,6 +46,7 @@ class VirtualKafkaClusterReconcilerTest {
     public static final VirtualKafkaCluster CLUSTER_NO_FILTERS = new VirtualKafkaClusterBuilder()
             .withNewMetadata()
                 .withName("foo")
+                .withNamespace("my-namespace")
                 .withGeneration(42L)
             .endMetadata()
             .withNewSpec()
@@ -129,7 +130,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.proxyRef references kafkaproxy.kroxylicious.io/my-proxy")),
+                                "spec.proxyRef references kafkaproxy.kroxylicious.io/my-proxy in namespace 'my-namespace'")),
                 Arguments.argumentSet("service not found",
                         CLUSTER_NO_FILTERS,
                         Optional.of(PROXY),
@@ -138,7 +139,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.targetKafkaServiceRef references kafkaservice.kroxylicious.io/my-kafka")),
+                                "spec.targetKafkaServiceRef references kafkaservice.kroxylicious.io/my-kafka in namespace 'my-namespace'")),
                 Arguments.argumentSet("service has unresolved refs",
                         CLUSTER_NO_FILTERS,
                         Optional.of(PROXY),
@@ -148,7 +149,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.TRANSITIVELY_REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.targetKafkaServiceRef references kafkaservice.kroxylicious.io/my-kafka")),
+                                "spec.targetKafkaServiceRef references kafkaservice.kroxylicious.io/my-kafka in namespace 'my-namespace'")),
                 Arguments.argumentSet("ingress not found",
                         CLUSTER_NO_FILTERS,
                         Optional.of(PROXY),
@@ -157,7 +158,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.ingressRefs references kafkaproxyingress.kroxylicious.io/my-ingress")),
+                                "spec.ingressRefs references kafkaproxyingress.kroxylicious.io/my-ingress in namespace 'my-namespace'")),
                 Arguments.argumentSet("ingress has unresolved refs",
                         CLUSTER_NO_FILTERS,
                         Optional.of(PROXY),
@@ -167,7 +168,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.TRANSITIVELY_REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.ingressRefs references kafkaproxyingress.kroxylicious.io/my-ingress")),
+                                "spec.ingressRefs references kafkaproxyingress.kroxylicious.io/my-ingress in namespace 'my-namespace'")),
                 Arguments.argumentSet("filter not found",
                         CLUSTER_ONE_FILTER,
                         Optional.of(PROXY),
@@ -176,7 +177,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                         Set.of(),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter")),
+                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter in namespace 'my-namespace'")),
                 Arguments.argumentSet("filter has unresolved refs",
                         CLUSTER_ONE_FILTER,
                         Optional.of(PROXY),
@@ -186,7 +187,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                                 .withStatus(Condition.Status.FALSE).endCondition().endStatus().build()),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.TRANSITIVELY_REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter")));
+                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter in namespace 'my-namespace'")));
     }
 
     @ParameterizedTest
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporterTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporterTest.java
index 4c6803cf53..13f0dafce1 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporterTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/resolver/ClusterConditionUnresolvedDependencyReporterTest.java
@@ -42,7 +42,8 @@
 @ExtendWith(MockitoExtension.class)
 class ClusterConditionUnresolvedDependencyReporterTest {
 
-    private static final VirtualKafkaCluster CLUSTER = new VirtualKafkaClusterBuilder().editMetadata().withName("cluster").endMetadata().build();
+    public static final String NAMESPACE = "foo";
+    private static final VirtualKafkaCluster CLUSTER = new VirtualKafkaClusterBuilder().editMetadata().withNamespace(NAMESPACE).withName("cluster").endMetadata().build();
 
     @Mock(strictness = LENIENT)
     Context<KafkaProxy> mockContext;
@@ -83,7 +84,7 @@ void deterministicallyReportsOneUnresolvedDependency() {
         verify(mockResourceContext).put(eq("cluster_conditions"), assertArg(a -> {
             assertThat(a).isInstanceOfSatisfying(Map.class, map -> {
                 assertThat(map).containsExactlyEntriesOf(Map.of("cluster", new ClusterCondition("cluster", Condition.Type.ResolvedRefs, Condition.Status.FALSE, "Invalid",
-                        "Resource of kind \"KafkaProtocolFilter\" in group \"filter.kroxylicious.io\" named \"a\" does not exist.")));
+                        "Resource kafkaprotocolfilter.filter.kroxylicious.io/a in namespace '" + NAMESPACE + "' was not found.")));
             });
         }));
     }
@@ -92,7 +93,7 @@ void deterministicallyReportsOneUnresolvedDependency() {
     void clusterRefUnresolved() {
         var unresolved = new KafkaServiceRefBuilder().withName("a").build();
         Set<LocalRef<?>> unresolvedDependencies = Set.of(unresolved);
-        VirtualKafkaCluster cluster = new VirtualKafkaClusterBuilder().editMetadata().withName("cluster").endMetadata()
+        VirtualKafkaCluster cluster = new VirtualKafkaClusterBuilder().editMetadata().withName("cluster").withNamespace(NAMESPACE).endMetadata()
                 .withNewSpec().withNewTargetKafkaServiceRef().withName("name").endTargetKafkaServiceRef()
                 .endSpec().build();
 
@@ -103,7 +104,7 @@ void clusterRefUnresolved() {
         verify(mockResourceContext).put(eq("cluster_conditions"), assertArg(a -> {
             assertThat(a).isInstanceOfSatisfying(Map.class, map -> {
                 assertThat(map).containsExactlyEntriesOf(Map.of("cluster", new ClusterCondition("cluster", Condition.Type.ResolvedRefs, Condition.Status.FALSE, "Invalid",
-                        "Resource of kind \"KafkaService\" in group \"kroxylicious.io\" named \"a\" does not exist.")));
+                        "Resource kafkaservice.kroxylicious.io/a in namespace '" + NAMESPACE + "' was not found.")));
             });
         }));
     }
@@ -120,7 +121,7 @@ void ingressRefUnresolved() {
         verify(mockResourceContext).put(eq("cluster_conditions"), assertArg(a -> {
             assertThat(a).isInstanceOfSatisfying(Map.class, map -> {
                 assertThat(map).containsExactlyEntriesOf(Map.of("cluster", new ClusterCondition("cluster", Condition.Type.ResolvedRefs, Condition.Status.FALSE, "Invalid",
-                        "Resource of kind \"KafkaProxyIngress\" in group \"kroxylicious.io\" named \"a\" does not exist.")));
+                        "Resource kafkaproxyingress.kroxylicious.io/a in namespace '" + NAMESPACE + "' was not found.")));
             });
         }));
     }
@@ -137,7 +138,7 @@ void filterUnresolved() {
         verify(mockResourceContext).put(eq("cluster_conditions"), assertArg(a -> {
             assertThat(a).isInstanceOfSatisfying(Map.class, map -> {
                 assertThat(map).containsExactlyEntriesOf(Map.of("cluster", new ClusterCondition("cluster", Condition.Type.ResolvedRefs, Condition.Status.FALSE, "Invalid",
-                        "Resource of kind \"KafkaProtocolFilter\" in group \"filter.kroxylicious.io\" named \"a\" does not exist.")));
+                        "Resource kafkaprotocolfilter.filter.kroxylicious.io/a in namespace '" + NAMESPACE + "' was not found.")));
             });
         }));
     }
diff --git a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/clusterref-resource-not-found/cond-ResolvedRefs-bar.yaml b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/clusterref-resource-not-found/cond-ResolvedRefs-bar.yaml
index 049192d7f8..3cf04f969f 100644
--- a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/clusterref-resource-not-found/cond-ResolvedRefs-bar.yaml
+++ b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/clusterref-resource-not-found/cond-ResolvedRefs-bar.yaml
@@ -9,5 +9,5 @@ cluster: "bar"
 type: "ResolvedRefs"
 status: "False"
 reason: "Invalid"
-message: "Resource of kind \"KafkaService\" in group \"kroxylicious.io\" named\
-    \ \"barref\" does not exist."
+message: "Resource kafkaservice.kroxylicious.io/barref in namespace 'proxy-ns' was\
+    \ not found."
diff --git a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/filter-resource-not-found/cond-ResolvedRefs-bar.yaml b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/filter-resource-not-found/cond-ResolvedRefs-bar.yaml
index 473081637c..1b6bed8e74 100644
--- a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/filter-resource-not-found/cond-ResolvedRefs-bar.yaml
+++ b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/filter-resource-not-found/cond-ResolvedRefs-bar.yaml
@@ -9,5 +9,5 @@ cluster: "bar"
 type: "ResolvedRefs"
 status: "False"
 reason: "Invalid"
-message: "Resource of kind \"KafkaProtocolFilter\" in group \"filter.kroxylicious.io\"\
-    \ named \"missing\" does not exist."
+message: "Resource kafkaprotocolfilter.filter.kroxylicious.io/missing in namespace\
+    \ 'proxy-ns' was not found."
\ No newline at end of file
diff --git a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-one-with-unresolved-ingress/cond-ResolvedRefs-foo.yaml b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-one-with-unresolved-ingress/cond-ResolvedRefs-foo.yaml
index 0f0489a11c..1ca962daba 100644
--- a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-one-with-unresolved-ingress/cond-ResolvedRefs-foo.yaml
+++ b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-one-with-unresolved-ingress/cond-ResolvedRefs-foo.yaml
@@ -9,4 +9,5 @@ cluster: foo
 type: ResolvedRefs
 status: 'False'
 reason: Invalid
-message: "Resource of kind \"KafkaProxyIngress\" in group \"kroxylicious.io\" named \"MISSING\" does not exist."
\ No newline at end of file
+message: "Resource kafkaproxyingress.kroxylicious.io/MISSING in namespace 'proxy-ns'\
+    \ was not found."
\ No newline at end of file
diff --git a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-with-colliding-ingress-and-unresolved-filter/cond-ResolvedRefs-bar.yaml b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-with-colliding-ingress-and-unresolved-filter/cond-ResolvedRefs-bar.yaml
index 067a335744..7b6fd88a53 100644
--- a/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-with-colliding-ingress-and-unresolved-filter/cond-ResolvedRefs-bar.yaml
+++ b/kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-with-colliding-ingress-and-unresolved-filter/cond-ResolvedRefs-bar.yaml
@@ -9,5 +9,5 @@ cluster: bar
 type: ResolvedRefs
 status: 'False'
 reason: Invalid
-message: "Resource of kind \"KafkaService\" in group \"kroxylicious.io\" named\
-    \ \"barref\" does not exist."
\ No newline at end of file
+message: "Resource kafkaservice.kroxylicious.io/barref in namespace 'proxy-ns' was\
+    \ not found."
\ No newline at end of file


Reviews:
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T02:13:18Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T03:29:21Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T04:20:46Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T05:38:18Z
user: login: k-wall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T15:22:48Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T20:53:40Z
user: login: tombentley
body: Thanks @robobario!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T22:34:54Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T23:25:12Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T23:27:23Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T01:47:36Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T01:48:21Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T04:39:21Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T18:57:36Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T19:21:51Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:42:25Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:45:03Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:54:02Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:54:13Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T00:56:02Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T03:05:18Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T03:05:37Z
user: login: tombentley
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T03:27:27Z
user: login: tombentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T04:01:16Z

Review comments:
diff_hunk: @@ -126,6 +138,15 @@ public static String uid(@NonNull HasMetadata resource) {
         return resource.getMetadata().getUid();
     }
 
+    public static String coordinatesString(LocalRef<?> ref, HasMetadata primary) {
+        GroupKind groupKind = new GroupKind(ref.getGroup(), ref.getKind());
+        String plural = GROUP_KIND_TO_PLURAL.get(groupKind);
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: robobario
body: maybe better to go for the singular and just lowercase the kind in the LocalRef to avoid this mapping
created_at: 2025-03-25T02:13:17Z
author_association: MEMBER
diff_hunk: @@ -29,14 +33,9 @@ static ClusterCondition accepted(String cluster) {
         return new ClusterCondition(cluster, Condition.Type.Accepted, Condition.Status.TRUE, null, null);
     }
 
-    static ClusterCondition filterInvalid(String cluster, String filterName, String detail) {
-        return new ClusterCondition(cluster, Condition.Type.Accepted, Condition.Status.FALSE, INVALID,
-                String.format("Filter \"%s\" is invalid: %s", filterName, detail));
-    }
-
-    public static ClusterCondition refNotFound(String cluster, LocalRef<?> ref) {
-        return new ClusterCondition(cluster, Condition.Type.ResolvedRefs, Condition.Status.FALSE, INVALID,
-                String.format("Resource of kind \"%s\" in group \"%s\" named \"%s\" does not exist.", ref.getKind(), ref.getGroup(), ref.getName()));
+    public static ClusterCondition refNotFound(HasMetadata referee, LocalRef<?> ref) {
+        return new ClusterCondition(name(referee), Condition.Type.ResolvedRefs, Condition.Status.FALSE, INVALID,
+                String.format("Resource %s does not exist.", coordinatesString(ref, referee)));
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterCondition.java
user: login: tombentley
body: Can we change this to "Resource %s was not found"? That aligns with what's usually the underlying cause -- we got a 404 when we requested the referenced resource. 
created_at: 2025-03-25T03:23:31Z
author_association: MEMBER
diff_hunk: @@ -126,6 +138,15 @@ public static String uid(@NonNull HasMetadata resource) {
         return resource.getMetadata().getUid();
     }
 
+    public static String coordinatesString(LocalRef<?> ref, HasMetadata primary) {
+        GroupKind groupKind = new GroupKind(ref.getGroup(), ref.getKind());
+        String plural = GROUP_KIND_TO_PLURAL.get(groupKind);
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: I'm not aware of any counterexamples to that working.
created_at: 2025-03-25T03:24:48Z
author_association: MEMBER
diff_hunk: @@ -126,6 +138,15 @@ public static String uid(@NonNull HasMetadata resource) {
         return resource.getMetadata().getUid();
     }
 
+    public static String coordinatesString(LocalRef<?> ref, HasMetadata primary) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: * "coordinatesString" is such a mouthful to say for such a simple thing. 
* What `primary` is really doing here it providing the namespace, so maybe there's a better name. `inNamespaceOf`?
created_at: 2025-03-25T03:28:33Z
author_association: MEMBER
diff_hunk: @@ -126,6 +138,15 @@ public static String uid(@NonNull HasMetadata resource) {
         return resource.getMetadata().getUid();
     }
 
+    public static String coordinatesString(LocalRef<?> ref, HasMetadata primary) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: robobario
body: done, I've shuffled it into the `LocalRef` hierarchy so now you can say `NamespacedLocalRef<T> LocalRef<T>#inNamespaceOf(HasMetadata resource)` and have made this the toString.

hmm, `NamespacedLocalRef` doesn't really fit, `LocalRef` is docced as being in the same namespace. Maybe it should be just `NamespacedRef` and it should have equals/hash that take namespace into account too.
created_at: 2025-03-25T04:20:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
+
+import java.util.Objects;
+
+public class NamespacedRef<T> extends LocalRef<T> {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: tombentley
body: I'm not convinced that we should have the `extends` clause. Is it really needed for what you're doing here?

The reason I'm a bit doubtful... How does `LocalRef` with a namespace differ from a "`GlobalRef`"? Sure we don't have `GlobalRef` yet, but we probably will, in the fullness of time. I think we should be a bit intentional with how the polymorphism these classes support. We don't want a `GlobalRef` that extends `LocalRef`, because they should not be substitutable like that.


created_at: 2025-03-25T05:35:54Z
author_association: MEMBER
diff_hunk: @@ -9,5 +9,5 @@ cluster: bar
 type: ResolvedRefs
 status: 'False'
 reason: Invalid
-message: "Resource of kind \"KafkaService\" in group \"kroxylicious.io\" named\
-    \ \"barref\" does not exist."
\ No newline at end of file
+message: "Resource kafkaservice.kroxylicious.io/barref in namespace: proxy-ns was\
path: kroxylicious-operator/src/test/resources/DerivedResourcesTest/two-clusters-with-colliding-ingress-and-unresolved-filter/cond-ResolvedRefs-bar.yaml
user: login: tombentley
body: When I see it here like this, the colon after namespace looks really weird. It's not punctuating the sentence in a natural way at all. I think we should either drop it, or enclose the namespace in quotes.
created_at: 2025-03-25T05:38:14Z
author_association: MEMBER
diff_hunk: @@ -40,4 +45,14 @@ public final boolean equals(Object obj) {
                 && Objects.equals(getKind(), other.getKind())
                 && Objects.equals(getName(), other.getName());
     }
+
+    @Override
+    public String toString() {
+        String group = getGroup().isEmpty() ? "" : "." + getGroup();
+        return getKind().toLowerCase(Locale.getDefault()) + group + "/" + getName();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/LocalRef.java
user: login: k-wall
body: Use Locale.ROOT, I don't think it is right to consider kinds etc as natural language.
created_at: 2025-03-25T15:22:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
+
+import java.util.Objects;
+
+public class NamespacedRef<T> extends LocalRef<T> {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: robobario
body: yep I had the same feeling, LocalRef has docs stating it's equality contract and NamespacedRef has a different contract. So I've extracted a `Ref` interface they both implement.

`NamespacedRef` will now only equal another `NamespacedRef` with matching group, kind, name, namespace.

Feels a bit overcooked, maybe we shouldn't touch the Ref hierarchy in this PR
created_at: 2025-03-25T20:53:40Z
author_association: MEMBER
diff_hunk: @@ -46,6 +44,10 @@ public final boolean equals(Object obj) {
                 && Objects.equals(getName(), other.getName());
     }
 
+    public NamespacedRef<T> inNamespaceOf(HasMetadata resource) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/LocalRef.java
user: login: tombentley
body: Javadoc
created_at: 2025-03-25T22:22:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.slug;
+
+/**
+ * Abstraction for references in one kubernetes resource to some kubernetes resource in a specific namespace.
+ * Two LocalRefs are equal iff they have the same group, kind, name and namespace
+ */
+public class NamespacedRef<T> implements Ref<T> {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: tombentley
body: Can this be a `record`?
created_at: 2025-03-25T22:23:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: tombentley
body: If you think about it `NamespacedRef` shouldn't be in the `api.common` package, because it should never be used in a CRD (that's the role of the `GlobalRef` I mentioned yesterday). This one is an internal detail of the operator. So let's move it there. We'll have to get rid of `LocalRef.inNamespaceOf()`, but it's just as easily made a static factory method of this class, i.e. `NamespacedRef.namespaced(LocalRef<T> ref, HasMetadata resource)`.
created_at: 2025-03-25T22:27:59Z
author_association: MEMBER
diff_hunk: @@ -186,7 +187,7 @@ static List<Arguments> shouldSetResolvedRefsToTrueOrFalse() {
                                 .withStatus(Condition.Status.FALSE).endCondition().endStatus().build()),
                         (Consumer<ConditionAssert>) ca -> ca.isResolvedRefsFalse(
                                 VirtualKafkaClusterReconciler.TRANSITIVELY_REFERENCED_RESOURCES_NOT_FOUND,
-                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter")));
+                                "spec.filterRefs references kafkaprotocolfilter.filter.kroxylicious.io/my-filter in namespace 'my-namespace'")));
path: kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconcilerTest.java
user: login: tombentley
body: This enough unresolvable references we're gonna end up with the really big condition message with a lot of repeated substrings! We might eventually want to limit the number of things we report about, but it's definitely not something to worry about right now.
created_at: 2025-03-25T22:34:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.slug;
+
+/**
+ * Abstraction for references in one kubernetes resource to some kubernetes resource in a specific namespace.
+ * Two LocalRefs are equal iff they have the same group, kind, name and namespace
+ */
+public class NamespacedRef<T> implements Ref<T> {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: robobario
body: If we do that it would be convenient if we went from `getKind()` to `kind()` etc on the Ref interface so that we can have a record that implements Ref more easily.

I'd move from having a delegate field to pulling `group`, `kind`, `name` into fields of the record.
created_at: 2025-03-25T23:25:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: robobario
body: yep can do.
created_at: 2025-03-25T23:27:23Z
author_association: MEMBER
diff_hunk: @@ -46,6 +44,10 @@ public final boolean equals(Object obj) {
                 && Objects.equals(getName(), other.getName());
     }
 
+    public NamespacedRef<T> inNamespaceOf(HasMetadata resource) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/LocalRef.java
user: login: robobario
body: done (on the new static method in NamespacedRef since I've moved it out of the common package)
created_at: 2025-03-26T01:47:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.kubernetes.api.common;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.slug;
+
+/**
+ * Abstraction for references in one kubernetes resource to some kubernetes resource in a specific namespace.
+ * Two LocalRefs are equal iff they have the same group, kind, name and namespace
+ */
+public class NamespacedRef<T> implements Ref<T> {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/api/common/NamespacedRef.java
user: login: robobario
body: done, though I've left renaming the methods of the `ref` interface for now to keep the changes a bit smaller.
created_at: 2025-03-26T01:48:21Z
author_association: MEMBER
diff_hunk: @@ -173,12 +173,13 @@ private static boolean hasAnyResolvedRefsFalse(List<Condition> conditions) {
     @NonNull
     private static <R extends CustomResource<?, ?>> Stream<String> refsMessage(
                                                                                String prefix,
-                                                                               Class<R> crdClass,
+                                                                               VirtualKafkaCluster cluster,
                                                                                TreeSet<? extends LocalRef<R>> refs) {
         return refs.isEmpty() ? Stream.of()
                 : Stream.of(
                         prefix + refs.stream()
-                                .map(ref -> ResourcesUtil.slug(crdClass, ref.getName()))
+                                .map(ref -> NamespacedRef.inNamespaceOf(ref, cluster))
+                                .map(NamespacedRef::toString)
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
user: login: tombentley
body: TBH I look at this and think "this could just be a function". It's not really clear right now that we'll ever need to manipulate `NamespaceRefs` as first-class objects. 
created_at: 2025-03-26T04:39:21Z
author_association: MEMBER
diff_hunk: @@ -173,12 +173,13 @@ private static boolean hasAnyResolvedRefsFalse(List<Condition> conditions) {
     @NonNull
     private static <R extends CustomResource<?, ?>> Stream<String> refsMessage(
                                                                                String prefix,
-                                                                               Class<R> crdClass,
+                                                                               VirtualKafkaCluster cluster,
                                                                                TreeSet<? extends LocalRef<R>> refs) {
         return refs.isEmpty() ? Stream.of()
                 : Stream.of(
                         prefix + refs.stream()
-                                .map(ref -> ResourcesUtil.slug(crdClass, ref.getName()))
+                                .map(ref -> NamespacedRef.inNamespaceOf(ref, cluster))
+                                .map(NamespacedRef::toString)
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
user: login: robobario
body: Yeah I think so too, I'll undo the Ref changes
created_at: 2025-03-26T18:57:36Z
author_association: MEMBER
diff_hunk: @@ -173,12 +173,13 @@ private static boolean hasAnyResolvedRefsFalse(List<Condition> conditions) {
     @NonNull
     private static <R extends CustomResource<?, ?>> Stream<String> refsMessage(
                                                                                String prefix,
-                                                                               Class<R> crdClass,
+                                                                               VirtualKafkaCluster cluster,
                                                                                TreeSet<? extends LocalRef<R>> refs) {
         return refs.isEmpty() ? Stream.of()
                 : Stream.of(
                         prefix + refs.stream()
-                                .map(ref -> ResourcesUtil.slug(crdClass, ref.getName()))
+                                .map(ref -> NamespacedRef.inNamespaceOf(ref, cluster))
+                                .map(NamespacedRef::toString)
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
user: login: tombentley
body: Sorry for the wasted effort :-(
created_at: 2025-03-26T19:21:50Z
author_association: MEMBER
diff_hunk: @@ -173,12 +173,13 @@ private static boolean hasAnyResolvedRefsFalse(List<Condition> conditions) {
     @NonNull
     private static <R extends CustomResource<?, ?>> Stream<String> refsMessage(
                                                                                String prefix,
-                                                                               Class<R> crdClass,
+                                                                               VirtualKafkaCluster cluster,
                                                                                TreeSet<? extends LocalRef<R>> refs) {
         return refs.isEmpty() ? Stream.of()
                 : Stream.of(
                         prefix + refs.stream()
-                                .map(ref -> ResourcesUtil.slug(crdClass, ref.getName()))
+                                .map(ref -> NamespacedRef.inNamespaceOf(ref, cluster))
+                                .map(NamespacedRef::toString)
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/VirtualKafkaClusterReconciler.java
user: login: robobario
body: No problem, pushed up with the Ref changes removed and added `namespacedSlug(LocalRef<?> ref, HasMetadata resource)` to `ResourcesUtil`
created_at: 2025-03-28T00:42:25Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,9 @@ public static <T extends HasMetadata> LocalRef<T> toLocalRef(T ref) {
     }
 
     public static String group(HasMetadata resource) {
+        if (!resource.getApiVersion().contains("/")) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: robobario
body: I found that using `toLocalRef` on a Secret failed while writing the unit tests, for the built in types the `apiVersion` is just the string `v1` and if you get the `group` via `HasMetadata#getGroup()` it's an empty string. So I've gone with empty string too.
created_at: 2025-03-28T00:45:03Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,9 @@ public static <T extends HasMetadata> LocalRef<T> toLocalRef(T ref) {
     }
 
     public static String group(HasMetadata resource) {
+        if (!resource.getApiVersion().contains("/")) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: OK, that makes sense. Kube uses the empty group for some core APIs (but in some contexts they get called the `core` group, so it's not always clear what to expect).
created_at: 2025-03-28T00:54:02Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,9 @@ public static <T extends HasMetadata> LocalRef<T> toLocalRef(T ref) {
     }
 
     public static String group(HasMetadata resource) {
+        if (!resource.getApiVersion().contains("/")) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: Maybe add a comment?
created_at: 2025-03-28T00:54:13Z
author_association: MEMBER
diff_hunk: @@ -452,15 +455,13 @@ static Condition resolvedRefsUnknown(Clock clock,
         return newUnknownCondition(clock, observedGenerationSource, Condition.Type.ResolvedRefs, e);
     }
 
-    static String slug(String singular, String group, String name) {
-        return singular + "." + group + "/" + name;
+    public static String slug(String singular, String group, String name) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: If we really want to include namespace in all the places then we should make this `private`
created_at: 2025-03-28T00:56:01Z
author_association: MEMBER
diff_hunk: @@ -452,15 +455,13 @@ static Condition resolvedRefsUnknown(Clock clock,
         return newUnknownCondition(clock, observedGenerationSource, Condition.Type.ResolvedRefs, e);
     }
 
-    static String slug(String singular, String group, String name) {
-        return singular + "." + group + "/" + name;
+    public static String slug(String singular, String group, String name) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: robobario
body: done
created_at: 2025-03-28T03:05:18Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,9 @@ public static <T extends HasMetadata> LocalRef<T> toLocalRef(T ref) {
     }
 
     public static String group(HasMetadata resource) {
+        if (!resource.getApiVersion().contains("/")) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: robobario
body: done
created_at: 2025-03-28T03:05:37Z
author_association: MEMBER
diff_hunk: @@ -452,15 +456,15 @@ static Condition resolvedRefsUnknown(Clock clock,
         return newUnknownCondition(clock, observedGenerationSource, Condition.Type.ResolvedRefs, e);
     }
 
-    static String slug(String singular, String group, String name) {
-        return singular + "." + group + "/" + name;
+    public static @NonNull String namespacedSlug(LocalRef<?> ref, HasMetadata resource) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ResourcesUtil.java
user: login: tombentley
body: I suppose we should call this one `slug`, because namespaces are standard, and the private one could be `localSlug`.
created_at: 2025-03-28T04:01:15Z
author_association: MEMBER

PR comments:
user: login: robobario
created_at: 2025-03-25T21:17:11Z
body: Have pushed up a refactor using `NamespacedRef` in the VKCReconciler so we uniformly use `NamespacedRef` to build up the message rather than sometimes using the CR annotations and sometimes ref.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-03-28T04:35:32Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=kroxylicious_kroxylicious&pullRequest=1994) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=1994&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=1994&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=1994&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=1994&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=1994&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=kroxylicious_kroxylicious&pullRequest=1994)


author_association: NONE