Repository: kroxylicious/kroxylicious
Pull request #2210: operator: refactor ingress model
Author: login: robobario
Created: 2025-05-20T22:37:33Z | Merged: 2025-05-26T21:54:12Z

### Type of change

- Refactoring

### Description

This is preparing for adding a second ingress type.

The Ingress naming in the network model area is confusing. We already have a wealth of classes named Ingress, plus the ingress concept in kubernetes causing confusion. Instead we rename to NetworkingModel. The networking model is an arrangement of kubernetes network resources, container ports and proxy configuration that are plumbed together such that clients can connect to a VirtualKafkaCluster via a KafkaProxyIngress.

* renames ProxyIngressModel to ProxyNetworkingModel
* renames VirtualClusterIngressModel to ClusterNetworkingModel
* renames IngressInstance to ClusterIngressNetworkingModel to match the other model class naming for this logical layer.
* IngressAllocator is renamed to NetworkingPlanner (the component that plans a NetworkingModel from the custom resources provided)
* moves downstream TLS to ClusterIngressNetworkingModel, this saves having to re-correlate the ingress back to a TLS blob pulled from the VKC ingresses. We can associate it as part of networking model building.
* inlines IngressDefinitionBuilder into NetworkingPlanner, this was the only client calling a couple of static methods.
* Makes ClusterIngressNetworkingModel responsible for supplying a NodeIdentificationStrategy so that the new loadbalancer model will be able to supply an SNI strategy in future
* Introduces a static bootstrapServiceName method on ClusterIPClusterIngressNetworkingModel to clarify the link between the service name and the VKC ingress status, where we are loading the Service as a secondary resource by name.
* Reduces visibility of IngressDefinition, as it's just a means to an ends of obtaining an ClusterIngressNetworkingModel once the ports are allocated. It is made private to the NetworkingPlanner

### 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`.



