This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-24823-infra-artemis-ports in repository https://gitbox.apache.org/repos/asf/camel.git
commit d19a655e803112619fa2a2e007e742dcd2009d73 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 12 19:45:37 2026 +0100 CAMEL-24823: camel-jbang - camel infra run artemis maps MQTT and AMQP only on request, and a taken port is one line camel infra run artemis mapped 1883 and 5672 on the host, the ports of mosquitto and RabbitMQ, so it could not run next to them. It now maps the core acceptor and the web console, and MQTT and AMQP with --property artemis.ports=mqtt,amqp (or all). A service that does not start because a port it maps on the host is taken fails with one line naming the port and the infra service that has it, instead of the stack trace of the container runtime. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 ++ .../ROOT/pages/camel-jbang-dev-services.adoc | 18 ++++ .../jbang/core/commands/infra/InfraPortInUse.java | 106 +++++++++++++++++++++ .../dsl/jbang/core/commands/infra/InfraRun.java | 32 ++++++- .../core/commands/infra/InfraPortInUseTest.java | 97 +++++++++++++++++++ .../infra/artemis/common/ArtemisProperties.java | 6 ++ .../infra/artemis/services/ArtemisContainer.java | 50 +++++++++- .../services/ArtemisContainerPortsTest.java | 57 +++++++++++ 8 files changed, 371 insertions(+), 5 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 93adc90a66f1..f501728aad1d 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -8263,3 +8263,13 @@ property of the bucket -- so `InfluxDb2Producer` never reads the option or the h Both are now deprecated with a note that says so. Remove `retentionPolicy` from the endpoint URI and stop setting the header: they are silently ignored. To control how long the data is kept, set the retention period on the bucket the endpoint writes to. + +=== camel-jbang - camel infra run artemis maps MQTT and AMQP only on request + +`camel infra run artemis` maps the core acceptor (61616) and the web console (8161) on the host, and no longer the +MQTT (1883) and AMQP (5672) ports, which are the ports of mosquitto and RabbitMQ: Artemis failed to start next to +`camel infra run mosquitto`. Map them with `--property artemis.ports=mqtt,amqp` (or `all`). +The `withFixedPort()` container of `camel-test-infra-artemis` maps the same ports, and reads the same property. + +A service that does not start because a port it maps on the host is taken now fails with one line that names the port, +and the infra service that has it, instead of the stack trace of the container runtime. diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-dev-services.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-dev-services.adoc index 616e9d307953..d85b3efc6007 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-dev-services.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-dev-services.adoc @@ -85,6 +85,24 @@ container image, and the settings of every other service. The property names are `camel infra restart` takes the options again, the way it takes `--port`, so a restart keeps the model it was started with. +Artemis maps the core acceptor (61616) and the web console (8161) on the host. MQTT (1883) and AMQP +(5672) are the ports of mosquitto and RabbitMQ, so Artemis maps them only when you ask: + +[source,bash] +---- +camel infra run artemis --property artemis.ports=mqtt,amqp +---- + +`all` maps both. When a port a service maps is taken, it does not start, and says which port and +which service has it: + +[source,bash] +---- +$ camel infra run artemis --property artemis.ports=mqtt +Starting service artemis (PID: 45390) +ERROR: Cannot start artemis: port 1883 is in use by mosquitto (PID 45211), stop it first with: camel infra stop mosquitto +---- + == Stopping a service [source,bash] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUse.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUse.java new file mode 100644 index 000000000000..e8962ae2519c --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUse.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.infra; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Why a service did not start when a port it maps on the host is taken: artemis maps 1883 when mosquitto has it, and + * Docker fails with "Bind for 0.0.0.0:1883 failed: port is already allocated" deep in a stack trace (CAMEL-24823). + */ +final class InfraPortInUse { + + // Docker: "Bind for 0.0.0.0:1883 failed: port is already allocated" + // Podman: "rootlessport listen tcp 0.0.0.0:1883: bind: address already in use" + private static final String[] IN_USE = { "port is already allocated", "address already in use" }; + private static final Pattern PORT = Pattern.compile(":(\\d{1,5})(?!\\d)"); + + private InfraPortInUse() { + } + + /** + * The port on the host that a failure to start says is taken. + * + * @param failure the failure of the start, with its causes + * @return the port, or -1 when the failure is not about a port in use, or does not name it + */ + static int port(Throwable failure) { + Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable t = failure; t != null && seen.add(t); t = t.getCause()) { + int port = port(t.getMessage()); + if (port > 0) { + return port; + } + } + return -1; + } + + static int port(String message) { + if (message == null) { + return -1; + } + String lower = message.toLowerCase(Locale.ROOT); + for (String inUse : IN_USE) { + int end = lower.indexOf(inUse); + if (end > 0) { + // the last port before the complaint is the one on the host + Matcher m = PORT.matcher(message.substring(0, end)); + int port = -1; + while (m.find()) { + port = Integer.parseInt(m.group(1)); + } + if (port > 0 && port <= 65535) { + return port; + } + } + } + return -1; + } + + /** + * Whether the service data of a running service has the port: as a value ({@code "port":1883}) or in an address + * ({@code "tcp://localhost:1883"}). Not a port that only starts the same ({@code 18830}), nor a container id that + * starts with its digits. + */ + static boolean hasPort(String serviceData, int port) { + if (serviceData == null) { + return false; + } + return Pattern.compile(":\\s*\"?" + port + "(?!\\w)").matcher(serviceData).find(); + } + + /** + * The one line that tells the user which port is taken, by what, and the way out. + * + * @param service the service that did not start + * @param port the port that is taken + * @param holder the running infra service that has the port, or {@code null} when it is another program + * @param pid the pid of the holder + */ + static String message(String service, int port, String holder, String pid) { + if (holder != null) { + return "Cannot start " + service + ": port " + port + " is in use by " + holder + " (PID " + pid + + "), stop it first with: camel infra stop " + holder; + } + return "Cannot start " + service + ": port " + port + " is in use by another program"; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraRun.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraRun.java index 913b3100ea01..ba2cfcbd977d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraRun.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraRun.java @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.infra; import java.io.File; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; import java.nio.file.Files; @@ -256,7 +257,15 @@ public class InfraRun extends InfraBaseCommand { printer().println("Starting service " + testService + prefix + " (PID: " + RuntimeUtil.getPid() + ")"); } - actualService.getClass().getMethod("initialize").invoke(actualService); + try { + actualService.getClass().getMethod("initialize").invoke(actualService); + } catch (InvocationTargetException e) { + String message = portInUseMessage(testService, e.getCause()); + if (message != null) { + throw CliException.failure(message); + } + throw e; + } Method[] serviceMethods = cl.loadClass(serviceInterface).getDeclaredMethods(); Map<String, Object> properties = new TreeMap<>(); @@ -359,6 +368,27 @@ public class InfraRun extends InfraBaseCommand { return 0; } + /** + * The one line for a service that did not start because a port it maps on the host is taken, naming the running + * infra service that has it, instead of the stack trace of the container runtime. + * + * @param service the service that did not start + * @param failure why it did not start + * @return the line, or {@code null} when the failure is not about a port in use + */ + String portInUseMessage(String service, Throwable failure) { + int port = InfraPortInUse.port(failure); + if (port < 0) { + return null; + } + for (RunningService running : findRunningServices(null)) { + if (InfraPortInUse.hasPort(readServiceData(running.pidFile()), port)) { + return InfraPortInUse.message(service, port, running.alias(), running.pid()); + } + } + return InfraPortInUse.message(service, port, null, null); + } + /** * Reads the --property options, which is how a service takes a setting the CLI has no flag of its own for, such as * the model of ollama. Only the pairs are read here, so that a malformed one is refused before anything is set and diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUseTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUseTest.java new file mode 100644 index 000000000000..615f08e6f6a2 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraPortInUseTest.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.infra; + +import java.net.BindException; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A service that does not start because a port it maps on the host is taken says so in one line, naming the infra + * service that has the port (CAMEL-24823). + */ +class InfraPortInUseTest extends InfraCommandTestSupport { + + // as Docker and testcontainers report it, a few causes down + private static final RuntimeException DOCKER = new RuntimeException( + "Container startup failed for image artemis", + new RuntimeException( + "Could not create/start container", + new RuntimeException( + "Status 500: {\"message\":\"failed to set up container networking: driver failed" + + " programming external connectivity on endpoint camel-artemis (3f2a9c1b7d4e):" + + " Bind for 0.0.0.0:1883 failed: port is already allocated\"}"))); + + @Test + void thePortDockerSaysIsTaken() { + assertThat(InfraPortInUse.port(DOCKER)).isEqualTo(1883); + assertThat(InfraPortInUse.port("Bind for [::]:5672 failed: port is already allocated")).isEqualTo(5672); + } + + @Test + void thePortPodmanSaysIsTaken() { + assertThat(InfraPortInUse.port( + "rootlessport listen tcp 0.0.0.0:1883: bind: address already in use")).isEqualTo(1883); + } + + @Test + void notAboutAPortInUse() { + assertThat(InfraPortInUse.port(new RuntimeException("Could not pull image: manifest unknown"))).isEqualTo(-1); + // an embedded service does not say which port + assertThat(InfraPortInUse.port(new BindException("Address already in use"))).isEqualTo(-1); + assertThat(InfraPortInUse.port((Throwable) null)).isEqualTo(-1); + } + + @Test + void theServiceDataOfTheServiceThatHasThePort() { + assertThat(InfraPortInUse.hasPort("{\"port\":1883}", 1883)).isTrue(); + assertThat(InfraPortInUse.hasPort("{\"getServiceAddress\":\"tcp://localhost:1883\"}", 1883)).isTrue(); + assertThat(InfraPortInUse.hasPort("{\"port\":\"1883\"}", 1883)).isTrue(); + // not another port that starts the same, nor a container id + assertThat(InfraPortInUse.hasPort("{\"port\":18830}", 1883)).isFalse(); + assertThat(InfraPortInUse.hasPort("{\"containerId\":\"1883ab7d4e9f\"}", 1883)).isFalse(); + assertThat(InfraPortInUse.hasPort(null, 1883)).isFalse(); + } + + @Test + void aPortThatARunningInfraServiceHas() throws Exception { + writePidFile("mosquitto", 4321, "{\"getPort\":1883,\"serviceAddress\":\"tcp://localhost:1883\"}"); + writePidFile("kafka", 1234, "{\"getBootstrapServers\":\"localhost:9092\"}"); + + assertThat(command().portInUseMessage("artemis", DOCKER)).isEqualTo( + "Cannot start artemis: port 1883 is in use by mosquitto (PID 4321), stop it first with: camel infra stop mosquitto"); + } + + @Test + void aPortThatAnotherProgramHas() throws Exception { + writePidFile("kafka", 1234, "{\"getBootstrapServers\":\"localhost:9092\"}"); + + assertThat(command().portInUseMessage("artemis", DOCKER)) + .isEqualTo("Cannot start artemis: port 1883 is in use by another program"); + assertThat(command().portInUseMessage("artemis", new RuntimeException("Could not pull image"))).isNull(); + } + + private InfraRun command() { + InfraRun command = new InfraRun(new CamelJBangMain().withPrinter(printer)); + // the pid files carry synthetic pids, so treat every pid as a live process + command.aliveCheck = pid -> true; + return command; + } +} diff --git a/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/common/ArtemisProperties.java b/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/common/ArtemisProperties.java index d48869238cdd..32a32c74a73a 100644 --- a/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/common/ArtemisProperties.java +++ b/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/common/ArtemisProperties.java @@ -29,6 +29,12 @@ public final class ArtemisProperties { public static final String ARTEMIS_SSL_KEYSTORE_PASSWORD = "artemis.ssl.keystore.password"; public static final String ARTEMIS_SSL_TRUSTSTORE_PATH = "artemis.ssl.truststore.path"; public static final String ARTEMIS_SSL_TRUSTSTORE_PASSWORD = "artemis.ssl.truststore.password"; + /** + * The protocols whose ports {@code camel infra run artemis} maps on the host as well, comma separated: {@code mqtt} + * (1883), {@code amqp} (5672) or {@code all}. By default only the core acceptor (61616) and the web console (8161) + * are, as mosquitto and RabbitMQ use the others. + */ + public static final String ARTEMIS_PORTS = "artemis.ports"; private ArtemisProperties() { diff --git a/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/services/ArtemisContainer.java b/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/services/ArtemisContainer.java index ee4c5f83fa34..3b181b66f11d 100644 --- a/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/services/ArtemisContainer.java +++ b/test-infra/camel-test-infra-artemis/src/main/java/org/apache/camel/test/infra/artemis/services/ArtemisContainer.java @@ -16,9 +16,16 @@ */ package org.apache.camel.test.infra.artemis.services; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + import org.apache.camel.test.infra.artemis.common.ArtemisProperties; import org.apache.camel.test.infra.common.LocalPropertyResolver; import org.apache.camel.test.infra.common.services.ContainerEnvironmentUtil; +import org.apache.camel.test.infra.common.services.ContainerEnvironmentUtil.PortConfig; import org.apache.camel.test.infra.messaging.services.MessagingContainer; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; @@ -48,17 +55,52 @@ public class ArtemisContainer extends GenericContainer<ArtemisContainer> impleme .waitingFor(Wait.forListeningPort()); } + /** + * A container with its ports on the same ports of the host, as {@code camel infra run artemis} runs it: the core + * acceptor (61616) and the web console (8161), and MQTT and AMQP only when {@link ArtemisProperties#ARTEMIS_PORTS} + * asks for them, as mosquitto (1883) and RabbitMQ (5672) use the same ports. + */ public static ArtemisContainer withFixedPort() { ArtemisContainer container = new ArtemisContainer(); ContainerEnvironmentUtil.configurePorts(container, true, - ContainerEnvironmentUtil.PortConfig.primary(DEFAULT_ACCEPTOR_PORT), - ContainerEnvironmentUtil.PortConfig.secondary(DEFAULT_MQTT_PORT), - ContainerEnvironmentUtil.PortConfig.secondary(DEFAULT_AMQP_PORT), - ContainerEnvironmentUtil.PortConfig.secondary(DEFAULT_ADMIN_PORT)); + fixedPorts(System.getProperty(ArtemisProperties.ARTEMIS_PORTS)).toArray(PortConfig[]::new)); return container; } + /** + * The ports to map on the same ports of the host. + * + * @param protocols the protocols to map as well, comma separated ({@code mqtt}, {@code amqp}, + * {@code all}), or {@code null} + * @return the ports + * @throws IllegalArgumentException for a protocol that is not one of them + */ + static List<PortConfig> fixedPorts(String protocols) { + Set<PortConfig> ports = new LinkedHashSet<>(); + ports.add(PortConfig.primary(DEFAULT_ACCEPTOR_PORT)); + ports.add(PortConfig.secondary(DEFAULT_ADMIN_PORT)); + if (protocols != null) { + for (String protocol : protocols.split(",")) { + switch (protocol.trim().toLowerCase(Locale.ROOT)) { + case "mqtt" -> ports.add(PortConfig.secondary(DEFAULT_MQTT_PORT)); + case "amqp" -> ports.add(PortConfig.secondary(DEFAULT_AMQP_PORT)); + case "all" -> { + ports.add(PortConfig.secondary(DEFAULT_MQTT_PORT)); + ports.add(PortConfig.secondary(DEFAULT_AMQP_PORT)); + } + case "" -> { + // nothing between two commas + } + default -> throw new IllegalArgumentException( + "Unknown protocol " + protocol.trim() + " in " + ArtemisProperties.ARTEMIS_PORTS + + ": use mqtt, amqp or all"); + } + } + } + return new ArrayList<>(ports); + } + /** * Gets the port number used for exchanging messages using the AMQP protocol * diff --git a/test-infra/camel-test-infra-artemis/src/test/java/org/apache/camel/test/infra/artemis/services/ArtemisContainerPortsTest.java b/test-infra/camel-test-infra-artemis/src/test/java/org/apache/camel/test/infra/artemis/services/ArtemisContainerPortsTest.java new file mode 100644 index 000000000000..e323a9a4a670 --- /dev/null +++ b/test-infra/camel-test-infra-artemis/src/test/java/org/apache/camel/test/infra/artemis/services/ArtemisContainerPortsTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.test.infra.artemis.services; + +import java.util.List; + +import org.apache.camel.test.infra.common.services.ContainerEnvironmentUtil.PortConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * camel infra run artemis maps the ports of MQTT and AMQP on the host only when asked, as mosquitto and RabbitMQ use + * the same ports (CAMEL-24823). + */ +class ArtemisContainerPortsTest { + + @Test + void theCoreAcceptorAndTheWebConsoleByDefault() { + assertEquals(List.of(PortConfig.primary(61616), PortConfig.secondary(8161)), ArtemisContainer.fixedPorts(null)); + } + + @Test + void mqttAndAmqpOnRequest() { + assertEquals(List.of(PortConfig.primary(61616), PortConfig.secondary(8161), PortConfig.secondary(1883)), + ArtemisContainer.fixedPorts("mqtt")); + assertEquals(List.of(PortConfig.primary(61616), PortConfig.secondary(8161), PortConfig.secondary(5672), + PortConfig.secondary(1883)), ArtemisContainer.fixedPorts(" AMQP, mqtt,")); + assertEquals(List.of(PortConfig.primary(61616), PortConfig.secondary(8161), PortConfig.secondary(1883), + PortConfig.secondary(5672)), ArtemisContainer.fixedPorts("all,mqtt")); + } + + @Test + void anUnknownProtocolIsRefused() { + IllegalArgumentException e + = assertThrows(IllegalArgumentException.class, () -> ArtemisContainer.fixedPorts("mqtt,stomp")); + + assertTrue(e.getMessage().contains("stomp"), e.getMessage()); + assertTrue(e.getMessage().contains("artemis.ports"), e.getMessage()); + } +}
