From d661bf83b2705f1aa52aed5ff7fada00a8dc9b99 Mon Sep 17 00:00:00 2001
From: Georgios Andrianakis <geoand@gmail.com>
Date: Thu, 3 Jun 2021 11:49:31 +0300
Subject: [PATCH] Allow specifying per-port nodePort

Resolves: #17582
---
 .../deployment/MinikubeProcessor.java         | 15 +++-
 .../deployment/AddNodePortDecorator.java      | 29 ++++++-
 .../kubernetes/deployment/PortConfig.java     | 14 +++-
 .../VanillaKubernetesProcessor.java           | 14 +++-
 .../KubernetesWithMultiplePortsTest.java      | 81 +++++++++++++++++++
 .../kubernetes-with-multiple-ports.properties |  9 +++
 6 files changed, 151 insertions(+), 11 deletions(-)
 create mode 100644 integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithMultiplePortsTest.java
 create mode 100644 integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-multiple-ports.properties

diff --git a/extensions/kubernetes/minikube/deployment/src/main/java/io/quarkus/minikube/deployment/MinikubeProcessor.java b/extensions/kubernetes/minikube/deployment/src/main/java/io/quarkus/minikube/deployment/MinikubeProcessor.java
index 9c0bba4c82e577..46e69b97345663 100644
--- a/extensions/kubernetes/minikube/deployment/src/main/java/io/quarkus/minikube/deployment/MinikubeProcessor.java
+++ b/extensions/kubernetes/minikube/deployment/src/main/java/io/quarkus/minikube/deployment/MinikubeProcessor.java
@@ -17,6 +17,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import io.dekorate.kubernetes.annotation.ServiceType;
@@ -42,6 +43,7 @@
 import io.quarkus.kubernetes.deployment.EnvConverter;
 import io.quarkus.kubernetes.deployment.KubernetesCommonHelper;
 import io.quarkus.kubernetes.deployment.KubernetesConfig;
+import io.quarkus.kubernetes.deployment.PortConfig;
 import io.quarkus.kubernetes.deployment.ResourceNameUtil;
 import io.quarkus.kubernetes.spi.ConfiguratorBuildItem;
 import io.quarkus.kubernetes.spi.DecoratorBuildItem;