Diff:
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterServiceDependentResource.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterServiceDependentResource.java
index debac0e560..a56e893dce 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterServiceDependentResource.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ClusterServiceDependentResource.java
@@ -18,7 +18,7 @@
 
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.toByNameMap;
@@ -51,11 +51,11 @@ public Map<String, Service> desiredResources(
                                                  Context<KafkaProxy> context) {
         KafkaProxyContext kafkaProxyContext = KafkaProxyContext.proxyContext(context);
         var model = kafkaProxyContext.model();
-        Stream<Service> serviceStream = model.clustersWithValidIngresses().stream()
+        Stream<Service> serviceStream = model.clustersWithValidNetworking().stream()
                 .map(ClusterResolutionResult::cluster)
                 .filter(cluster -> !kafkaProxyContext.isBroken(cluster))
-                .flatMap(cluster -> model.ingressModel().clusterIngressModel(cluster)
-                        .map(ProxyIngressModel.VirtualClusterIngressModel::services)
+                .flatMap(cluster -> model.networkingModel().clusterIngressModel(cluster)
+                        .map(ProxyNetworkingModel.ClusterNetworkingModel::services)
                         .orElse(Stream.empty()));
         return serviceStream.collect(toByNameMap());
     }
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyContext.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyContext.java
index 2a851e7bb9..aaf3f018ef 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyContext.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyContext.java
@@ -63,7 +63,7 @@ static KafkaProxyContext proxyContext(Context<KafkaProxy> context) {
     boolean isBroken(VirtualKafkaCluster cluster) {
         var fullyResolved = model().resolutionResult().fullyResolvedClustersInNameOrder().stream().map(ResourcesUtil::name).collect(Collectors.toSet());
         return !fullyResolved.contains(ResourcesUtil.name(cluster))
-                || !model().ingressModel().clusterIngressModel(cluster).stream()
+                || !model().networkingModel().clusterIngressModel(cluster).stream()
                         .allMatch(ingressModel -> ingressModel.ingressExceptions().isEmpty());
 
     }
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconciler.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconciler.java
index 9fa9e3d9e4..2eb164c355 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconciler.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/KafkaProxyReconciler.java
@@ -21,7 +21,6 @@
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
 import org.slf4j.Logger;
@@ -59,21 +58,18 @@
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaServiceSpec;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaClusterSpec;
-import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
-import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.Ingresses;
 import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls.TlsClientAuthentication;
 import io.kroxylicious.kubernetes.filter.api.v1alpha1.KafkaProtocolFilter;
 import io.kroxylicious.kubernetes.filter.api.v1alpha1.KafkaProtocolFilterSpec;
 import io.kroxylicious.kubernetes.operator.model.ProxyModel;
 import io.kroxylicious.kubernetes.operator.model.ProxyModelBuilder;
-import io.kroxylicious.kubernetes.operator.model.ingress.ClusterIPIngressDefinition;
-import io.kroxylicious.kubernetes.operator.model.ingress.ClusterIPIngressDefinition.ClusterIPIngressInstance;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ClusterIngressNetworkingModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.kubernetes.operator.resolver.ResolutionResult;
 import io.kroxylicious.proxy.config.Configuration;
 import io.kroxylicious.proxy.config.NamedFilterDefinition;
-import io.kroxylicious.proxy.config.NamedRange;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
 import io.kroxylicious.proxy.config.PortIdentifiesNodeIdentificationStrategy;
 import io.kroxylicious.proxy.config.TargetCluster;
 import io.kroxylicious.proxy.config.VirtualCluster;
@@ -89,7 +85,6 @@
 import io.kroxylicious.proxy.config.tls.TlsClientAuth;
 import io.kroxylicious.proxy.config.tls.TrustProvider;
 import io.kroxylicious.proxy.config.tls.TrustStore;
-import io.kroxylicious.proxy.service.HostPort;
 import io.kroxylicious.proxy.tag.VisibleForTesting;
 
 import edu.umd.cs.findbugs.annotations.Nullable;
@@ -97,7 +92,6 @@
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.namespace;
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.toLocalRef;
-import static java.lang.Math.toIntExact;
 
 // @formatter:off
 @Workflow(dependents = {
@@ -158,7 +152,7 @@ public void initContext(
                             Context<KafkaProxy> context) {
         ProxyModelBuilder proxyModelBuilder = ProxyModelBuilder.contextBuilder();
         ProxyModel model = proxyModelBuilder.build(proxy, context);
-        boolean hasClusters = !model.clustersWithValidIngresses().isEmpty();
+        boolean hasClusters = !model.clustersWithValidNetworking().isEmpty();
         ConfigurationFragment<Configuration> fragment = null;
         if (hasClusters) {
             fragment = generateProxyConfig(model);
@@ -207,17 +201,17 @@ private ConfigurationFragment<Configuration> generateProxyConfig(ProxyModel mode
     }
 
     private static List<ConfigurationFragment<VirtualCluster>> buildVirtualClusters(Set<String> successfullyBuiltFilterNames, ProxyModel model) {
-        return model.clustersWithValidIngresses().stream()
+        return model.clustersWithValidNetworking().stream()
                 .filter(cluster -> cluster.filterResolutionResults().stream().allMatch(
                         filterResult -> successfullyBuiltFilterNames.contains(filterDefinitionName(filterResult.reference()))))
-                .map(cluster -> buildVirtualCluster(cluster, model.ingressModel()))
+                .map(cluster -> buildVirtualCluster(cluster, model.networkingModel()))
                 .toList();
     }
 
     private List<ConfigurationFragment<NamedFilterDefinition>> buildFilterDefinitions(ProxyModel model) {
         List<ConfigurationFragment<NamedFilterDefinition>> filterDefinitions = new ArrayList<>();
         Set<NamedFilterDefinition> uniqueValues = new HashSet<>();
-        for (ClusterResolutionResult cluster : model.clustersWithValidIngresses()) {
+        for (ClusterResolutionResult cluster : model.clustersWithValidNetworking()) {
             for (ConfigurationFragment<NamedFilterDefinition> namedFilterDefinitionAndFiles : filterDefinitions(cluster)) {
                 if (uniqueValues.add(namedFilterDefinitionAndFiles.fragment())) {
                     filterDefinitions.add(namedFilterDefinitionAndFiles);
@@ -261,15 +255,12 @@ private SecureConfigInterpolator.InterpolationResult interpolateConfig(KafkaProt
     }
 
     private static ConfigurationFragment<VirtualCluster> buildVirtualCluster(ClusterResolutionResult cluster,
-                                                                             ProxyIngressModel ingressModel) {
+                                                                             ProxyNetworkingModel ingressModel) {
 
-        ProxyIngressModel.VirtualClusterIngressModel virtualClusterIngressModel = ingressModel.clusterIngressModel(cluster.cluster()).orElseThrow();
-        var gatewayFragments = ConfigurationFragment.reduce(virtualClusterIngressModel.ingressModels().stream()
-                .map(ProxyIngressModel.IngressModel::ingressInstance)
-                .map(ingressInstance -> {
-                    var instance = ((ClusterIPIngressInstance) ingressInstance);
-                    return buildVirtualClusterGateway(instance);
-                }).toList());
+        ProxyNetworkingModel.ClusterNetworkingModel clusterNetworkingModel = ingressModel.clusterIngressModel(cluster.cluster()).orElseThrow();
+        var gatewayFragments = ConfigurationFragment.reduce(clusterNetworkingModel.clusterIngressNetworkingModelResults().stream()
+                .map(ProxyNetworkingModel.ClusterIngressNetworkingModelResult::clusterIngressNetworkingModel)
+                .map(KafkaProxyReconciler::buildVirtualClusterGateway).toList());
 
         KafkaService kafkaServiceRef = cluster.serviceResolutionResult().referentResource();
         var virtualClusterConfigurationFragment = gatewayFragments
@@ -287,31 +278,25 @@ private static ConfigurationFragment<VirtualCluster> buildVirtualCluster(Cluster
                 (virtualCluster, gateways) -> virtualCluster);
     }
 
-    public static ConfigurationFragment<VirtualClusterGateway> buildVirtualClusterGateway(ClusterIPIngressInstance instance) {
-        List<NamedRange> portRanges = IntStream.range(0, instance.definition().nodeIdRanges().size()).mapToObj(i -> {
-            NodeIdRanges range = instance.definition().nodeIdRanges().get(i);
-            String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
-            return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
-        }).toList();
+    public static ConfigurationFragment<VirtualClusterGateway> buildVirtualClusterGateway(ClusterIngressNetworkingModel gateway) {
 
-        var ingressName = instance.definition().resource().getMetadata().getName();
-        var tlsConfigFragment = instance.definition().cluster().getSpec().getIngresses().stream()
-                .filter(i -> ingressName.equals(i.getIngressRef().getName()))
-                .map(Ingresses::getTls)
-                .filter(Objects::nonNull)
-                .findFirst()
+        var tlsConfigFragment = gateway.downstreamTls()
                 .map(KafkaProxyReconciler::buildTlsFragment)
                 .orElse(ConfigurationFragment.empty());
 
         var volumes = tlsConfigFragment.volumes();
         var mounts = tlsConfigFragment.mounts();
 
-        return new ConfigurationFragment<>(new VirtualClusterGateway("default",
-                new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", instance.firstIdentifyingPort()),
-                        qualifiedServiceHost(instance.definition()), null,
-                        portRanges),
-                null,
-                tlsConfigFragment.fragment()), volumes, mounts);
+        NodeIdentificationStrategy nodeIdentificationStrategy = gateway.nodeIdentificationStrategy();
+        if (nodeIdentificationStrategy instanceof PortIdentifiesNodeIdentificationStrategy port) {
+            return new ConfigurationFragment<>(new VirtualClusterGateway("default",
+                    port,
+                    null,
+                    tlsConfigFragment.fragment()), volumes, mounts);
+        }
+        else {
+            throw new IllegalStateException("Unsupported node identification strategy: " + nodeIdentificationStrategy);
+        }
     }
 
     private static ConfigurationFragment<Optional<Tls>> buildTlsFragment(io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls ingressTls) {
@@ -325,10 +310,6 @@ private static ConfigurationFragment<Optional<Tls>> buildTlsFragment(io.kroxylic
                                 buildProtocols(ingressTls.getProtocols()).orElse(null))));
     }
 
-    private static String qualifiedServiceHost(ClusterIPIngressDefinition definition) {
-        return name(definition.cluster()) + "-" + name(definition.resource()) + "." + namespace(definition.cluster()) + ".svc.cluster.local";
-    }
-
     private static ConfigurationFragment<TargetCluster> buildTargetCluster(KafkaService kafkaServiceRef) {
         return buildTargetClusterTls(kafkaServiceRef)
                 .map(tls -> new TargetCluster(kafkaServiceRef.getSpec().getBootstrapServers(), tls));
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyConfigStateDependentResource.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyConfigStateDependentResource.java
index 7d6d5d805f..e5663107e9 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyConfigStateDependentResource.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyConfigStateDependentResource.java
@@ -19,8 +19,8 @@
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
 import io.kroxylicious.kubernetes.operator.model.ProxyModel;
-import io.kroxylicious.kubernetes.operator.model.ingress.IngressConflictException;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.IngressConflictException;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 
 import static io.kroxylicious.kubernetes.operator.Labels.standardLabels;
@@ -70,13 +70,13 @@ protected ConfigMap desired(KafkaProxy primary,
     }
 
     private static void addAcceptedConditions(VirtualKafkaClusterStatusFactory statusFactory, ProxyModel proxyModel, ProxyConfigStateData data) {
-        var model = proxyModel.ingressModel();
-        for (ProxyIngressModel.VirtualClusterIngressModel virtualClusterIngressModel : model.clusters()) {
-            VirtualKafkaCluster cluster = virtualClusterIngressModel.cluster();
+        var model = proxyModel.networkingModel();
+        for (ProxyNetworkingModel.ClusterNetworkingModel clusterNetworkingModel : model.clusterNetworkingModels()) {
+            VirtualKafkaCluster cluster = clusterNetworkingModel.cluster();
 
             VirtualKafkaCluster patch;
-            if (!virtualClusterIngressModel.ingressExceptions().isEmpty()) {
-                IngressConflictException first = virtualClusterIngressModel.ingressExceptions().iterator().next();
+            if (!clusterNetworkingModel.ingressExceptions().isEmpty()) {
+                IngressConflictException first = clusterNetworkingModel.ingressExceptions().iterator().next();
                 patch = statusFactory.newFalseConditionStatusPatch(cluster,
                         Condition.Type.Accepted, Condition.REASON_INVALID,
                         "Ingress(es) [" + first.getIngressName() + "] of cluster conflicts with another ingress");
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResource.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResource.java
index bfb6f42d8f..c24c066af4 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResource.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResource.java
@@ -34,7 +34,7 @@
 import io.kroxylicious.kubernetes.operator.checksum.Crc32ChecksumGenerator;
 import io.kroxylicious.kubernetes.operator.checksum.MetadataChecksumGenerator;
 import io.kroxylicious.kubernetes.operator.model.ProxyModel;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.proxy.tag.VisibleForTesting;
 
@@ -90,7 +90,7 @@ protected Deployment desired(KafkaProxy primary,
                     .editOrNewSelector()
                     .withMatchLabels(deploymentSelector(primary))
                     .endSelector()
-                    .withTemplate(podTemplate(primary, kafkaProxyContext, model.ingressModel(), model.clustersWithValidIngresses(), checksum))
+                    .withTemplate(podTemplate(primary, kafkaProxyContext, model.networkingModel(), model.clustersWithValidNetworking(), checksum))
                 .endSpec()
                 .build();
         // @formatter:on
@@ -102,7 +102,7 @@ String checksumFor(KafkaProxy primary, Context<KafkaProxy> context, ProxyModel m
                 .get(MetadataChecksumGenerator.CHECKSUM_CONTEXT_KEY, MetadataChecksumGenerator.class)
                 .orElse(new Crc32ChecksumGenerator());
         checksumGenerator.appendMetadata(primary);
-        model.clustersWithValidIngresses().stream().map(ClusterResolutionResult::cluster).forEach(checksumGenerator::appendMetadata);
+        model.clustersWithValidNetworking().stream().map(ClusterResolutionResult::cluster).forEach(checksumGenerator::appendMetadata);
         String encoded = checksumGenerator.encode();
 
         if (LOGGER.isDebugEnabled()) {
@@ -128,7 +128,7 @@ public static Map<String, String> podLabels(KafkaProxy primary) {
 
     private PodTemplateSpec podTemplate(KafkaProxy primary,
                                         KafkaProxyContext kafkaProxyContext,
-                                        ProxyIngressModel ingressModel,
+                                        ProxyNetworkingModel ingressModel,
                                         List<ClusterResolutionResult> clusterResolutionResults,
                                         String checksum) {
         PodTemplateSpecFluent<PodTemplateSpecBuilder>.MetadataNested<PodTemplateSpecBuilder> metadataBuilder = new PodTemplateSpecBuilder()
@@ -162,7 +162,7 @@ private PodTemplateSpec podTemplate(KafkaProxy primary,
     }
 
     private Container proxyContainer(KafkaProxyContext kafkaProxyContext,
-                                     ProxyIngressModel ingressModel,
+                                     ProxyNetworkingModel ingressModel,
                                      List<ClusterResolutionResult> clusterResolutionResults) {
         // @formatter:off
          var containerBuilder = new ContainerBuilder()
@@ -202,10 +202,8 @@ private Container proxyContainer(KafkaProxyContext kafkaProxyContext,
         // broker ports
         clusterResolutionResults.forEach(resolutionResult -> {
             if (!kafkaProxyContext.isBroken(resolutionResult.cluster())) {
-                ProxyIngressModel.VirtualClusterIngressModel virtualClusterIngressModel = ingressModel.clusterIngressModel(resolutionResult.cluster()).orElseThrow();
-                for (ProxyIngressModel.IngressModel ingress : virtualClusterIngressModel.ingressModels()) {
-                    ingress.proxyContainerPorts().forEach(containerBuilder::addToPorts);
-                }
+                ProxyNetworkingModel.ClusterNetworkingModel clusterNetworkingModel = ingressModel.clusterIngressModel(resolutionResult.cluster()).orElseThrow();
+                clusterNetworkingModel.registerProxyContainerPorts(containerBuilder::addToPorts);
             }
         });
         return containerBuilder.build();
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 e23a899096..3cef868f3b 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
@@ -71,6 +71,7 @@
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.namespace;
 import static io.kroxylicious.kubernetes.operator.ResourcesUtil.toLocalRef;
+import static io.kroxylicious.kubernetes.operator.model.networking.ClusterIPClusterIngressNetworkingModel.bootstrapServiceName;
 
 /**
  * Reconciles a {@link VirtualKafkaCluster} by checking whether the resources
@@ -403,7 +404,8 @@ public List<EventSource<?, VirtualKafkaCluster>> prepareEventSources(EventSource
                     return cluster.getSpec().getIngresses()
                             .stream()
                             .map(Ingresses::getIngressRef)
-                            .flatMap(ir -> ResourcesUtil.localRefAsResourceId(cluster, new AnyLocalRefBuilder().withName(name + "-" + ir.getName()).build()).stream())
+                            .flatMap(ir -> ResourcesUtil
+                                    .localRefAsResourceId(cluster, new AnyLocalRefBuilder().withName(bootstrapServiceName(cluster, ir.getName())).build()).stream())
                             .collect(Collectors.toSet());
                 })
                 .withSecondaryToPrimaryMapper(kubenetesService -> Optional.of(kubenetesService)
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModel.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModel.java
index 00ad991d83..91ec50902d 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModel.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModel.java
@@ -8,7 +8,7 @@
 
 import java.util.List;
 
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
 
@@ -17,11 +17,11 @@
  * During a reconciliation we take the raw Custom Resources, resolve the graph of references between them
  * and allocate shared resources, like container ports to various components.
  * @param resolutionResult the resolved dependencies for this reconciliation
- * @param ingressModel the ingress model for this reconciliation
- * @param clustersWithValidIngresses virtual kafka clusters that are fully resolved and have valid ingresses
+ * @param networkingModel the networking model for this reconciliation
+ * @param clustersWithValidNetworking virtual kafka clusters that are fully resolved and have valid networking models
  */
 public record ProxyModel(ProxyResolutionResult resolutionResult,
-                         ProxyIngressModel ingressModel,
-                         List<ClusterResolutionResult> clustersWithValidIngresses) {
+                         ProxyNetworkingModel networkingModel,
+                         List<ClusterResolutionResult> clustersWithValidNetworking) {
 
 }
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModelBuilder.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModelBuilder.java
index 8b3e5ba5b8..ba65c6b224 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModelBuilder.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModelBuilder.java
@@ -15,8 +15,8 @@
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
 import io.kroxylicious.kubernetes.operator.ResourcesUtil;
 import io.kroxylicious.kubernetes.operator.StaleReferentStatusException;
-import io.kroxylicious.kubernetes.operator.model.ingress.IngressAllocator;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.NetworkingPlanner;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.kubernetes.operator.resolver.DependencyResolver;
 import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
@@ -43,7 +43,7 @@ public ProxyModel build(KafkaProxy primary, Context<KafkaProxy> context) {
         }
         // to try and produce the most stable allocation of ports we can, we attempt to consider all clusters in the ingress allocation, even those
         // that we know are unacceptable due to unresolved dependencies.
-        ProxyIngressModel ingressModel = IngressAllocator.allocateProxyIngressModel(primary, resolutionResult);
+        ProxyNetworkingModel ingressModel = NetworkingPlanner.planNetworking(primary, resolutionResult);
         List<ClusterResolutionResult> clustersWithValidIngresses = resolutionResult.allResolutionResultsInClusterNameOrder()
                 .filter(clusterResolutionResult -> clusterResolutionResult.allReferentsFullyResolved() && !ResourcesUtil.hasFreshResolvedRefsFalseCondition(
                         clusterResolutionResult.cluster()))
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
deleted file mode 100644
index ad3aa47dbd..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.IntStream;
-import java.util.stream.Stream;
-
-import io.fabric8.kubernetes.api.model.ContainerPort;
-import io.fabric8.kubernetes.api.model.ContainerPortBuilder;
-import io.fabric8.kubernetes.api.model.IntOrString;
-import io.fabric8.kubernetes.api.model.ServiceBuilder;
-
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
-import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
-import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
-import io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource;
-import io.kroxylicious.kubernetes.operator.ResourcesUtil;
-import io.kroxylicious.proxy.tag.VisibleForTesting;
-
-import static io.kroxylicious.kubernetes.operator.Labels.standardLabels;
-import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
-import static io.kroxylicious.kubernetes.operator.ResourcesUtil.namespace;
-import static java.lang.Math.toIntExact;
-
-public record ClusterIPIngressDefinition(
-                                         KafkaProxyIngress resource,
-                                         VirtualKafkaCluster cluster,
-                                         KafkaProxy primary,
-                                         List<NodeIdRanges> nodeIdRanges)
-        implements IngressDefinition {
-
-    public ClusterIPIngressDefinition {
-        Objects.requireNonNull(resource);
-        Objects.requireNonNull(cluster);
-        Objects.requireNonNull(primary);
-        Objects.requireNonNull(nodeIdRanges);
-        if (nodeIdRanges.isEmpty()) {
-            throw new IllegalArgumentException("nodeIdRanges cannot be empty");
-        }
-    }
-
-    public record ClusterIPIngressInstance(ClusterIPIngressDefinition definition, int firstIdentifyingPort, int lastIdentifyingPort)
-            implements IngressInstance {
-        public ClusterIPIngressInstance {
-            Objects.requireNonNull(definition);
-            sanityCheckPortRange(definition, firstIdentifyingPort, lastIdentifyingPort);
-        }
-
-        @Override
-        public Stream<ServiceBuilder> services() {
-            var serviceSpecBuilder = new ServiceBuilder()
-                    .withNewMetadata()
-                    .withName(serviceName(definition.cluster, definition.resource))
-                    .withNamespace(namespace(definition.cluster))
-                    .addToLabels(standardLabels(definition.primary))
-                    .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(definition.primary)).endOwnerReference()
-                    .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(definition.cluster)).endOwnerReference()
-                    .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(definition.resource)).endOwnerReference()
-                    .endMetadata()
-                    .withNewSpec()
-                    .withSelector(ProxyDeploymentDependentResource.podLabels(definition.primary));
-            for (int i = firstIdentifyingPort; i <= lastIdentifyingPort; i++) {
-                serviceSpecBuilder = serviceSpecBuilder
-                        .addNewPort()
-                        .withName(name(definition.cluster) + "-" + i)
-                        .withPort(i)
-                        .withTargetPort(new IntOrString(i))
-                        .withProtocol("TCP")
-                        .endPort();
-            }
-            return Stream.of(serviceSpecBuilder.endSpec());
-        }
-
-        private static void sanityCheckPortRange(ClusterIPIngressDefinition definition, int startPortInc, int endPortInc) {
-            int requiredPorts = definition.numIdentifyingPortsRequired();
-            if ((endPortInc - startPortInc + 1) != requiredPorts) {
-                throw new IllegalArgumentException("require " + requiredPorts + " ports");
-            }
-        }
-
-        @Override
-        public Stream<ContainerPort> proxyContainerPorts() {
-            Stream<ContainerPort> bootstrapPort = Stream.of(new ContainerPortBuilder().withContainerPort(firstIdentifyingPort)
-                    .withName(firstIdentifyingPort + "-bootstrap").build());
-            Stream<ContainerPort> ingressNodePorts = IntStream.range(0, definition().nodeCount()).mapToObj(
-                    nodeIdx -> {
-                        int port = firstIdentifyingPort + nodeIdx + 1;
-                        return new ContainerPortBuilder().withContainerPort(port)
-                                .withName(port + "-node").build();
-                    });
-            return Stream.concat(bootstrapPort, ingressNodePorts);
-        }
-    }
-
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
-        Objects.requireNonNull(cluster);
-        Objects.requireNonNull(resource);
-        return name(cluster) + "-" + name(resource);
-    }
-
-    @Override
-    public ClusterIPIngressInstance createInstance(int firstIdentifyingPort, int lastIdentifyingPort) {
-        return new ClusterIPIngressInstance(this, firstIdentifyingPort, lastIdentifyingPort);
-    }
-
-    @Override
-    public int numIdentifyingPortsRequired() {
-        // one per broker plus the bootstrap
-        return nodeCount() + 1;
-    }
-
-    // note: we use CRD validation to enforce end >= start at the apiserver level
-    private int nodeCount() {
-        return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
-    }
-
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressAllocator.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressAllocator.java
deleted file mode 100644
index fe592953f7..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressAllocator.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Stream;
-
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
-import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
-import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
-
-import static io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource.PROXY_PORT_START;
-import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
-
-/**
- * IngressAllocator is responsible for:
- * <ul>
- *     <li>building a ProxyIngressModel with logical ingress model classes created per VirtualCluster</li>
- *     <li>allocating identifying container ports to ingresses</li>
- *     <li>deciding which ingress instances are in conflict with each other and selecting which to accept</li>
- * </ul>
- */
-public class IngressAllocator {
-    private IngressAllocator() {
-    }
-
-    /**
-     * Allocates a ProxyIngressModel. The aim is to deterministically produce a model of the ports that will
-     * be used by the proxy container. We want this to be as stable as possible, so we will allocate ports
-     * to potentially unacceptable virtual clusters if we can.
-     *
-     * @param primary primary being reconciled
-     * @param resolutionResult
-     * @return non-null ProxyIngressModel
-     */
-    public static ProxyIngressModel allocateProxyIngressModel(KafkaProxy primary,
-                                                              ProxyResolutionResult resolutionResult) {
-        AtomicInteger exclusivePorts = new AtomicInteger(PROXY_PORT_START);
-        // include broken clusters in the model, so that if they are healed the ports will stay the same
-        Stream<ClusterResolutionResult> virtualKafkaClusterStream = resolutionResult.allResolutionResultsInClusterNameOrder();
-        List<ProxyIngressModel.VirtualClusterIngressModel> list = virtualKafkaClusterStream
-                .map(it -> new ProxyIngressModel.VirtualClusterIngressModel(it.cluster(), allocateIngressModel(primary, it, exclusivePorts)))
-                .toList();
-        return new ProxyIngressModel(list);
-    }
-
-    private static List<ProxyIngressModel.IngressModel> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it, AtomicInteger ports) {
-        Stream<IngressDefinition> ingressStream = IngressDefinitionBuilder.buildIngressDefinitions(primary, it);
-        return ingressStream.map(resource -> {
-            int toAllocate = resource.numIdentifyingPortsRequired();
-            IngressConflictException exception = null;
-            if (ports.get() != PROXY_PORT_START) {
-                exception = new IngressConflictException(name(resource.resource()),
-                        "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
-                                + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
-            }
-            int firstIdentifyingPort = ports.get();
-            int lastIdentifyingPort = ports.addAndGet(toAllocate) - 1;
-            return new ProxyIngressModel.IngressModel(resource.createInstance(firstIdentifyingPort, lastIdentifyingPort), exception);
-        }).toList();
-    }
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinition.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinition.java
deleted file mode 100644
index a53ca9e841..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinition.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
-
-/**
- * Ingress definition for a single VirtualKafkaCluster, KafkaProxyIngress pair that:
- * <ol>
- *     <li>declares the requirements of the ingress (e.g. how many identifying ports it requires)</li>
- *     <li>can instantiate IngressInstances</li>
- * </ol>
- */
-public interface IngressDefinition {
-
-    /**
-     * The raw resource that was translated into this definition
-     * @return resource
-     */
-    KafkaProxyIngress resource();
-
-    /**
-     * Create an instance of the Ingress with identifying ports allocated to it. Identifying meaning that
-     * the port on the container is expected to unambiguously identify which node the client is connecting to.
-     * I.e. using a port-per-broker strategy at the proxy.
-     *
-     * @param firstIdentifyingPort the first identifying port allocated to this ingress instance
-     * @param lastIdentifyingPort the last identifying port (inclusive) allocated to this ingress instance
-     * @return a non-null ingress instance
-     */
-    IngressInstance createInstance(int firstIdentifyingPort, int lastIdentifyingPort);
-
-    /**
-     * Some Ingress strategies require a set of ports in the proxy pod to be unique and exclusive so that the Proxy
-     * can use the client's connection port to identify the cluster and upstream node id they want to communicate with.
-     *
-     * @return the number of identifying ports this ingress requires
-     */
-    int numIdentifyingPortsRequired();
-
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinitionBuilder.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinitionBuilder.java
deleted file mode 100644
index 7caf739085..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressDefinitionBuilder.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import java.util.List;
-import java.util.Optional;
-import java.util.stream.Stream;
-
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
-import io.kroxylicious.kubernetes.api.v1alpha1.KafkaService;
-import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
-import io.kroxylicious.kubernetes.api.v1alpha1.kafkaproxyingressspec.ClusterIP;
-import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
-import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRangesBuilder;
-import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
-
-/**
- * IngressDefinitionBuilder is responsible for creating richer IngressDefinition objects from the raw
- * Custom Resources that define it.
- */
-class IngressDefinitionBuilder {
-
-    public static final List<NodeIdRanges> DEFAULT_NODE_ID_RANGES = List.of(new NodeIdRangesBuilder().withName("default").withStart(0L).withEnd(2L).build());
-
-    private IngressDefinitionBuilder() {
-    }
-
-    public static Stream<IngressDefinition> buildIngressDefinitions(KafkaProxy primary, ClusterResolutionResult clusterResult) {
-        VirtualKafkaCluster cluster = clusterResult.cluster();
-        return clusterResult.ingressResolutionResults().stream()
-                .flatMap(
-                        ingressResolutionResult -> {
-                            Optional<KafkaProxyIngress> maybeIngress = ingressResolutionResult.ingressResolutionResult().maybeReferentResource();
-                            if (maybeIngress.isPresent()) {
-                                Optional<KafkaService> maybeService = clusterResult.serviceResolutionResult().maybeReferentResource();
-                                // todo, maybe we should not include the case where the service does not resolve, it's optimistic to assume its using the default node id range
-                                List<NodeIdRanges> nodeIdRanges = maybeService.map(s -> s.getSpec().getNodeIdRanges()).orElse(DEFAULT_NODE_ID_RANGES);
-                                return Stream.of(buildIngressDefinition(primary, cluster, maybeIngress.get(), nodeIdRanges));
-                            }
-                            else {
-                                // skip unresolved ingresses
-                                return Stream.empty();
-                            }
-                        });
-    }
-
-    private static IngressDefinition buildIngressDefinition(KafkaProxy primary, VirtualKafkaCluster cluster, KafkaProxyIngress ingress, List<NodeIdRanges> nodeIdRanges) {
-        ClusterIP clusterIP = ingress.getSpec().getClusterIP();
-        if (clusterIP != null) {
-            return new ClusterIPIngressDefinition(ingress, cluster, primary, nodeIdRanges);
-        }
-        else {
-            throw new IllegalStateException("ingress must have clusterIP specified");
-        }
-    }
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressInstance.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressInstance.java
deleted file mode 100644
index ece8818ba1..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressInstance.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import java.util.stream.Stream;
-
-import io.fabric8.kubernetes.api.model.ContainerPort;
-import io.fabric8.kubernetes.api.model.ServiceBuilder;
-
-/**
- * Ingress model that provides:
- * <ol>
- *     <li>
- *         The Gateway Config, that will be added to the virtual clusters gateways
- *     </li>
- *     <li>
- *         The Services that will be manifested for this ingress
- *     </li>
- *     <li>
- *         The container ports that will be exposed on the Proxy pod (will correspond
- *         to the ports configured in the Gateway Config).
- *     </li>
- * </ol>
- */
-public interface IngressInstance {
-
-    /**
-     * Services to be created for this ingress
-     * @return a stream of ServiceBuilders
-     */
-    Stream<ServiceBuilder> services();
-
-    /**
-     * ProxyContainerPorts to be added for this ingress
-     * @return a stream of ServiceBuilders
-     */
-    Stream<ContainerPort> proxyContainerPorts();
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java
deleted file mode 100644
index 2dde93ce07..0000000000
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * 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.operator.model.ingress;
-
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import io.fabric8.kubernetes.api.model.ContainerPort;
-import io.fabric8.kubernetes.api.model.Service;
-import io.fabric8.kubernetes.api.model.ServiceBuilder;
-
-import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
-
-import edu.umd.cs.findbugs.annotations.Nullable;
-
-import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
-
-/**
- * The ProxyIngressModel represents the logical arrangement of ingresses for a single KafkaProxy.
- * Different ingresses may need different resources on the pod, like unique identifying ports that
- * the proxy can use to determine the upstream nodes, or a shared port.
- * This also is a place where we can transform a single KafkaProxyIngress specification into multiple
- * Ingress instances (numerous virtual clusters can refer to the same ingress), with it own unique
- * resource allocation in the proxy pod.
- * It also describes invalid ingresses
- * @param clusters the list of cluster models, note that we do not consider if the clusters are broken yet
- */
-public record ProxyIngressModel(List<VirtualClusterIngressModel> clusters) {
-
-    public Optional<VirtualClusterIngressModel> clusterIngressModel(VirtualKafkaCluster cluster) {
-        return clusters.stream()
-                .filter(c -> name(c.cluster).equals(name(cluster)))
-                .findFirst();
-    }
-
-    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModel> ingressModels) {
-
-        public Stream<Service> services() {
-            return ingressModels.stream().flatMap(it -> it.ingressInstance().services()).map(ServiceBuilder::build);
-        }
-
-        public Set<IngressConflictException> ingressExceptions() {
-            return ingressModels.stream().flatMap(it -> Stream.ofNullable(it.exception)).collect(Collectors.toSet());
-        }
-    }
-
-    public record IngressModel(IngressInstance ingressInstance, @Nullable IngressConflictException exception) {
-
-        public Stream<ContainerPort> proxyContainerPorts() {
-            return ingressInstance.proxyContainerPorts();
-        }
-
-    }
-
-}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModel.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModel.java
new file mode 100644
index 0000000000..0a52cf53c0
--- /dev/null
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModel.java
@@ -0,0 +1,161 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ContainerPortBuilder;
+import io.fabric8.kubernetes.api.model.IntOrString;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource;
+import io.kroxylicious.kubernetes.operator.ResourcesUtil;
+import io.kroxylicious.proxy.config.NamedRange;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
+import io.kroxylicious.proxy.config.PortIdentifiesNodeIdentificationStrategy;
+import io.kroxylicious.proxy.service.HostPort;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import static io.kroxylicious.kubernetes.operator.Labels.standardLabels;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.namespace;
+import static java.lang.Math.toIntExact;
+
+public record ClusterIPClusterIngressNetworkingModel(KafkaProxy proxy,
+                                                     VirtualKafkaCluster cluster,
+                                                     KafkaProxyIngress ingress,
+                                                     List<NodeIdRanges> nodeIdRanges,
+                                                     @Nullable Tls tls,
+                                                     int firstIdentifyingPort,
+                                                     int lastIdentifyingPort)
+        implements ClusterIngressNetworkingModel {
+
+    public ClusterIPClusterIngressNetworkingModel {
+        Objects.requireNonNull(proxy);
+        Objects.requireNonNull(cluster);
+        Objects.requireNonNull(ingress);
+        Objects.requireNonNull(nodeIdRanges);
+        if (nodeIdRanges.isEmpty()) {
+            throw new IllegalArgumentException("nodeIdRanges cannot be empty");
+        }
+        int numIdentifyingPorts = lastIdentifyingPort - firstIdentifyingPort + 1;
+        int numIdentifyingPortsRequired = numIdentifyingPortsRequired(nodeIdRanges);
+        if (numIdentifyingPortsRequired != numIdentifyingPorts) {
+            throw new IllegalArgumentException("number of identifying ports allocated %d does not match the number required %d"
+                    .formatted(numIdentifyingPorts, numIdentifyingPortsRequired));
+        }
+    }
+
+    public static String bootstrapServiceName(VirtualKafkaCluster cluster, String ingressName) {
+        Objects.requireNonNull(cluster);
+        Objects.requireNonNull(ingressName);
+        return name(cluster) + "-" + ingressName;
+    }
+
+    @Override
+    public Stream<ServiceBuilder> services() {
+        String serviceName = bootstrapServiceName();
+        var serviceSpecBuilder = new ServiceBuilder()
+                .withMetadata(serviceMetadata(serviceName))
+                .withNewSpec()
+                .withSelector(ProxyDeploymentDependentResource.podLabels(proxy));
+        for (int i = firstIdentifyingPort; i <= lastIdentifyingPort; i++) {
+            serviceSpecBuilder = serviceSpecBuilder
+                    .addNewPort()
+                    .withName(name(cluster) + "-" + i)
+                    .withPort(i)
+                    .withTargetPort(new IntOrString(i))
+                    .withProtocol("TCP")
+                    .endPort();
+        }
+        return Stream.of(serviceSpecBuilder.endSpec());
+    }
+
+    @NonNull
+    private String bootstrapServiceName() {
+        return bootstrapServiceName(cluster, name(ingress));
+    }
+
+    ObjectMeta serviceMetadata(String name) {
+        return new ObjectMetaBuilder()
+                .withName(name)
+                .withNamespace(namespace(cluster))
+                .addToLabels(standardLabels(proxy))
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(proxy)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(cluster)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(ingress)).endOwnerReference()
+                .build();
+    }
+
+    @Override
+    public Stream<ContainerPort> proxyContainerPorts() {
+        Stream<ContainerPort> bootstrapPort = Stream.of(new ContainerPortBuilder().withContainerPort(firstIdentifyingPort)
+                .withName(firstIdentifyingPort + "-bootstrap").build());
+        int nodeCount = lastIdentifyingPort - firstIdentifyingPort;
+        Stream<ContainerPort> ingressNodePorts = IntStream.range(0, nodeCount).mapToObj(
+                nodeIdx -> {
+                    int port = firstIdentifyingPort + nodeIdx + 1;
+                    return new ContainerPortBuilder().withContainerPort(port)
+                            .withName(port + "-node").build();
+                });
+        return Stream.concat(bootstrapPort, ingressNodePorts);
+    }
+
+    @Override
+    public NodeIdentificationStrategy nodeIdentificationStrategy() {
+        List<NamedRange> portRanges = IntStream.range(0, nodeIdRanges.size()).mapToObj(i -> {
+            NodeIdRanges range = nodeIdRanges.get(i);
+            String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+            return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+        }).toList();
+        return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                crossNamespaceServiceAddress(),
+                null,
+                portRanges);
+    }
+
+    /**
+     * @return an address that any pod in the same k8s cluster can use to address the service, regardless of which namespace the pod is in
+     */
+    private String crossNamespaceServiceAddress() {
+        return bootstrapServiceName() + "." + namespace(cluster) + ".svc.cluster.local";
+    }
+
+    @Override
+    public Optional<Tls> downstreamTls() {
+        return Optional.ofNullable(tls);
+    }
+
+    public static int numIdentifyingPortsRequired(List<NodeIdRanges> nodeIdRanges) {
+        // one per broker plus the bootstrap
+        return nodeCount(nodeIdRanges) + 1;
+    }
+
+    // note: we use CRD validation to enforce end >= start at the apiserver level
+    public static int nodeCount(List<NodeIdRanges> nodeIdRanges) {
+        Objects.requireNonNull(nodeIdRanges);
+        if (nodeIdRanges.isEmpty()) {
+            throw new IllegalArgumentException("nodeIdRanges cannot be empty");
+        }
+        return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
+    }
+
+}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIngressNetworkingModel.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIngressNetworkingModel.java
new file mode 100644
index 0000000000..7b5ddf52d6
--- /dev/null
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIngressNetworkingModel.java
@@ -0,0 +1,70 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
+
+/**
+ * Represents the client-facing resources, and backend plumbing required to enable a client to
+ * connect to a Virtual Kafka Cluster on one of it's declared ingresses.
+ * Client-facing resources could include one-of:
+ * <ul>
+ *     <li>kubernetes ClusterIP Services for in-cluster access</li>
+ *     <li>kubernetes LoadBalancer Services for off-cluster access</li>
+ * </ul>
+ * Backend plumbing will include:
+ * <ul>
+ *     <li>container ports exposed on the Proxy Pod</li>
+ *     <li>gateway configuration in the Proxy Config/li>
+ * </ul>
+ */
+public interface ClusterIngressNetworkingModel {
+
+    /**
+     * The VirtualKafkaCluster underlying this model
+     * @return a VirtualKafkaCluster
+     */
+    VirtualKafkaCluster cluster();
+
+    /**
+     * The KafkaProxyIngress underlying this model
+     * @return a KafkaProxyIngress
+     */
+    KafkaProxyIngress ingress();
+
+    /**
+     * Kubernetes Services to be created for this model
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ServiceBuilder> services();
+
+    /**
+     * ContainerPorts to be added to the proxy for this model
+     * @return a stream of ContainerPorts
+     */
+    Stream<ContainerPort> proxyContainerPorts();
+
+    /**
+     * The node identification strategy to be injected into the Proxy Config for this model
+     * @return a NodeIdentificationStrategy
+     */
+    NodeIdentificationStrategy nodeIdentificationStrategy();
+
+    /**
+     * The downstream TLS to be injected into the Proxy Config for this model, if available
+     */
+    Optional<Tls> downstreamTls();
+}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressConflictException.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/IngressConflictException.java
similarity index 92%
rename from kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressConflictException.java
rename to kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/IngressConflictException.java
index 6e8c2cc9f8..6a64c25644 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressConflictException.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/IngressConflictException.java
@@ -4,7 +4,7 @@
  * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
  */
 
-package io.kroxylicious.kubernetes.operator.model.ingress;
+package io.kroxylicious.kubernetes.operator.model.networking;
 
 /**
  * Indicates that a declared ingress for a virtual cluster was incompatible with some other ingress of this proxy. For instance,
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/NetworkingPlanner.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/NetworkingPlanner.java
new file mode 100644
index 0000000000..2a1302f6a2
--- /dev/null
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/NetworkingPlanner.java
@@ -0,0 +1,186 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaService;
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaproxyingressspec.ClusterIP;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRangesBuilder;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel.ClusterIngressNetworkingModelResult;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel.ClusterNetworkingModel;
+import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
+import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import static io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource.PROXY_PORT_START;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
+
+/**
+ * NetworkingPlanner is responsible for:
+ * <ul>
+ *     <li>planning a ProxyNetworkingModel, which is a logical model of all resources/configuration needed to connect clients to all virtual kafka clusters of the proxy</li>
+ *     <li>allocating identifying container ports to the model</li>
+ *     <li>detecting ClusterIngressNetworkingModels that are in conflict with each other, and selecting which to accept</li>
+ * </ul>
+ * We aim to produce a stable model, where port changes are minimised, therefore we may consider broken clusters even though
+ * their model will not be manifested as resources.
+ */
+public class NetworkingPlanner {
+    private NetworkingPlanner() {
+    }
+
+    private static final List<NodeIdRanges> DEFAULT_NODE_ID_RANGES = List.of(new NodeIdRangesBuilder().withName("default").withStart(0L).withEnd(2L).build());
+
+    /**
+     * Allocates a ProxyNetworkingModel. The aim is to deterministically produce a model of the ports that will
+     * be used by the proxy container. We want this to be as stable as possible, so we will allocate ports
+     * to potentially unacceptable virtual clusters if we can.
+     *
+     * @param primary primary being reconciled
+     * @param proxyResolutionResult
+     * @return non-null ProxyIngressModel
+     */
+    public static ProxyNetworkingModel planNetworking(KafkaProxy primary,
+                                                      ProxyResolutionResult proxyResolutionResult) {
+        AtomicInteger identifyingPorts = new AtomicInteger(PROXY_PORT_START);
+        // include broken clusters in the model, so that if they are healed the ports will stay the same
+        Stream<ClusterResolutionResult> virtualKafkaClusterStream = proxyResolutionResult.allResolutionResultsInClusterNameOrder();
+        List<ClusterNetworkingModel> list = virtualKafkaClusterStream
+                .map(it -> planClusterNetworking(primary, it, identifyingPorts))
+                .toList();
+        return new ProxyNetworkingModel(list);
+    }
+
+    private static ClusterNetworkingModel planClusterNetworking(KafkaProxy primary,
+                                                                ClusterResolutionResult clusterResolutionResult,
+                                                                AtomicInteger identifyingPorts) {
+        Stream<ClusterIngressNetworkingDefinition> networkingDefinitions = planClusterIngressNetworkingDefinitions(primary, clusterResolutionResult);
+        List<ClusterIngressNetworkingModelResult> ingressResults = networkingDefinitions.map(networkingDefinition -> {
+            int toAllocate = networkingDefinition.numIdentifyingPortsRequired();
+            Integer firstIdentifyingPort = null;
+            Integer lastIdentifyingPort = null;
+            IngressConflictException exception = null;
+            if (toAllocate != 0) {
+                if (identifyingPorts.get() != PROXY_PORT_START) {
+                    exception = new IngressConflictException(name(networkingDefinition.ingress()),
+                            "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
+                                    + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
+                }
+                firstIdentifyingPort = identifyingPorts.get();
+                lastIdentifyingPort = identifyingPorts.addAndGet(toAllocate) - 1;
+            }
+            ClusterIngressNetworkingModel networkingModel = networkingDefinition.createNetworkingModel(firstIdentifyingPort, lastIdentifyingPort);
+            return new ClusterIngressNetworkingModelResult(networkingModel, exception);
+        }).toList();
+        return new ClusterNetworkingModel(clusterResolutionResult.cluster(), ingressResults);
+    }
+
+    static Stream<ClusterIngressNetworkingDefinition> planClusterIngressNetworkingDefinitions(KafkaProxy primary,
+                                                                                              ClusterResolutionResult clusterResolutionResult) {
+        VirtualKafkaCluster cluster = clusterResolutionResult.cluster();
+        return clusterResolutionResult.ingressResolutionResults().stream()
+                .flatMap(
+                        ingressResolutionResult -> {
+                            Optional<KafkaProxyIngress> maybeIngress = ingressResolutionResult.ingressResolutionResult().maybeReferentResource();
+                            if (maybeIngress.isPresent()) {
+                                Optional<KafkaService> maybeService = clusterResolutionResult.serviceResolutionResult().maybeReferentResource();
+                                // todo, maybe we should not include the case where the service does not resolve, it's optimistic to assume its using the default node id range
+                                List<NodeIdRanges> nodeIdRanges = maybeService.map(s -> s.getSpec().getNodeIdRanges()).orElse(DEFAULT_NODE_ID_RANGES);
+                                return Stream.of(
+                                        clusterIngressNetworkingDefinition(primary, cluster, maybeIngress.get(), nodeIdRanges,
+                                                ingressResolutionResult.ingress().getTls()));
+                            }
+                            else {
+                                // skip unresolved ingresses
+                                return Stream.empty();
+                            }
+                        });
+    }
+
+    private static ClusterIngressNetworkingDefinition clusterIngressNetworkingDefinition(KafkaProxy primary,
+                                                                                         VirtualKafkaCluster cluster,
+                                                                                         KafkaProxyIngress ingress,
+                                                                                         List<NodeIdRanges> nodeIdRanges,
+                                                                                         @Nullable Tls tls) {
+        ClusterIP clusterIP = ingress.getSpec().getClusterIP();
+        if (clusterIP != null) {
+            return new ClusterIPClusterIngressNetworkingDefinition(ingress, cluster, primary, nodeIdRanges, tls);
+        }
+        else {
+            throw new IllegalStateException("ingress must have clusterIP specified");
+        }
+    }
+
+    /**
+     * ClusterIngressNetworkingDefinition definition for a single VirtualKafkaCluster, KafkaProxyIngress pair that:
+     * <ol>
+     *     <li>declares the requirements of the Ingress (e.g. how many identifying ports it requires)</li>
+     *     <li>can instantiate IngressModel</li>
+     * </ol>
+     * Corresponds to a single ingress item in the VirtualKafkaCluster spec.ingresses
+     */
+    private interface ClusterIngressNetworkingDefinition {
+
+        /**
+         * The raw resource that was translated into this definition
+         * @return resource
+         */
+        KafkaProxyIngress ingress();
+
+        /**
+         * Create an ClusterIngressNetworkingModel with identifying ports allocated to it. Identifying meaning that
+         * the port on the container is expected to unambiguously identify which node the client is connecting to.
+         * I.e. using a port-per-broker strategy at the proxy.
+         *
+         * @param firstIdentifyingPort the first identifying port allocated to this Ingress
+         * @param lastIdentifyingPort the last identifying port (inclusive) allocated to this Ingress
+         * @return a non-null ClusterIngressNetworkingModel
+         */
+        ClusterIngressNetworkingModel createNetworkingModel(@Nullable Integer firstIdentifyingPort, @Nullable Integer lastIdentifyingPort);
+
+        /**
+         * Some Ingress strategies require a set of ports in the proxy pod to be unique and exclusive so that the Proxy
+         * can use the client's connection port to identify the cluster and upstream node id they want to communicate with.
+         *
+         * @return the number of identifying ports this ingress requires
+         */
+        int numIdentifyingPortsRequired();
+    }
+
+    private record ClusterIPClusterIngressNetworkingDefinition(
+                                                               KafkaProxyIngress ingress,
+                                                               VirtualKafkaCluster cluster,
+                                                               KafkaProxy primary,
+                                                               List<NodeIdRanges> nodeIdRanges,
+                                                               @Nullable Tls tls)
+            implements ClusterIngressNetworkingDefinition {
+
+        @Override
+        public ClusterIngressNetworkingModel createNetworkingModel(@Nullable Integer firstIdentifyingPort, @Nullable Integer lastIdentifyingPort) {
+            Objects.requireNonNull(firstIdentifyingPort);
+            Objects.requireNonNull(lastIdentifyingPort);
+            return new ClusterIPClusterIngressNetworkingModel(primary, cluster, ingress, nodeIdRanges, tls, firstIdentifyingPort, lastIdentifyingPort);
+        }
+
+        @Override
+        public int numIdentifyingPortsRequired() {
+            return ClusterIPClusterIngressNetworkingModel.numIdentifyingPortsRequired(nodeIdRanges);
+        }
+
+    }
+}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ProxyNetworkingModel.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ProxyNetworkingModel.java
new file mode 100644
index 0000000000..d98056d629
--- /dev/null
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/ProxyNetworkingModel.java
@@ -0,0 +1,87 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
+
+/**
+ * The ProxyNetworkingModel models the logical arrangement of client-facing resources, and backend plumbing
+ * for a single KafkaProxy.
+ * Different ingresses need different client-facing resources, like ClusterIP Services for on-cluster access
+ * or LoadBalancer Services for off-cluster access.
+ * Different ingresses may need different resources on the pod, like unique identifying ports that
+ * the proxy can use to determine the upstream nodes, or a shared port for SNI access.
+ * It also describes logical issues discovered when composing the proxy ingress model, such as conflicting ingresses that
+ * required clashing resources.
+ * @param clusterNetworkingModels the list of cluster models, note that we do not consider if the clusters are broken yet
+ */
+public record ProxyNetworkingModel(List<ClusterNetworkingModel> clusterNetworkingModels) {
+
+    public Optional<ClusterNetworkingModel> clusterIngressModel(VirtualKafkaCluster cluster) {
+        return clusterNetworkingModels.stream()
+                .filter(c -> name(c.cluster).equals(name(cluster)))
+                .findFirst();
+    }
+
+    /**
+     *
+     * @param cluster cluster
+     * @param clusterIngressNetworkingModelResults the ingress model results, one per ingress in the VKC spec.ingresses in the same order
+     */
+    public record ClusterNetworkingModel(VirtualKafkaCluster cluster, List<ClusterIngressNetworkingModelResult> clusterIngressNetworkingModelResults) {
+
+        public Stream<Service> services() {
+            return clusterIngressNetworkingModelResults.stream().flatMap(it -> it.clusterIngressNetworkingModel().services()).map(ServiceBuilder::build);
+        }
+
+        public Set<IngressConflictException> ingressExceptions() {
+            return clusterIngressNetworkingModelResults.stream()
+                    .filter(it -> it.exception != null)
+                    .map(ClusterIngressNetworkingModelResult::exception)
+                    .collect(Collectors.toSet());
+        }
+
+        /**
+         * Register the container ports of all ClusterIngressNetworkingModelResults
+         * @param portConsumer consumer that will accept all container ports
+         */
+        public void registerProxyContainerPorts(Consumer<ContainerPort> portConsumer) {
+            clusterIngressNetworkingModelResults.forEach(result -> {
+                result.proxyContainerPorts().forEach(portConsumer);
+            });
+        }
+    }
+
+    /**
+     * The result of modelling a single ingress from a VKC spec.ingresses array into a ClusterIngressNetworkingModelResult
+     * @param clusterIngressNetworkingModel the ingress model
+     * @param exception an exception if there was a conflict, null otherwise
+     */
+    public record ClusterIngressNetworkingModelResult(ClusterIngressNetworkingModel clusterIngressNetworkingModel, @Nullable IngressConflictException exception) {
+
+        public Stream<ContainerPort> proxyContainerPorts() {
+            return clusterIngressNetworkingModel.proxyContainerPorts();
+        }
+
+    }
+
+}
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/package-info.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/package-info.java
similarity index 81%
rename from kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/package-info.java
rename to kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/package-info.java
index d1671357c9..79ac0d8f89 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/package-info.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/package-info.java
@@ -5,12 +5,12 @@
  */
 
 /**
- * Classes comprising the operator's configuration model.
+ * Classes comprising the operator's logical networking model.
  */
 @ReturnValuesAreNonnullByDefault
 @DefaultAnnotationForParameters(NonNull.class)
 @DefaultAnnotation(NonNull.class)
-package io.kroxylicious.kubernetes.operator.model.ingress;
+package io.kroxylicious.kubernetes.operator.model.networking;
 
 import edu.umd.cs.findbugs.annotations.DefaultAnnotation;
 import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/DependencyResolver.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/DependencyResolver.java
index 31acae9e5e..97e0fd5ae0 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/DependencyResolver.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/DependencyResolver.java
@@ -170,7 +170,7 @@ private List<IngressResolutionResult> resolveIngresses(LocalRef<VirtualKafkaClus
             ResolutionResult<KafkaProxy> kafkaProxyResolutionResult = optionalKafkaProxyIngress
                     .map(i -> resolveProxy(ResourcesUtil.toLocalRef(i), proxies, i.getSpec().getProxyRef()))
                     .orElse(null);
-            return new IngressResolutionResult(resolvedIngress, kafkaProxyResolutionResult);
+            return new IngressResolutionResult(resolvedIngress, kafkaProxyResolutionResult, ingress);
         }).toList();
     }
 }
diff --git a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/IngressResolutionResult.java b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/IngressResolutionResult.java
index bff5d903dc..b6a703acb9 100644
--- a/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/IngressResolutionResult.java
+++ b/kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/resolver/IngressResolutionResult.java
@@ -8,13 +8,17 @@
 
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.Ingresses;
 
 import edu.umd.cs.findbugs.annotations.Nullable;
 
 /**
  * The result of resolving a VKC's {@code spec.ingresses.ingressRef} and {@code spec.ingresses.proxyRef}
+ *
  * @param ingressResolutionResult the resolved KafkaProxyIngress result
  * @param proxyResolutionResult the resolved KafkaProxy result
+ * @param ingress the virtual kafka cluster ingress that was being resolved
  */
 public record IngressResolutionResult(ResolutionResult<KafkaProxyIngress> ingressResolutionResult,
-                                      @Nullable ResolutionResult<KafkaProxy> proxyResolutionResult) {}
+                                      @Nullable ResolutionResult<KafkaProxy> proxyResolutionResult,
+                                      Ingresses ingress) {}
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResourceTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResourceTest.java
index eb344c5177..f71a875d2e 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResourceTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResourceTest.java
@@ -30,7 +30,7 @@
 import io.kroxylicious.kubernetes.operator.checksum.Crc32ChecksumGenerator;
 import io.kroxylicious.kubernetes.operator.checksum.MetadataChecksumGenerator;
 import io.kroxylicious.kubernetes.operator.model.ProxyModel;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.kubernetes.operator.resolver.ResolutionResult;
 
@@ -67,7 +67,7 @@ void setUp() {
         kafkaService = new KafkaServiceBuilder().withNewMetadata().withName(PROXY_NAME).endMetadata().build();
         virtualKafkaCluster = new VirtualKafkaClusterBuilder().build();
 
-        proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyIngressModel(List.of()), List.of(clusterResolutionResultFor(virtualKafkaCluster)));
+        proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyNetworkingModel(List.of()), List.of(clusterResolutionResultFor(virtualKafkaCluster)));
 
         var resourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(null, kafkaProxy, kubernetesContext);
         resourceContext.put(Crc32ChecksumGenerator.CHECKSUM_CONTEXT_KEY, metadataChecksumGenerator);
@@ -105,7 +105,7 @@ void shouldIncludeMultipleResolvedVirtualClusterInChecksum() {
         List<ClusterResolutionResult> clusterResolutionResults = List.of(
                 clusterResolutionResultFor(virtualKafkaCluster),
                 clusterResolutionResultFor(virtualKafkaClusterB));
-        proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyIngressModel(List.of()), clusterResolutionResults);
+        proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyNetworkingModel(List.of()), clusterResolutionResults);
         ProxyDeploymentDependentResource proxyDeploymentDependentResource = new ProxyDeploymentDependentResource();
 
         // When
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentTest.java
index 7c7925ba85..cf283bec5b 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentTest.java
@@ -34,7 +34,7 @@
 import io.kroxylicious.kubernetes.operator.assertj.OperatorAssertions;
 import io.kroxylicious.kubernetes.operator.checksum.MetadataChecksumGenerator;
 import io.kroxylicious.kubernetes.operator.model.ProxyModel;
-import io.kroxylicious.kubernetes.operator.model.ingress.ProxyIngressModel;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel;
 import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
 import io.kroxylicious.kubernetes.operator.resolver.ResolutionResult;
 
@@ -119,7 +119,7 @@ void shouldAddReferentChecksumAnnotation() {
     @Test
     void shouldAddReferentChecksumOfProxy() {
         // Given
-        var proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyIngressModel(List.of()), List.of());
+        var proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyNetworkingModel(List.of()), List.of());
         configureProxyModel(proxyModel);
         ProxyDeploymentDependentResource proxyDeploymentDependentResource = new ProxyDeploymentDependentResource();
 
@@ -139,7 +139,7 @@ private Context<KafkaProxy> setupContext() {
                 .withGeneration(3L).endMetadata().build();
         kafkaService = new KafkaServiceBuilder().withNewMetadata().withName(PROXY_NAME).endMetadata().build();
 
-        var proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyIngressModel(List.of()), List.of(clusterResolutionResultFor(virtualKafkaCluster)));
+        var proxyModel = new ProxyModel(EMPTY_RESOLUTION_RESULT, new ProxyNetworkingModel(List.of()), List.of(clusterResolutionResultFor(virtualKafkaCluster)));
 
         Context<KafkaProxy> context = mock(Context.class);
         resourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(null, kafkaProxy, context);
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 645dd198a7..cd7c145eae 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
@@ -66,7 +66,7 @@
 
 import edu.umd.cs.findbugs.annotations.NonNull;
 
-import static io.kroxylicious.kubernetes.operator.model.ingress.ClusterIPIngressDefinition.serviceName;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
@@ -203,7 +203,7 @@ class VirtualKafkaClusterReconcilerTest {
             .build();
     public static final Service KUBERNETES_INGRESS_SERVICES = new ServiceBuilder().
             withNewMetadata()
-                .withName(serviceName(CLUSTER_NO_FILTERS, INGRESS))
+                .withName(name(CLUSTER_NO_FILTERS) + "-" + name(INGRESS))
                 .withNamespace(NAMESPACE)
                 .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(CLUSTER_NO_FILTERS)).endOwnerReference()
                 .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(INGRESS)).endOwnerReference()
@@ -595,7 +595,7 @@ private static ConfigMap buildProxyConfigMapWithPatch(VirtualKafkaCluster cluste
                     .withName(clusterOneFilter.getSpec().getProxyRef().getName())
                 .endMetadata()
                 .withData(new ProxyConfigStateData().addStatusPatchForCluster(
-                    ResourcesUtil.name(clusterOneFilter),
+                    name(clusterOneFilter),
                     STATUS_FACTORY.newTrueConditionStatusPatch(clusterOneFilter, Condition.Type.ResolvedRefs, "")).build())
                 .build();
         // @formatter:on
diff --git a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinitionTest.java b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModelTest.java
similarity index 79%
rename from kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinitionTest.java
rename to kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModelTest.java
index ec7c2b7756..ee9c6f122d 100644
--- a/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinitionTest.java
+++ b/kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/model/networking/ClusterIPClusterIngressNetworkingModelTest.java
@@ -4,7 +4,7 @@
  * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
  */
 
-package io.kroxylicious.kubernetes.operator.model.ingress;
+package io.kroxylicious.kubernetes.operator.model.networking;
 
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -31,10 +31,10 @@
 import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngressBuilder;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
 import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaClusterBuilder;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaproxyingressspec.ClusterIP;
 import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
 import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRangesBuilder;
 import io.kroxylicious.kubernetes.operator.KafkaProxyReconciler;
-import io.kroxylicious.kubernetes.operator.model.ingress.ClusterIPIngressDefinition.ClusterIPIngressInstance;
 import io.kroxylicious.proxy.config.NamedRange;
 import io.kroxylicious.proxy.config.VirtualClusterGateway;
 import io.kroxylicious.proxy.service.HostPort;
@@ -46,7 +46,7 @@
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.params.provider.Arguments.argumentSet;
 
-class ClusterIPIngressDefinitionTest {
+class ClusterIPClusterIngressNetworkingModelTest {
 
     private static final String INGRESS_NAME = "my-ingress";
     public static final String CLUSTER_NAME = "my-cluster";
@@ -59,6 +59,11 @@ class ClusterIPIngressDefinitionTest {
                 .withName(INGRESS_NAME)
                 .withNamespace(NAMESPACE)
             .endMetadata()
+            .withNewSpec()
+                .withNewClusterIP()
+                    .withProtocol(ClusterIP.Protocol.TCP)
+                .endClusterIP()
+            .endSpec()
             .build();
     // @formatter:on
 
@@ -84,44 +89,43 @@ class ClusterIPIngressDefinitionTest {
 
     @Test
     void numIdentifyingPortsRequiredSingleRange() {
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(NODE_ID_RANGE));
         // 3 ports for the nodeId range + 1 for bootstrap
-        assertThat(definition.numIdentifyingPortsRequired()).isEqualTo(4);
+        int numIdentifyingPortsRequired = ClusterIPClusterIngressNetworkingModel.numIdentifyingPortsRequired(List.of(NODE_ID_RANGE));
+        assertThat(numIdentifyingPortsRequired).isEqualTo(4);
     }
 
     @Test
     void createInstancesWithExpectedNumber() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1L, 3L)));
+        List<NodeIdRanges> nodeIdRange = List.of(createNodeIdRange("a", 1L, 3L));
         // when
+        ClusterIPClusterIngressNetworkingModel model = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                nodeIdRange, null, 1, 4);
         // then
-        // expect to be allocated 4 ports total, 3 ports for the nodeId range + 1 for bootstrap
-        assertThat(definition.createInstance(1, 4)).isNotNull();
+        assertThat(model).isNotNull();
     }
 
     @Test
     void createInstancesWithTooManyPorts() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1L, 3L)));
+        List<NodeIdRanges> nodeIdRange = List.of(createNodeIdRange("a", 1L, 3L));
         // when
         // then
         // more ports than the 4 required by the node id range + 1 for bootstrap
-        assertThatThrownBy(() -> definition.createInstance(1, 5)).isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                nodeIdRange, null, 1, 5)).isInstanceOf(IllegalArgumentException.class);
     }
 
     @Test
     void createInstancesWithNotEnoughPorts() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1L, 3L)));
+        List<NodeIdRanges> nodeIdRange = List.of(createNodeIdRange("a", 1L, 3L));
 
         // when
         // then
         // more ports than the 4 required by the node id range + 1 for bootstrap
-        assertThatThrownBy(() -> definition.createInstance(1, 3)).isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                nodeIdRange, null, 1, 3)).isInstanceOf(IllegalArgumentException.class);
     }
 
     @Test
@@ -130,10 +134,10 @@ void gatewayConfigSingleRange() {
         String rangeName = "a";
         int rangeStart = 1;
         int rangeEnd = 3;
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange(rangeName, rangeStart, rangeEnd)));
+        List<NodeIdRanges> nodeIdRange = List.of(createNodeIdRange(rangeName, rangeStart, rangeEnd));
         // 3 ports for the nodeId range + 1 for bootstrap
