diff --git a/jmx-scraper/README.md b/jmx-scraper/README.md index 49d80e49c..46ce69441 100644 --- a/jmx-scraper/README.md +++ b/jmx-scraper/README.md @@ -81,8 +81,19 @@ be set through the standard `JAVA_TOOL_OPTIONS` environment variable using the ` ## Troubleshooting +### Exported metrics + In order to investigate when and what metrics are being captured and sent, setting the `otel.metrics.exporter` -configuration option to include `logging` exporter provides log messages when metrics are being exported. +configuration option to include `logging` exporter provides log messages when metrics are being exported + +### JMX connection test + +Connection to the remote JVM through the JMX can be tested by adding the `-test` argument. +When doing so, the JMX Scraper will only test the connection to the remote JVM with provided configuration +and exit. + +- Connection OK: `JMX connection test OK` message is written to standard output and exit status = `0` +- Connection ERROR: `JMX connection test ERROR` message is written to standard output and exit status = `1` ## Extra libraries in classpath diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectionTest.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectionTest.java new file mode 100644 index 000000000..8509ab043 --- /dev/null +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectionTest.java @@ -0,0 +1,133 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.jmxscraper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Function; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.Slf4jLogConsumer; + +/** + * Tests all supported ways to connect to remote JMX interface. This indirectly tests + * JmxConnectionBuilder and relies on containers to minimize the JMX/RMI network complications which + * are not NAT-friendly. + */ +public class JmxConnectionTest { + + // OTLP endpoint is not used in test mode, but still has to be provided + private static final String DUMMY_OTLP_ENDPOINT = "http://dummy-otlp-endpoint:8080/"; + private static final String SCRAPER_BASE_IMAGE = "openjdk:8u342-jre-slim"; + + private static final int JMX_PORT = 9999; + private static final String APP_HOST = "app"; + + private static final Logger jmxScraperLogger = LoggerFactory.getLogger("JmxScraperContainer"); + private static final Logger appLogger = LoggerFactory.getLogger("TestAppContainer"); + + private static Network network; + + @BeforeAll + static void beforeAll() { + network = Network.newNetwork(); + } + + @AfterAll + static void afterAll() { + network.close(); + } + + @Test + void connectionError() { + try (JmxScraperContainer scraper = scraperContainer().withRmiServiceUrl("unknown_host", 1234)) { + scraper.start(); + waitTerminated(scraper); + checkConnectionLogs(scraper, /* expectedOk= */ false); + } + } + + @Test + void connectNoAuth() { + connectionTest( + app -> app.withJmxPort(JMX_PORT), scraper -> scraper.withRmiServiceUrl(APP_HOST, JMX_PORT)); + } + + @Test + void userPassword() { + String login = "user"; + String pwd = "t0p!Secret"; + connectionTest( + app -> app.withJmxPort(JMX_PORT).withUserAuth(login, pwd), + scraper -> scraper.withRmiServiceUrl(APP_HOST, JMX_PORT).withUser(login).withPassword(pwd)); + } + + private static void connectionTest( + Function customizeApp, + Function customizeScraper) { + try (TestAppContainer app = customizeApp.apply(appContainer())) { + app.start(); + try (JmxScraperContainer scraper = customizeScraper.apply(scraperContainer())) { + scraper.start(); + waitTerminated(scraper); + checkConnectionLogs(scraper, /* expectedOk= */ true); + } + } + } + + private static void checkConnectionLogs(JmxScraperContainer scraper, boolean expectedOk) { + + String[] logLines = scraper.getLogs().split("\n"); + String lastLine = logLines[logLines.length - 1]; + + if (expectedOk) { + assertThat(lastLine) + .describedAs("should log connection success") + .endsWith("JMX connection test OK"); + } else { + assertThat(lastLine) + .describedAs("should log connection failure") + .endsWith("JMX connection test ERROR"); + } + } + + private static void waitTerminated(GenericContainer container) { + int retries = 10; + while (retries > 0 && container.isRunning()) { + retries--; + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + assertThat(retries) + .describedAs("container should stop when testing connection") + .isNotEqualTo(0); + } + + private static JmxScraperContainer scraperContainer() { + return new JmxScraperContainer(DUMMY_OTLP_ENDPOINT, SCRAPER_BASE_IMAGE) + .withLogConsumer(new Slf4jLogConsumer(jmxScraperLogger)) + .withNetwork(network) + // mandatory to have a target system even if we don't collect metrics + .withTargetSystem("jvm") + // we are only testing JMX connection here + .withTestJmx(); + } + + private static TestAppContainer appContainer() { + return new TestAppContainer() + .withLogConsumer(new Slf4jLogConsumer(appLogger)) + .withNetwork(network) + .withNetworkAliases(APP_HOST); + } +} diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectorBuilderTest.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectorBuilderTest.java deleted file mode 100644 index f3f931e58..000000000 --- a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxConnectorBuilderTest.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.contrib.jmxscraper; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import javax.management.ObjectName; -import javax.management.remote.JMXConnector; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.Network; - -public class JmxConnectorBuilderTest { - - private static Network network; - - @BeforeAll - static void beforeAll() { - network = Network.newNetwork(); - } - - @AfterAll - static void afterAll() { - network.close(); - } - - @Test - void noAuth() { - int port = PortSelector.getAvailableRandomPort(); - try (TestAppContainer app = new TestAppContainer().withHostAccessFixedJmxPort(port)) { - app.start(); - testConnector( - () -> JmxConnectorBuilder.createNew(app.getHost(), app.getMappedPort(port)).build()); - } - } - - @Test - void loginPwdAuth() { - int port = PortSelector.getAvailableRandomPort(); - String login = "user"; - String pwd = "t0p!Secret"; - try (TestAppContainer app = - new TestAppContainer().withHostAccessFixedJmxPort(port).withUserAuth(login, pwd)) { - app.start(); - testConnector( - () -> - JmxConnectorBuilder.createNew(app.getHost(), app.getMappedPort(port)) - .withUser(login) - .withPassword(pwd) - .build()); - } - } - - @Test - void serverSSL() { - // TODO: test with SSL enabled as RMI registry seems to work differently with SSL - - // create keypair (public,private) - // create server keystore with private key - // configure server keystore - // - // create client truststore with public key - // can we configure to use a custom truststore ??? - // connect to server - } - - private static void testConnector(ConnectorSupplier connectorSupplier) { - try (JMXConnector connector = connectorSupplier.get()) { - assertThat(connector.getMBeanServerConnection()) - .isNotNull() - .satisfies( - connection -> { - try { - ObjectName name = new ObjectName("io.opentelemetry.test:name=TestApp"); - Object value = connection.getAttribute(name, "IntValue"); - assertThat(value).isEqualTo(42); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private interface ConnectorSupplier { - JMXConnector get() throws IOException; - } -} diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxScraperContainer.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxScraperContainer.java index 455b53c73..691c2fa08 100644 --- a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxScraperContainer.java +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/JmxScraperContainer.java @@ -28,6 +28,7 @@ public class JmxScraperContainer extends GenericContainer { private String user; private String password; private final List extraJars; + private boolean testJmx; public JmxScraperContainer(String otlpEndpoint, String baseImage) { super(baseImage); @@ -35,10 +36,7 @@ public JmxScraperContainer(String otlpEndpoint, String baseImage) { String scraperJarPath = System.getProperty("shadow.jar.path"); assertThat(scraperJarPath).isNotNull(); - this.withCopyFileToContainer(MountableFile.forHostPath(scraperJarPath), "/scraper.jar") - .waitingFor( - Wait.forLogMessage(".*JMX scraping started.*", 1) - .withStartupTimeout(Duration.ofSeconds(10))); + this.withCopyFileToContainer(MountableFile.forHostPath(scraperJarPath), "/scraper.jar"); this.endpoint = otlpEndpoint; this.targetSystems = new HashSet<>(); @@ -108,6 +106,12 @@ public JmxScraperContainer withCustomYaml(String yamlPath) { return this; } + @CanIgnoreReturnValue + public JmxScraperContainer withTestJmx() { + this.testJmx = true; + return this; + } + @Override public void start() { // for now only configure through JVM args @@ -152,6 +156,15 @@ public void start() { arguments.add("io.opentelemetry.contrib.jmxscraper.JmxScraper"); } + if (testJmx) { + arguments.add("-test"); + this.waitingFor(Wait.forLogMessage(".*JMX connection test.*", 1)); + } else { + this.waitingFor( + Wait.forLogMessage(".*JMX scraping started.*", 1) + .withStartupTimeout(Duration.ofSeconds(10))); + } + this.withCommand(arguments.toArray(new String[0])); logger().info("Starting scraper with command: " + String.join(" ", arguments)); diff --git a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppContainer.java b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppContainer.java index 649153a41..ee88451fe 100644 --- a/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppContainer.java +++ b/jmx-scraper/src/integrationTest/java/io/opentelemetry/contrib/jmxscraper/TestAppContainer.java @@ -61,28 +61,6 @@ public TestAppContainer withUserAuth(String login, String pwd) { return this; } - /** - * Configures app container for host-to-container access, port will be used as-is from host to - * work-around JMX in docker. This is optional on Linux as there is a network route and the - * container is accessible, but not on Mac where the container runs in an isolated VM. - * - * @param port port to use, must be available on host. - * @return this - */ - @CanIgnoreReturnValue - public TestAppContainer withHostAccessFixedJmxPort(int port) { - // To get host->container JMX connection working docker must expose JMX/RMI port under the same - // port number. Because of this testcontainers' standard exposed port randomization approach - // can't be used. - // Explanation: - // https://forums.docker.com/t/exposing-mapped-jmx-ports-from-multiple-containers/5287/6 - properties.put("com.sun.management.jmxremote.port", Integer.toString(port)); - properties.put("com.sun.management.jmxremote.rmi.port", Integer.toString(port)); - properties.put("java.rmi.server.hostname", getHost()); - addFixedExposedPort(port, port); - return this; - } - @Override public void start() { // TODO: add support for ssl diff --git a/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/JmxScraper.java b/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/JmxScraper.java index 29156c67c..ad8c8b7f4 100644 --- a/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/JmxScraper.java +++ b/jmx-scraper/src/main/java/io/opentelemetry/contrib/jmxscraper/JmxScraper.java @@ -19,6 +19,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -34,6 +35,7 @@ public class JmxScraper { private static final Logger logger = Logger.getLogger(JmxScraper.class.getName()); private static final String CONFIG_ARG = "-config"; + private static final String TEST_ARG = "-test"; private final JmxConnectorBuilder client; private final JmxMetricInsight service; @@ -52,8 +54,11 @@ public static void main(String[] args) { // set log format System.setProperty("java.util.logging.SimpleFormatter.format", "%1$tF %1$tT %4$s %5$s%n"); + List effectiveArgs = new ArrayList<>(Arrays.asList(args)); + boolean testMode = effectiveArgs.remove(TEST_ARG); + try { - Properties argsConfig = parseArgs(Arrays.asList(args)); + Properties argsConfig = argsToConfig(effectiveArgs); propagateToSystemProperties(argsConfig); // auto-configure and register SDK @@ -78,16 +83,21 @@ public static void main(String[] args) { Optional.ofNullable(scraperConfig.getUsername()).ifPresent(connectorBuilder::withUser); Optional.ofNullable(scraperConfig.getPassword()).ifPresent(connectorBuilder::withPassword); - JmxScraper jmxScraper = new JmxScraper(connectorBuilder, service, scraperConfig); - jmxScraper.start(); + if (testMode) { + System.exit(testConnection(connectorBuilder) ? 0 : 1); + } else { + JmxScraper jmxScraper = new JmxScraper(connectorBuilder, service, scraperConfig); + jmxScraper.start(); + } } catch (ConfigurationException e) { logger.log(Level.SEVERE, "invalid configuration ", e); System.exit(1); } catch (InvalidArgumentException e) { - logger.log(Level.SEVERE, "invalid configuration provided through arguments", e); + logger.log(Level.SEVERE, e.getMessage(), e); + logger.info("Usage: java -jar [-test] [-config ]"); + logger.info(" -test test JMX connection with provided configuration and exit"); logger.info( - "Usage: java -jar " - + "-config "); + " -config provide configuration, where is - for stdin, or "); System.exit(1); } catch (IOException e) { logger.log(Level.SEVERE, "Unable to connect ", e); @@ -98,6 +108,24 @@ public static void main(String[] args) { } } + private static boolean testConnection(JmxConnectorBuilder connectorBuilder) { + try (JMXConnector connector = connectorBuilder.build()) { + + MBeanServerConnection connection = connector.getMBeanServerConnection(); + Integer mbeanCount = connection.getMBeanCount(); + if (mbeanCount > 0) { + logger.log(Level.INFO, "JMX connection test OK"); + return true; + } else { + logger.log(Level.SEVERE, "JMX connection test ERROR"); + return false; + } + } catch (IOException e) { + logger.log(Level.SEVERE, "JMX connection test ERROR", e); + return false; + } + } + // package private for testing static void propagateToSystemProperties(Properties properties) { for (Map.Entry entry : properties.entrySet()) { @@ -116,7 +144,7 @@ static void propagateToSystemProperties(Properties properties) { * * @param args application commandline arguments */ - static Properties parseArgs(List args) throws InvalidArgumentException { + static Properties argsToConfig(List args) throws InvalidArgumentException { if (args.isEmpty()) { // empty properties from stdin or external file @@ -124,10 +152,10 @@ static Properties parseArgs(List args) throws InvalidArgumentException { return new Properties(); } if (args.size() != 2) { - throw new InvalidArgumentException("Exactly two arguments expected, got " + args.size()); + throw new InvalidArgumentException("Unexpected number of arguments"); } if (!args.get(0).equalsIgnoreCase(CONFIG_ARG)) { - throw new InvalidArgumentException("Unexpected first argument must be '" + CONFIG_ARG + "'"); + throw new InvalidArgumentException("Unexpected argument must be '" + CONFIG_ARG + "'"); } String path = args.get(1); @@ -155,8 +183,7 @@ private static Properties loadPropertiesFromPath(String path) throws InvalidArgu properties.load(is); return properties; } catch (IOException e) { - throw new InvalidArgumentException( - "Failed to read config properties file: '" + path + "'", e); + throw new InvalidArgumentException("Failed to read config from file: '" + path + "'", e); } } diff --git a/jmx-scraper/src/test/java/io/opentelemetry/contrib/jmxscraper/JmxScraperTest.java b/jmx-scraper/src/test/java/io/opentelemetry/contrib/jmxscraper/JmxScraperTest.java index 1532715d9..2d3178072 100644 --- a/jmx-scraper/src/test/java/io/opentelemetry/contrib/jmxscraper/JmxScraperTest.java +++ b/jmx-scraper/src/test/java/io/opentelemetry/contrib/jmxscraper/JmxScraperTest.java @@ -30,8 +30,8 @@ void shouldThrowExceptionWhenInvalidCommandLineArgsProvided() { @Test void emptyArgumentsAllowed() throws InvalidArgumentException { - assertThat(JmxScraper.parseArgs(Collections.emptyList())) - .describedAs("empty arguments allowed to use JVM properties") + assertThat(JmxScraper.argsToConfig(Collections.emptyList())) + .describedAs("empty config allowed to use JVM properties") .isEmpty(); } @@ -41,7 +41,7 @@ void shouldThrowExceptionWhenMissingProperties() { } private static void testInvalidArguments(String... args) { - assertThatThrownBy(() -> JmxScraper.parseArgs(Arrays.asList(args))) + assertThatThrownBy(() -> JmxScraper.argsToConfig(Arrays.asList(args))) .isInstanceOf(InvalidArgumentException.class); } @@ -53,7 +53,7 @@ void shouldCreateConfig_propertiesLoadedFromFile() throws InvalidArgumentExcepti List args = Arrays.asList("-config", filePath); // When - Properties parsedConfig = JmxScraper.parseArgs(args); + Properties parsedConfig = JmxScraper.argsToConfig(args); JmxScraperConfig config = JmxScraperConfig.fromConfig(TestUtil.configProperties(parsedConfig)); // Then @@ -72,7 +72,7 @@ void shouldCreateConfig_propertiesLoadedFromStdIn() throws InvalidArgumentExcept List args = Arrays.asList("-config", "-"); // When - Properties parsedConfig = JmxScraper.parseArgs(args); + Properties parsedConfig = JmxScraper.argsToConfig(args); JmxScraperConfig config = JmxScraperConfig.fromConfig(TestUtil.configProperties(parsedConfig));