@@ -139,8 +141,17 @@ public List<DecoratorBuildItem> createDecorators(ApplicationInfoBuildItem applic
 
         //Service handling
         result.add(new DecoratorBuildItem(MINIKUBE, new ApplyServiceTypeDecorator(name, ServiceType.NodePort.name())));
-        result.add(new DecoratorBuildItem(MINIKUBE, new AddNodePortDecorator(name, config.getNodePort()
-                .orElseGet(() -> getStablePortNumberInRange(name, MIN_NODE_PORT_VALUE, MAX_NODE_PORT_VALUE)))));
+        List<PortConfig> nodeConfigPorts = config.getPorts().values().stream().filter(pc -> pc.nodePort.isPresent())
+                .collect(Collectors.toList());
+        if (!nodeConfigPorts.isEmpty()) {
+            for (PortConfig portConfig : nodeConfigPorts) {
+                result.add(new DecoratorBuildItem(KUBERNETES,
+                        new AddNodePortDecorator(name, portConfig.nodePort.getAsInt(), portConfig.containerPort)));
+            }
+        } else {
+            result.add(new DecoratorBuildItem(MINIKUBE, new AddNodePortDecorator(name, config.getNodePort()
+                    .orElseGet(() -> getStablePortNumberInRange(name, MIN_NODE_PORT_VALUE, MAX_NODE_PORT_VALUE)))));
+        }
 
         //Probe port handling
         Integer port = ports.stream().filter(p -> HTTP_PORT.equals(p.getName())).map(KubernetesPortBuildItem::getPort)
diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/AddNodePortDecorator.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/AddNodePortDecorator.java
index 045b7419a0bdba..39b51a73b79686 100644
--- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/AddNodePortDecorator.java
+++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/AddNodePortDecorator.java
@@ -2,11 +2,15 @@
 
 import static io.quarkus.kubernetes.deployment.Constants.*;
 
+import java.util.OptionalInt;
+import java.util.function.Predicate;
+
 import org.jboss.logging.Logger;
 
 import io.dekorate.kubernetes.decorator.Decorator;
 import io.dekorate.kubernetes.decorator.NamedResourceDecorator;
 import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ServicePortBuilder;
 import io.fabric8.kubernetes.api.model.ServiceSpecFluent;
 
 public class AddNodePortDecorator extends NamedResourceDecorator<ServiceSpecFluent> {
@@ -14,21 +18,40 @@ public class AddNodePortDecorator extends NamedResourceDecorator<ServiceSpecFlue
     private static final Logger log = Logger.getLogger(AddNodePortDecorator.class);
 
     private final int nodePort;
+    private final OptionalInt matchingTargetPort;
 
     public AddNodePortDecorator(String name, int nodePort) {
+        this(name, nodePort, OptionalInt.empty());
+    }
+
+    public AddNodePortDecorator(String name, int nodePort, OptionalInt matchingTargetPort) {
         super(name);
         if (nodePort < MIN_NODE_PORT_VALUE || nodePort > MAX_NODE_PORT_VALUE) {
             log.info("Using a port outside of the " + MIN_NODE_PORT_VALUE + "-" + MAX_NODE_PORT_VALUE
                     + " range might not work, see https://kubernetes.io/docs/concepts/services-networking/service/#nodeport");
         }
         this.nodePort = nodePort;
+        this.matchingTargetPort = matchingTargetPort;
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public void andThenVisit(ServiceSpecFluent service, ObjectMeta resourceMeta) {
-        ServiceSpecFluent.PortsNested<?> editFirstPort = service.editFirstPort();
-        editFirstPort.withNodePort(nodePort);
-        editFirstPort.endPort();
+        ServiceSpecFluent.PortsNested<?> editPort;
+        if (matchingTargetPort.isPresent()) {
+            editPort = service.editMatchingPort(new Predicate<ServicePortBuilder>() {
+                @Override
+                public boolean test(ServicePortBuilder servicePortBuilder) {
+                    return (servicePortBuilder.buildTargetPort() != null)
+                            && (servicePortBuilder.buildTargetPort().getIntVal() != null)
+                            && (matchingTargetPort.getAsInt() == servicePortBuilder.buildTargetPort().getIntVal());
+                }
+            });
+        } else {
+            editPort = service.editFirstPort();
+        }
+        editPort.withNodePort(nodePort);
+        editPort.endPort();
     }
 
     @Override
diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PortConfig.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PortConfig.java
index 67eda9a8f4372f..0a431297b3b792 100644
--- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PortConfig.java
+++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PortConfig.java
@@ -15,13 +15,13 @@ public class PortConfig {
      * The port number. Refers to the container port.
      */
     @ConfigItem
-    OptionalInt containerPort;
+    public OptionalInt containerPort;
 
     /**
      * The host port.
      */
     @ConfigItem
-    OptionalInt hostPort;
+    public OptionalInt hostPort;
 
     /**
      * The application path (refers to web application path).
@@ -29,12 +29,18 @@ public class PortConfig {
      * @return The path, defaults to /.
      */
     @ConfigItem(defaultValue = "/")
-    Optional<String> path;
+    public Optional<String> path;
 
     /**
      * The protocol.
      */
     @ConfigItem(defaultValue = "TCP")
-    Protocol protocol;
+    public Protocol protocol;
+
+    /**
+     * The nodePort to which this port should be mapped to.
+     * This only takes affect when the serviceType is set to node-port.
+     */
+    public OptionalInt nodePort;
 
 }
diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/VanillaKubernetesProcessor.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/VanillaKubernetesProcessor.java
index beae8e595eb5ce..75fa7635afaec6 100644
--- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/VanillaKubernetesProcessor.java
+++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/VanillaKubernetesProcessor.java
@@ -10,6 +10,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import io.dekorate.kubernetes.annotation.ServiceType;
@@ -133,8 +134,17 @@ public List<DecoratorBuildItem> createDecorators(ApplicationInfoBuildItem applic
 
         // Service handling
         result.add(new DecoratorBuildItem(KUBERNETES, new ApplyServiceTypeDecorator(name, config.getServiceType().name())));
-        if ((config.getServiceType() == ServiceType.NodePort) && config.nodePort.isPresent()) {
-            result.add(new DecoratorBuildItem(KUBERNETES, new AddNodePortDecorator(name, config.nodePort.getAsInt())));
+        if ((config.getServiceType() == ServiceType.NodePort)) {
+            List<PortConfig> nodeConfigPorts = config.ports.values().stream().filter(pc -> pc.nodePort.isPresent())
+                    .collect(Collectors.toList());
+            if (!nodeConfigPorts.isEmpty()) {
+                for (PortConfig portConfig : nodeConfigPorts) {
+                    result.add(new DecoratorBuildItem(KUBERNETES,
+                            new AddNodePortDecorator(name, portConfig.nodePort.getAsInt(), portConfig.containerPort)));
+                }
+            } else if (config.nodePort.isPresent()) {
+                result.add(new DecoratorBuildItem(KUBERNETES, new AddNodePortDecorator(name, config.nodePort.getAsInt())));
+            }
         }
 
         // Probe port handling
diff --git a/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithMultiplePortsTest.java b/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithMultiplePortsTest.java
new file mode 100644
index 00000000000000..0e784fb82ef527
--- /dev/null
+++ b/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithMultiplePortsTest.java
@@ -0,0 +1,81 @@
+package io.quarkus.it.kubernetes;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.JavaArchive;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.quarkus.test.ProdBuildResults;
+import io.quarkus.test.ProdModeTestResults;
+import io.quarkus.test.QuarkusProdModeTest;
+
+public class KubernetesWithMultiplePortsTest {
+
+    @RegisterExtension
+    static final QuarkusProdModeTest config = new QuarkusProdModeTest()
+            .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class).addClasses(GreetingResource.class))
+            .setApplicationName("kubernetes-with-multiple-ports")
+            .setApplicationVersion("0.1-SNAPSHOT")
+            .withConfigurationResource("kubernetes-with-multiple-ports.properties");
+
+    @ProdBuildResults
+    private ProdModeTestResults prodModeTestResults;
+
+    @Test
+    public void assertGeneratedResources() throws IOException {
+        Path kubernetesDir = prodModeTestResults.getBuildDir().resolve("kubernetes");
+        assertThat(kubernetesDir)
+                .isDirectoryContaining(p -> p.getFileName().endsWith("kubernetes.json"))
+                .isDirectoryContaining(p -> p.getFileName().endsWith("kubernetes.yml"));
+        List<HasMetadata> kubernetesList = DeserializationUtil
+                .deserializeAsList(kubernetesDir.resolve("kubernetes.yml"));
+
+        assertThat(kubernetesList).hasSize(2);
+
+        assertThat(kubernetesList).filteredOn(i -> "Deployment".equals(i.getKind())).singleElement().satisfies(i -> {
+            assertThat(i).isInstanceOfSatisfying(Deployment.class, d -> {
+                assertThat(d.getMetadata()).satisfies(m -> {
+                    assertThat(m.getName()).isEqualTo("kubernetes-with-multiple-ports");
+                });
+
+                assertThat(d.getSpec()).satisfies(deploymentSpec -> {
+                    assertThat(deploymentSpec.getTemplate()).satisfies(t -> {
+                        assertThat(t.getSpec()).satisfies(podSpec -> {
+                            assertThat(podSpec.getContainers()).singleElement().satisfies(container -> {
+                                assertThat(container.getPorts()).hasSize(2);
+                                assertThat(container.getPorts()).filteredOn(cp -> cp.getContainerPort() == 8080).hasSize(1);
+                                assertThat(container.getPorts()).filteredOn(cp -> cp.getContainerPort() == 5005).hasSize(1);
+                            });
+                        });
+                    });
+                });
+            });
+        });
+
+        assertThat(kubernetesList).filteredOn(i -> "Service".equals(i.getKind())).singleElement().satisfies(i -> {
+            assertThat(i).isInstanceOfSatisfying(Service.class, s -> {
+                assertThat(s.getSpec()).satisfies(spec -> {
+                    assertEquals("NodePort", spec.getType());
+                    assertThat(spec.getPorts()).hasSize(2);
+                    assertThat(spec.getPorts()).filteredOn(sp -> sp.getPort() == 8080).singleElement().satisfies(p -> {
+                        assertThat(p.getNodePort()).isEqualTo(30000);
+                    });
+                    assertThat(spec.getPorts()).filteredOn(sp -> sp.getPort() == 5005).singleElement().satisfies(p -> {
+                        assertThat(p.getNodePort()).isEqualTo(31000);
+                    });
+                });
+            });
+        });
+    }
+
+}
diff --git a/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-multiple-ports.properties b/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-multiple-ports.properties
new file mode 100644
index 00000000000000..e3ef96683ea626
--- /dev/null
+++ b/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-multiple-ports.properties
@@ -0,0 +1,9 @@
+quarkus.kubernetes.ports.http.name=http
+quarkus.kubernetes.ports.http.host-port=8080
+quarkus.kubernetes.ports.http.container-port=8080
+quarkus.kubernetes.ports.http.node-port=30000
+quarkus.kubernetes.ports.remote.name=http
+quarkus.kubernetes.ports.remote.host-port=5005
+quarkus.kubernetes.ports.remote.container-port=5005
+quarkus.kubernetes.ports.remote.node-port=31000
+quarkus.kubernetes.service-type=NodePort