-        ClusterIPIngressInstance instance = definition.createInstance(1, 4);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                nodeIdRange, null, 1, 4);
         assertThat(instance).isNotNull();
 
         // when
@@ -157,10 +161,10 @@ void gatewayConfigSingleRange() {
     @Test
     void serviceMetadata() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1, 3)));
         // 3 ports for the nodeId range + 1 for bootstrap
-        IngressInstance instance = definition.createInstance(1, 4);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1, 3)), null, 1, 4);
+        assertThat(instance).isNotNull();
         assertThat(instance).isNotNull();
 
         // when
@@ -199,11 +203,8 @@ void serviceMetadata() {
     @Test
     void serviceCommonSpec() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1, 3)));
-        // 3 ports for the nodeId range + 1 for bootstrap
-        IngressInstance instance = definition.createInstance(1, 4);
-        assertThat(instance).isNotNull();
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1, 3)), null, 1, 4);
 
         // when
         List<ServiceBuilder> serviceBuilders = instance.services().toList();
@@ -223,10 +224,10 @@ void serviceCommonSpec() {
     @Test
     void servicesSingleRangePorts() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1, 3)));
         // 3 ports for the nodeId range + 1 for bootstrap
-        IngressInstance instance = definition.createInstance(1, 4);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1, 3)), null, 1, 4);
+
         assertThat(instance).isNotNull();
 
         // when
@@ -251,10 +252,10 @@ void servicesSingleRangePorts() {
     @Test
     void servicesMultipleRangesPorts() {
         // given
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1, 1), createNodeIdRange("b", 3, 3)));
         // 2 ports for the nodeId ranges + 1 for bootstrap
-        IngressInstance instance = definition.createInstance(1, 3);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1, 1), createNodeIdRange("b", 3, 3)), null, 1, 3);
+
         assertThat(instance).isNotNull();
 
         // when
@@ -283,10 +284,12 @@ void gatewayConfigNameDefault() {
 
         int rangeStart2 = 5;
         int rangeEnd2 = 6;
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange(null, rangeStart, rangeEnd), createNodeIdRange(null, rangeStart2, rangeEnd2)));
+        List<NodeIdRanges> nodeIdRange = List.of(createNodeIdRange(null, rangeStart, rangeEnd), createNodeIdRange(null, rangeStart2, rangeEnd2));
+
         // 5 ports for the nodeId ranges + 1 for bootstrap
-        ClusterIPIngressInstance instance = definition.createInstance(2, 7);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                nodeIdRange, null, 2, 7);
+
         assertThat(instance).isNotNull();
 
         // when
@@ -318,10 +321,10 @@ void gatewayConfigMultipleRange() {
         String rangeName2 = "b";
         int rangeStart2 = 5;
         int rangeEnd2 = 6;
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange(rangeName, rangeStart, rangeEnd), createNodeIdRange(rangeName2, rangeStart2, rangeEnd2)));
         // 5 ports for the nodeId ranges + 1 for bootstrap
-        ClusterIPIngressInstance instance = (ClusterIPIngressInstance) definition.createInstance(2, 7);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange(rangeName, rangeStart, rangeEnd), createNodeIdRange(rangeName2, rangeStart2, rangeEnd2)), null, 2, 7);
+
         assertThat(instance).isNotNull();
 
         // when
@@ -345,24 +348,22 @@ void gatewayConfigMultipleRange() {
 
     @Test
     void numIdentifyingPortsRequiredMultipleRanges() {
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(NODE_ID_RANGE, createNodeIdRange("a", 5L, 6L)));
         // 5 ports for the nodeId ranges + 1 for bootstrap
-        assertThat(definition.numIdentifyingPortsRequired()).isEqualTo(6);
+        assertThat(ClusterIPClusterIngressNetworkingModel.numIdentifyingPortsRequired(List.of(NODE_ID_RANGE, createNodeIdRange("a", 5L, 6L)))).isEqualTo(6);
     }
 
     @Test
     void rangesMustBeNonEmpty() {
         List<NodeIdRanges> nodeIdRanges = List.of();
-        assertThatThrownBy(() -> new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY, nodeIdRanges))
+        assertThatThrownBy(() -> new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS, nodeIdRanges, null, 1, 3))
                 .isInstanceOf(IllegalArgumentException.class);
     }
 
     @Test
     void proxyContainerPortsSingleRange() {
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1L, 3L)));
-        IngressInstance instance = definition.createInstance(1, 4);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1, 3)), null, 1, 4);
+
         assertThat(instance).isNotNull();
         assertThat(instance.proxyContainerPorts())
                 .containsExactly(createContainerPort(1 + "-bootstrap", 1),
@@ -373,9 +374,9 @@ void proxyContainerPortsSingleRange() {
 
     @Test
     void proxyContainerPortsMultipleRanges() {
-        ClusterIPIngressDefinition definition = new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY,
-                List.of(createNodeIdRange("a", 1L, 1L), createNodeIdRange("b", 3L, 4L)));
-        IngressInstance instance = definition.createInstance(1, 4);
+        ClusterIngressNetworkingModel instance = new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS,
+                List.of(createNodeIdRange("a", 1L, 1L), createNodeIdRange("b", 3L, 4L)), null, 1, 4);
+
         assertThat(instance).isNotNull();
         assertThat(instance.proxyContainerPorts())
                 .containsExactly(createContainerPort(1 + "-bootstrap", 1),
@@ -386,10 +387,10 @@ void proxyContainerPortsMultipleRanges() {
 
     public static Stream<Arguments> constructorArgsMustBeNonNull() {
         List<NodeIdRanges> nodeIdRanges = List.of(NODE_ID_RANGE);
-        ThrowingCallable nullIngress = () -> new ClusterIPIngressDefinition(null, VIRTUAL_KAFKA_CLUSTER, PROXY, nodeIdRanges);
-        ThrowingCallable nullVirtualCluster = () -> new ClusterIPIngressDefinition(INGRESS, null, PROXY, nodeIdRanges);
-        ThrowingCallable nullProxy = () -> new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, null, nodeIdRanges);
-        ThrowingCallable nullRanges = () -> new ClusterIPIngressDefinition(INGRESS, VIRTUAL_KAFKA_CLUSTER, PROXY, null);
+        ThrowingCallable nullIngress = () -> new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, null, nodeIdRanges, null, 1, 4);
+        ThrowingCallable nullVirtualCluster = () -> new ClusterIPClusterIngressNetworkingModel(PROXY, null, INGRESS, nodeIdRanges, null, 1, 4);
+        ThrowingCallable nullProxy = () -> new ClusterIPClusterIngressNetworkingModel(null, VIRTUAL_KAFKA_CLUSTER, INGRESS, nodeIdRanges, null, 1, 4);
+        ThrowingCallable nullRanges = () -> new ClusterIPClusterIngressNetworkingModel(PROXY, VIRTUAL_KAFKA_CLUSTER, INGRESS, null, null, 1, 4);
         return Stream.of(argumentSet("ingress", nullIngress),
                 argumentSet("virtualCluster", nullVirtualCluster),
                 argumentSet("proxy", nullProxy),


Reviews:
user: login: SamBarker
body: I know I don't fully grok the networking model or exactly what this PR does to it however the fact it removes `IngressInstance` as a type from the model is enough of an improvement for me to approve.

There are however some naming and API questions I'd like to at least discuss before merging this.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T04:47:13Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T21:12:24Z
user: login: SamBarker
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T21:29:09Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T00:37:10Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T00:40:35Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T00:41:45Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T01:00:19Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T01:01:35Z
user: login: SamBarker
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T02:04:28Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T03:26:08Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T04:19:00Z
user: login: robobario
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T04:23:08Z
user: login: SamBarker
body: Thanks @robobario I think this is a good step forward.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-26T09:23:02Z

Review comments:
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: Why doesn't this happen in the `PortIdentifiesNodeIdentificationStrategy`?
created_at: 2025-05-22T02:02:39Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: Would it make more sense for the deition.tls to have the `Optional<Tls>` as its return type as well?
created_at: 2025-05-22T02:03:44Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
+        }
     }
 
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
+    private ObjectMeta serviceMetadata(String name) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: I know we don't need it today (so maybe YAGNI applies) but it would be more flexible to return the builder here so callers could add annotations etc. 

Then again this is a private method, so we can just change it later. (leaving this here in case it sparks other thoughts)
created_at: 2025-05-22T02:05:33Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
+        }
     }
 
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
+    private ObjectMeta serviceMetadata(String name) {
+        return new ObjectMetaBuilder()
+                .withName(name)
+                .withNamespace(namespace(cluster))
+                .addToLabels(standardLabels(primary))
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(primary)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(cluster)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(ingress)).endOwnerReference()
+                .build();
+    }
+
+    public static String bootstrapServiceName(VirtualKafkaCluster cluster, String ingressName) {
         Objects.requireNonNull(cluster);
-        Objects.requireNonNull(resource);
-        return name(cluster) + "-" + name(resource);
+        Objects.requireNonNull(ingressName);
+        return name(cluster) + "-" + ingressName;
+    }
+
+    private String qualifiedServiceHost() {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: I don't intuitively know what `qualified` means here, would `getServiceDnsAddress` or `serviceDnsAddress` be a better name?
created_at: 2025-05-22T02:07:30Z
author_association: MEMBER
diff_hunk: @@ -122,4 +160,8 @@ private int nodeCount() {
         return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
     }
 
+    public static String qualified(HasMetadata resource, String serviceName) {
+        return serviceName + "." + namespace(resource) + ".svc.cluster.local";
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: We need to pick a mechanism for abstracting `cluster.local` maybe centralise it to the context? 
created_at: 2025-05-22T02:08:44Z
author_association: MEMBER
diff_hunk: @@ -49,19 +61,58 @@ public static ProxyIngressModel allocateProxyIngressModel(KafkaProxy primary,
         return new ProxyIngressModel(list);
     }
 
-    private static List<ProxyIngressModel.IngressModel> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it, AtomicInteger ports) {
-        Stream<IngressDefinition> ingressStream = IngressDefinitionBuilder.buildIngressDefinitions(primary, it);
-        return ingressStream.map(resource -> {
-            int toAllocate = resource.numIdentifyingPortsRequired();
+    private static List<ProxyIngressModel.IngressModelResult> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it, AtomicInteger ports) {
+        Stream<IngressDefinition> ingressDefinitions = buildIngressDefinitions(primary, it);
+        return ingressDefinitions.map(ingressDefinition -> {
+            int toAllocate = ingressDefinition.numIdentifyingPortsRequired();
+            Integer firstIdentifyingPort = null;
+            Integer lastIdentifyingPort = null;
             IngressConflictException exception = null;
-            if (ports.get() != PROXY_PORT_START) {
-                exception = new IngressConflictException(name(resource.resource()),
-                        "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
-                                + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
+            if (toAllocate != 0) {
+                if (ports.get() != PROXY_PORT_START) {
+                    exception = new IngressConflictException(name(ingressDefinition.ingress()),
+                            "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
+                                    + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
+                }
+                firstIdentifyingPort = ports.get();
+                lastIdentifyingPort = ports.addAndGet(toAllocate) - 1;
             }
-            int firstIdentifyingPort = ports.get();
-            int lastIdentifyingPort = ports.addAndGet(toAllocate) - 1;
-            return new ProxyIngressModel.IngressModel(resource.createInstance(firstIdentifyingPort, lastIdentifyingPort), exception);
+
+            IngressModel ingressModel = ingressDefinition.createIngressModel(firstIdentifyingPort, lastIdentifyingPort);
+            return new ProxyIngressModel.IngressModelResult(ingressModel, exception);
         }).toList();
     }
+
+    static Stream<IngressDefinition> buildIngressDefinitions(KafkaProxy primary, ClusterResolutionResult clusterResult) {
+        VirtualKafkaCluster cluster = clusterResult.cluster();
+        return clusterResult.ingressResolutionResults().stream()
+                .flatMap(
+                        ingressResolutionResult -> {
+                            Optional<KafkaProxyIngress> maybeIngress = ingressResolutionResult.ingressResolutionResult().maybeReferentResource();
+                            if (maybeIngress.isPresent()) {
+                                Optional<KafkaService> maybeService = clusterResult.serviceResolutionResult().maybeReferentResource();
+                                // todo, maybe we should not include the case where the service does not resolve, it's optimistic to assume its using the default node id range
+                                List<NodeIdRanges> nodeIdRanges = maybeService.map(s -> s.getSpec().getNodeIdRanges()).orElse(DEFAULT_NODE_ID_RANGES);
+                                return Stream.of(buildIngressDefinition(primary, cluster, maybeIngress.get(), nodeIdRanges, ingressResolutionResult.ingress().getTls()));
+                            }
+                            else {
+                                // skip unresolved ingresses
+                                return Stream.empty();
+                            }
+                        });
+    }
+
+    private static IngressDefinition buildIngressDefinition(KafkaProxy primary,
+                                                            VirtualKafkaCluster cluster,
+                                                            KafkaProxyIngress ingress,
+                                                            List<NodeIdRanges> nodeIdRanges,
+                                                            @Nullable Tls tls) {
+        ClusterIP clusterIP = ingress.getSpec().getClusterIP();
+        if (clusterIP != null) {
+            return new ClusterIPIngressDefinition(ingress, cluster, primary, nodeIdRanges, tls);
+        }
+        else {
+            throw new IllegalStateException("ingress must have clusterIP specified");
+        }
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressAllocator.java
user: login: SamBarker
body: Shouldn't all this happen inside `ClusterIPIngressDefinition#init`? Or is the idea that the allocator knows about Kube resources and the definition realise on simple java types? 
created_at: 2025-05-22T02:26:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.operator.model.ingress;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
+
+/**
+ * Kubernetes Ingress model representing a single VirtualCluster Gateway in the Proxy Config
+ * and the kubernetes resources required to expose it to clients. It is responsible for
+ * providing:
+ * <ol>
+ *     <li>
+ *         The Gateway Config, that will be added to the virtual cluster's gateways
+ *     </li>
+ *     <li>
+ *         The Services that will be manifested for this IngressModel
+ *     </li>
+ *     <li>
+ *         The identifying container ports that will be exposed on the Proxy pod (will correspond
+ *         to the ports configured in the Gateway Config).
+ *     </li>
+ *     <li>
+ *         Describing whether this IngressModel requires a shared TLS port to be provisioned
+ *     </li>
+ * </ol>
+ */
+public interface IngressModel {
+
+    /**
+     * Kubernetes Services to be created for this IngressModel
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ServiceBuilder> services();
+
+    /**
+     * ContainerPorts to be added to the proxy for this IngressModel
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ContainerPort> proxyContainerPorts();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressModel.java
user: login: SamBarker
body: I'm struggling with this one, I kinda follow the javadoc describing the IngressModel but then I don't follow something specific to the pod/container. That's not really about access for clients as they never interact with the pod or its ports they interact with the ClusterIP or gateway. 
created_at: 2025-05-22T02:31:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.operator.model.ingress;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
+
+/**
+ * Kubernetes Ingress model representing a single VirtualCluster Gateway in the Proxy Config
+ * and the kubernetes resources required to expose it to clients. It is responsible for
+ * providing:
+ * <ol>
+ *     <li>
+ *         The Gateway Config, that will be added to the virtual cluster's gateways
+ *     </li>
+ *     <li>
+ *         The Services that will be manifested for this IngressModel
+ *     </li>
+ *     <li>
+ *         The identifying container ports that will be exposed on the Proxy pod (will correspond
+ *         to the ports configured in the Gateway Config).
+ *     </li>
+ *     <li>
+ *         Describing whether this IngressModel requires a shared TLS port to be provisioned
+ *     </li>
+ * </ol>
+ */
+public interface IngressModel {
+
+    /**
+     * Kubernetes Services to be created for this IngressModel
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ServiceBuilder> services();
+
+    /**
+     * ContainerPorts to be added to the proxy for this IngressModel
+     * @return a stream of ServiceBuilders
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressModel.java
user: login: SamBarker
body: Nit 
```suggestion
     * @return a stream of ContainerPorts
```
created_at: 2025-05-22T02:31:20Z
author_association: MEMBER
diff_hunk: @@ -40,21 +40,31 @@ public Optional<VirtualClusterIngressModel> clusterIngressModel(VirtualKafkaClus
                 .findFirst();
     }
 
-    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModel> ingressModels) {
+    /**
+     *
+     * @param cluster cluster
+     * @param ingressModelResults the ingress model results, one per ingress in the VKC spec.ingresses in the same order
+     */
+    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModelResult> ingressModelResults) {
 
         public Stream<Service> services() {
-            return ingressModels.stream().flatMap(it -> it.ingressInstance().services()).map(ServiceBuilder::build);
+            return ingressModelResults.stream().flatMap(it -> it.ingressModel().services()).map(ServiceBuilder::build);
         }
 
         public Set<IngressConflictException> ingressExceptions() {
-            return ingressModels.stream().flatMap(it -> Stream.ofNullable(it.exception)).collect(Collectors.toSet());
+            return ingressModelResults.stream().flatMap(it -> Stream.ofNullable(it.exception)).collect(Collectors.toSet());
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java
user: login: SamBarker
body: This looked rather odd initially, but having poked it in an IDE the alternative is:
```suggestion
            return ingressModelResults.stream().filter(it -> it.exception != null).map(IngressModelResult::exception).collect(Collectors.toSet());
```

which is a more verbose it might be a tiny bit more readable. 
created_at: 2025-05-22T04:26:10Z
author_association: MEMBER
diff_hunk: @@ -40,21 +40,31 @@ public Optional<VirtualClusterIngressModel> clusterIngressModel(VirtualKafkaClus
                 .findFirst();
     }
 
-    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModel> ingressModels) {
+    /**
+     *
+     * @param cluster cluster
+     * @param ingressModelResults the ingress model results, one per ingress in the VKC spec.ingresses in the same order
+     */
+    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModelResult> ingressModelResults) {
 
         public Stream<Service> services() {
-            return ingressModels.stream().flatMap(it -> it.ingressInstance().services()).map(ServiceBuilder::build);
+            return ingressModelResults.stream().flatMap(it -> it.ingressModel().services()).map(ServiceBuilder::build);
         }
 
         public Set<IngressConflictException> ingressExceptions() {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java
user: login: SamBarker
body: Given 3 out of the 4 production usages just check if the resulting set is empty, I'm inclined to say we should be able to ask the the record if its valid or not and only use the this method in the one place that actually cares about the exception.
created_at: 2025-05-22T04:28:24Z
author_association: MEMBER
diff_hunk: @@ -40,21 +40,31 @@ public Optional<VirtualClusterIngressModel> clusterIngressModel(VirtualKafkaClus
                 .findFirst();
     }
 
-    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModel> ingressModels) {
+    /**
+     *
+     * @param cluster cluster
+     * @param ingressModelResults the ingress model results, one per ingress in the VKC spec.ingresses in the same order
+     */
+    public record VirtualClusterIngressModel(VirtualKafkaCluster cluster, List<IngressModelResult> ingressModelResults) {
 
         public Stream<Service> services() {
-            return ingressModels.stream().flatMap(it -> it.ingressInstance().services()).map(ServiceBuilder::build);
+            return ingressModelResults.stream().flatMap(it -> it.ingressModel().services()).map(ServiceBuilder::build);
         }
 
         public Set<IngressConflictException> ingressExceptions() {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ProxyIngressModel.java
user: login: SamBarker
body: Hmmmmmm am I contradicting my previous self with that. As validity for other result classes is often in the eye of the beholder. 

I feel like we really do want the `*Model` to be able to declare if its valid or not, but maybe not the results classes themselves.
created_at: 2025-05-22T04:41:34Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: we are converting from `NodeIdRanges` declared in the k8s CRD to `NamedRange` in the kroxylicious config api. `PortIdentifiesNodeIdentificationStrategy` isn't aware of the k8s model.
created_at: 2025-05-25T21:12:24Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: Ok that makes sense.
created_at: 2025-05-25T21:29:09Z
author_association: MEMBER
diff_hunk: @@ -122,4 +160,8 @@ private int nodeCount() {
         return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
     }
 
+    public static String qualified(HasMetadata resource, String serviceName) {
+        return serviceName + "." + namespace(resource) + ".svc.cluster.local";
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: yep, we have a ticket for this one #2056
created_at: 2025-05-26T00:37:10Z
author_association: MEMBER
diff_hunk: @@ -49,19 +61,58 @@ public static ProxyIngressModel allocateProxyIngressModel(KafkaProxy primary,
         return new ProxyIngressModel(list);
     }
 
-    private static List<ProxyIngressModel.IngressModel> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it, AtomicInteger ports) {
-        Stream<IngressDefinition> ingressStream = IngressDefinitionBuilder.buildIngressDefinitions(primary, it);
-        return ingressStream.map(resource -> {
-            int toAllocate = resource.numIdentifyingPortsRequired();
+    private static List<ProxyIngressModel.IngressModelResult> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it, AtomicInteger ports) {
+        Stream<IngressDefinition> ingressDefinitions = buildIngressDefinitions(primary, it);
+        return ingressDefinitions.map(ingressDefinition -> {
+            int toAllocate = ingressDefinition.numIdentifyingPortsRequired();
+            Integer firstIdentifyingPort = null;
+            Integer lastIdentifyingPort = null;
             IngressConflictException exception = null;
-            if (ports.get() != PROXY_PORT_START) {
-                exception = new IngressConflictException(name(resource.resource()),
-                        "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
-                                + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
+            if (toAllocate != 0) {
+                if (ports.get() != PROXY_PORT_START) {
+                    exception = new IngressConflictException(name(ingressDefinition.ingress()),
+                            "Currently we do not support a virtual cluster with multiple ingresses that need unique ports to identify which node the "
+                                    + "client is connecting to. We currently do not have a sufficient strategy for port allocation for this case. See https://github.com/kroxylicious/kroxylicious/issues/1902");
+                }
+                firstIdentifyingPort = ports.get();
+                lastIdentifyingPort = ports.addAndGet(toAllocate) - 1;
             }
-            int firstIdentifyingPort = ports.get();
-            int lastIdentifyingPort = ports.addAndGet(toAllocate) - 1;
-            return new ProxyIngressModel.IngressModel(resource.createInstance(firstIdentifyingPort, lastIdentifyingPort), exception);
+
+            IngressModel ingressModel = ingressDefinition.createIngressModel(firstIdentifyingPort, lastIdentifyingPort);
+            return new ProxyIngressModel.IngressModelResult(ingressModel, exception);
         }).toList();
     }
+
+    static Stream<IngressDefinition> buildIngressDefinitions(KafkaProxy primary, ClusterResolutionResult clusterResult) {
+        VirtualKafkaCluster cluster = clusterResult.cluster();
+        return clusterResult.ingressResolutionResults().stream()
+                .flatMap(
+                        ingressResolutionResult -> {
+                            Optional<KafkaProxyIngress> maybeIngress = ingressResolutionResult.ingressResolutionResult().maybeReferentResource();
+                            if (maybeIngress.isPresent()) {
+                                Optional<KafkaService> maybeService = clusterResult.serviceResolutionResult().maybeReferentResource();
+                                // todo, maybe we should not include the case where the service does not resolve, it's optimistic to assume its using the default node id range
+                                List<NodeIdRanges> nodeIdRanges = maybeService.map(s -> s.getSpec().getNodeIdRanges()).orElse(DEFAULT_NODE_ID_RANGES);
+                                return Stream.of(buildIngressDefinition(primary, cluster, maybeIngress.get(), nodeIdRanges, ingressResolutionResult.ingress().getTls()));
+                            }
+                            else {
+                                // skip unresolved ingresses
+                                return Stream.empty();
+                            }
+                        });
+    }
+
+    private static IngressDefinition buildIngressDefinition(KafkaProxy primary,
+                                                            VirtualKafkaCluster cluster,
+                                                            KafkaProxyIngress ingress,
+                                                            List<NodeIdRanges> nodeIdRanges,
+                                                            @Nullable Tls tls) {
+        ClusterIP clusterIP = ingress.getSpec().getClusterIP();
+        if (clusterIP != null) {
+            return new ClusterIPIngressDefinition(ingress, cluster, primary, nodeIdRanges, tls);
+        }
+        else {
+            throw new IllegalStateException("ingress must have clusterIP specified");
+        }
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressAllocator.java
user: login: robobario
body: this is more preparing for the next PR where we will be saying "else if the ingress has a LoadBalancer property, create a LoadBalancerDefinition, else throw`.

it's more of a sanity check since the CRD should be guaranteeing one of them is non-null
created_at: 2025-05-26T00:40:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.operator.model.ingress;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.proxy.config.NodeIdentificationStrategy;
+
+/**
+ * Kubernetes Ingress model representing a single VirtualCluster Gateway in the Proxy Config
+ * and the kubernetes resources required to expose it to clients. It is responsible for
+ * providing:
+ * <ol>
+ *     <li>
+ *         The Gateway Config, that will be added to the virtual cluster's gateways
+ *     </li>
+ *     <li>
+ *         The Services that will be manifested for this IngressModel
+ *     </li>
+ *     <li>
+ *         The identifying container ports that will be exposed on the Proxy pod (will correspond
+ *         to the ports configured in the Gateway Config).
+ *     </li>
+ *     <li>
+ *         Describing whether this IngressModel requires a shared TLS port to be provisioned
+ *     </li>
+ * </ol>
+ */
+public interface IngressModel {
+
+    /**
+     * Kubernetes Services to be created for this IngressModel
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ServiceBuilder> services();
+
+    /**
+     * ContainerPorts to be added to the proxy for this IngressModel
+     * @return a stream of ServiceBuilders
+     */
+    Stream<ContainerPort> proxyContainerPorts();
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/IngressModel.java
user: login: robobario
body: rename/javadoc updates pushed up, hopefully the move away from `Ingress` naming and the javadoc talking about the backend plumbling requirements improve things here, clarifying why container ports is part of the networking plan. 
created_at: 2025-05-26T00:41:45Z
author_association: MEMBER
diff_hunk: @@ -122,4 +160,8 @@ private int nodeCount() {
         return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
     }
 
+    public static String qualified(HasMetadata resource, String serviceName) {
+        return serviceName + "." + namespace(resource) + ".svc.cluster.local";
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: Maybe we shouldn't be including the `svc.cluster.local`. pods should be able to resolve `serviceName + "." + namespace(resource)` on-cluster.
created_at: 2025-05-26T01:00:19Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
+        }
     }
 
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
+    private ObjectMeta serviceMetadata(String name) {
+        return new ObjectMetaBuilder()
+                .withName(name)
+                .withNamespace(namespace(cluster))
+                .addToLabels(standardLabels(primary))
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(primary)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(cluster)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(ingress)).endOwnerReference()
+                .build();
+    }
+
+    public static String bootstrapServiceName(VirtualKafkaCluster cluster, String ingressName) {
         Objects.requireNonNull(cluster);
-        Objects.requireNonNull(resource);
-        return name(cluster) + "-" + name(resource);
+        Objects.requireNonNull(ingressName);
+        return name(cluster) + "-" + ingressName;
+    }
+
+    private String qualifiedServiceHost() {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: maybe we could call it the `crossNamespaceServiceAddress`. the intent is that we can resolve this address from arbitrary namespaces
created_at: 2025-05-26T01:01:35Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
+        }
     }
 
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
+    private ObjectMeta serviceMetadata(String name) {
+        return new ObjectMetaBuilder()
+                .withName(name)
+                .withNamespace(namespace(cluster))
+                .addToLabels(standardLabels(primary))
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(primary)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(cluster)).endOwnerReference()
+                .addNewOwnerReferenceLike(ResourcesUtil.newOwnerReferenceTo(ingress)).endOwnerReference()
+                .build();
+    }
+
+    public static String bootstrapServiceName(VirtualKafkaCluster cluster, String ingressName) {
         Objects.requireNonNull(cluster);
-        Objects.requireNonNull(resource);
-        return name(cluster) + "-" + name(resource);
+        Objects.requireNonNull(ingressName);
+        return name(cluster) + "-" + ingressName;
+    }
+
+    private String qualifiedServiceHost() {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: SamBarker
body: That sounds good to me!
created_at: 2025-05-26T02:04:28Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: this class doesn't know about the definition any more as it's an internal detail of the NetworkingPlanner
created_at: 2025-05-26T03:26:08Z
author_association: MEMBER
diff_hunk: @@ -122,4 +160,8 @@ private int nodeCount() {
         return nodeIdRanges.stream().mapToInt(range -> toIntExact((range.getEnd() - range.getStart()) + 1)).sum();
     }
 
+    public static String qualified(HasMetadata resource, String serviceName) {
+        return serviceName + "." + namespace(resource) + ".svc.cluster.local";
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: will defer that one
created_at: 2025-05-26T04:19:00Z
author_association: MEMBER
diff_hunk: @@ -97,18 +102,51 @@ public Stream<ContainerPort> proxyContainerPorts() {
                     });
             return Stream.concat(bootstrapPort, ingressNodePorts);
         }
+
+        @Override
+        public NodeIdentificationStrategy nodeIdentificationStrategy() {
+            List<NamedRange> portRanges = IntStream.range(0, definition().nodeIdRanges().size()).mapToObj(i -> {
+                NodeIdRanges range = definition().nodeIdRanges().get(i);
+                String name = Optional.ofNullable(range.getName()).orElse("range-" + i);
+                return new NamedRange(name, toIntExact(range.getStart()), toIntExact(range.getEnd()));
+            }).toList();
+            return new PortIdentifiesNodeIdentificationStrategy(new HostPort("localhost", firstIdentifyingPort()),
+                    definition.qualifiedServiceHost(), null,
+                    portRanges);
+        }
+
+        @Override
+        public Optional<Tls> downstreamTls() {
+            return Optional.ofNullable(definition.tls());
+        }
     }
 
-    @VisibleForTesting
-    public static String serviceName(VirtualKafkaCluster cluster, KafkaProxyIngress resource) {
+    private ObjectMeta serviceMetadata(String name) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ingress/ClusterIPIngressDefinition.java
user: login: robobario
body: the public interface this feeds into returns `Stream<ServiceBuilder> services`, which should let the outer clients edit in annotations etc. (maybe in the LoadBalancer branch we should stamp all the services with the standard labels, rather than configuring all the labels down in the specific networking model).
created_at: 2025-05-26T04:23:08Z
author_association: MEMBER
diff_hunk: @@ -206,8 +206,8 @@ private Container proxyContainer(KafkaProxyContext kafkaProxyContext,
         // broker ports
         clusterResolutionResults.forEach(resolutionResult -> {
             if (!kafkaProxyContext.isBroken(resolutionResult.cluster())) {
-                ProxyIngressModel.VirtualClusterIngressModel virtualClusterIngressModel = ingressModel.clusterIngressModel(resolutionResult.cluster()).orElseThrow();
-                for (ProxyIngressModel.IngressModel ingress : virtualClusterIngressModel.ingressModels()) {
+                ProxyNetworkingModel.ClusterNetworkingModel clusterNetworkingModel = ingressModel.clusterIngressModel(resolutionResult.cluster()).orElseThrow();
+                for (ProxyNetworkingModel.ClusterIngressNetworkingModelResult ingress : clusterNetworkingModel.clusterIngressNetworkingModelResults()) {
                     ingress.proxyContainerPorts().forEach(containerBuilder::addToPorts);
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/ProxyDeploymentDependentResource.java
user: login: SamBarker
body: Could we do something more like
```suggestion
                clusterNetworkingModel.registerContainerPorts(containerBuilder::addToPorts)
```
Or `bindContainerPorts`?

Manually pealing back the structure and consuming the values feels a bit wrong from an object orientation perspective.  
created_at: 2025-05-26T09:11:32Z
author_association: MEMBER
diff_hunk: @@ -17,11 +17,11 @@
  * During a reconciliation we take the raw Custom Resources, resolve the graph of references between them
  * and allocate shared resources, like container ports to various components.
  * @param resolutionResult the resolved dependencies for this reconciliation
- * @param ingressModel the ingress model for this reconciliation
- * @param clustersWithValidIngresses virtual kafka clusters that are fully resolved and have valid ingresses
+ * @param networkingModel the networking model for this reconciliation
+ * @param clustersWithValidNetworkingModels virtual kafka clusters that are fully resolved and have valid networking models
  */
 public record ProxyModel(ProxyResolutionResult resolutionResult,
-                         ProxyIngressModel ingressModel,
-                         List<ClusterResolutionResult> clustersWithValidIngresses) {
+                         ProxyNetworkingModel networkingModel,
+                         List<ClusterResolutionResult> clustersWithValidNetworkingModels) {
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/ProxyModel.java
user: login: SamBarker
body: Does `models` add anything in the arg name? it feels rather odd especially as the parameter doesn't have a `*Model` type...

```suggestion
                         List<ClusterResolutionResult> clustersWithValidNetworking) {
```
created_at: 2025-05-26T09:13:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaService;
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaproxyingressspec.ClusterIP;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRangesBuilder;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel.ClusterIngressNetworkingModelResult;
+import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
+import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import static io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource.PROXY_PORT_START;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
+
+/**
+ * NetworkingPlanner is responsible for:
+ * <ul>
+ *     <li>planning a ProxyNetworkingModel</li>
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/NetworkingPlanner.java
user: login: SamBarker
body: This feels a little circular: "the network planner is responsible for planning a network"

Should it maybe elaborate a bit on what a `ProxyNetworkingModel` is?
created_at: 2025-05-26T09:16:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+/*
+ * 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.operator.model.networking;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
+import io.kroxylicious.kubernetes.api.v1alpha1.KafkaService;
+import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaproxyingressspec.ClusterIP;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRanges;
+import io.kroxylicious.kubernetes.api.v1alpha1.kafkaservicespec.NodeIdRangesBuilder;
+import io.kroxylicious.kubernetes.api.v1alpha1.virtualkafkaclusterspec.ingresses.Tls;
+import io.kroxylicious.kubernetes.operator.model.networking.ProxyNetworkingModel.ClusterIngressNetworkingModelResult;
+import io.kroxylicious.kubernetes.operator.resolver.ClusterResolutionResult;
+import io.kroxylicious.kubernetes.operator.resolver.ProxyResolutionResult;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import static io.kroxylicious.kubernetes.operator.ProxyDeploymentDependentResource.PROXY_PORT_START;
+import static io.kroxylicious.kubernetes.operator.ResourcesUtil.name;
+
+/**
+ * NetworkingPlanner is responsible for:
+ * <ul>
+ *     <li>planning a ProxyNetworkingModel</li>
+ *     <li>allocating identifying container ports to the model</li>
+ *     <li>detecting ClusterIngressNetworkingModels that are in conflict with each other, and selecting which to accept</li>
+ * </ul>
+ * We aim to produce a stable model, where port changes are minimised, therefore we may consider broken clusters even though
+ * their model will not be manifested as resources.
+ */
+public class NetworkingPlanner {
+    private NetworkingPlanner() {
+    }
+
+    private static final List<NodeIdRanges> DEFAULT_NODE_ID_RANGES = List.of(new NodeIdRangesBuilder().withName("default").withStart(0L).withEnd(2L).build());
+
+    /**
+     * Allocates a ProxyNetworkingModel. The aim is to deterministically produce a model of the ports that will
+     * be used by the proxy container. We want this to be as stable as possible, so we will allocate ports
+     * to potentially unacceptable virtual clusters if we can.
+     *
+     * @param primary primary being reconciled
+     * @param resolutionResult
+     * @return non-null ProxyIngressModel
+     */
+    public static ProxyNetworkingModel planNetworking(KafkaProxy primary,
+                                                      ProxyResolutionResult resolutionResult) {
+        AtomicInteger identifyingPorts = new AtomicInteger(PROXY_PORT_START);
+        // include broken clusters in the model, so that if they are healed the ports will stay the same
+        Stream<ClusterResolutionResult> virtualKafkaClusterStream = resolutionResult.allResolutionResultsInClusterNameOrder();
+        List<ProxyNetworkingModel.ClusterNetworkingModel> list = virtualKafkaClusterStream
+                .map(it -> new ProxyNetworkingModel.ClusterNetworkingModel(it.cluster(), allocateIngressModel(primary, it, identifyingPorts)))
+                .toList();
+        return new ProxyNetworkingModel(list);
+    }
+
+    private static List<ClusterIngressNetworkingModelResult> allocateIngressModel(KafkaProxy primary, ClusterResolutionResult it,
path: kroxylicious-operator/src/main/java/io/kroxylicious/kubernetes/operator/model/networking/NetworkingPlanner.java
user: login: SamBarker
body: Again I find `Model` in the method name odd, its fine for the result to be a model but I think the goal of the method is to `allocatePorts`. Maybe `allocateIngressPorts`.
created_at: 2025-05-26T09:19:58Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-26T21:50:52Z
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=2210) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=2210&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=2210&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=2210&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [95.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=2210&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=2210&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=kroxylicious_kroxylicious&pullRequest=2210)


author_association: NONE