Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
Expand Down Expand Up @@ -44,6 +46,9 @@ public class JmxConnectionTest {

private static Network network;

// temporary folder for files that are copied to container
@TempDir private Path tempDir;

@BeforeAll
static void beforeAll() {
network = Network.newNetwork();
Expand All @@ -63,32 +68,43 @@ void connectionError() {
}
}

@Test
void connectNoAuth() {
@ParameterizedTest
@EnumSource
void connectNoAuth(JmxScraperContainer.ConfigSource configSource) {
connectionTest(
app -> app.withJmxPort(JMX_PORT), scraper -> scraper.withRmiServiceUrl(APP_HOST, JMX_PORT));
app -> app.withJmxPort(JMX_PORT),
scraper -> scraper.withRmiServiceUrl(APP_HOST, JMX_PORT).withConfigSource(configSource));
}

@Test
void userPassword() {
@ParameterizedTest
@EnumSource
void userPassword(JmxScraperContainer.ConfigSource configSource) {
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));
scraper ->
scraper
.withRmiServiceUrl(APP_HOST, JMX_PORT)
.withUser(login)
.withPassword(pwd)
.withConfigSource(configSource));
}

@Test
void serverSsl(@TempDir Path tempDir) {
testServerSsl(tempDir, /* sslRmiRegistry= */ false);
@ParameterizedTest
@EnumSource
void serverSsl(JmxScraperContainer.ConfigSource configSource) {
testServerSsl(/* sslRmiRegistry= */ false, configSource);
}

@Test
void serverSslWithSslRmiRegistry(@TempDir Path tempDir) {
testServerSsl(tempDir, /* sslRmiRegistry= */ true);
@ParameterizedTest
@EnumSource
void serverSslWithSslRmiRegistry(JmxScraperContainer.ConfigSource configSource) {
testServerSsl(/* sslRmiRegistry= */ true, configSource);
}

private static void testServerSsl(Path tempDir, boolean sslRmiRegistry) {
private void testServerSsl(
boolean sslRmiRegistry, JmxScraperContainer.ConfigSource configSource) {
// two keystores:
// server keystore with public/private key pair
// client trust store with certificate from server
Expand All @@ -110,11 +126,13 @@ private static void testServerSsl(Path tempDir, boolean sslRmiRegistry) {
scraper ->
(sslRmiRegistry ? scraper.withSslRmiRegistry() : scraper)
.withRmiServiceUrl(APP_HOST, JMX_PORT)
.withTrustStore(clientTrustStore));
.withTrustStore(clientTrustStore)
.withConfigSource(configSource));
}

@Test
void serverSslClientSsl(@TempDir Path tempDir) {
@ParameterizedTest
@EnumSource(value = JmxScraperContainer.ConfigSource.class)
void serverSslClientSsl(JmxScraperContainer.ConfigSource configSource) {
// Note: this could have been made simpler by relying on the fact that keystore could be used
// as a trust store, but having clear split provides also some extra clarity
//
Expand Down Expand Up @@ -153,7 +171,8 @@ void serverSslClientSsl(@TempDir Path tempDir) {
scraper
.withRmiServiceUrl(APP_HOST, JMX_PORT)
.withKeyStore(clientKeyStore)
.withTrustStore(clientTrustStore));
.withTrustStore(clientTrustStore)
.withConfigSource(configSource));
}

private static void connectionTest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@
import static org.assertj.core.api.Assertions.assertThat;

import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.MountableFile;
Expand All @@ -35,6 +39,19 @@ public class JmxScraperContainer extends GenericContainer<JmxScraperContainer> {
private TestKeyStore keyStore;
private TestKeyStore trustStore;
private boolean sslRmiRegistry;
private ConfigSource configSource;

/** Defines different strategies to provide scraper configuration */
public enum ConfigSource {
/** system properties with "-D" prefix in JVM command */
SYSTEM_PROPERTIES,
/** properties file */
PROPERTIES_FILE,
/** standard input */
STDIN,
/** environment variables with "OTEL_" prefix, non-otel options as system properties */
ENVIRONMENT_VARIABLES;
}

public JmxScraperContainer(String otlpEndpoint, String baseImage) {
super(baseImage);
Expand All @@ -48,6 +65,7 @@ public JmxScraperContainer(String otlpEndpoint, String baseImage) {
this.targetSystems = new HashSet<>();
this.customYamlFiles = new HashSet<>();
this.extraJars = new ArrayList<>();
this.configSource = ConfigSource.SYSTEM_PROPERTIES;
}

/**
Expand Down Expand Up @@ -182,83 +200,200 @@ public JmxScraperContainer withSslRmiRegistry() {
return this;
}

/**
* Sets how configuration is provided to scraper
*
* @param source configuration source
* @return this
*/
@CanIgnoreReturnValue
public JmxScraperContainer withConfigSource(ConfigSource source) {
this.configSource = source;
return this;
}

@Override
public void start() {
// for now only configure through JVM args
List<String> arguments = new ArrayList<>();
arguments.add("java");
arguments.add("-Dotel.metrics.exporter=otlp");
arguments.add("-Dotel.exporter.otlp.endpoint=" + endpoint);

if (!targetSystems.isEmpty()) {
arguments.add("-Dotel.jmx.target.system=" + String.join(",", targetSystems));
}
Map<String, String> config = initConfig();

if (serviceUrl == null) {
throw new IllegalStateException("Missing service URL");
}
arguments.add("-Dotel.jmx.service.url=" + serviceUrl);
// always use a very short export interval for testing
arguments.add("-Dotel.metric.export.interval=1s");
List<String> cmd = createCommand(config);

if (user != null) {
arguments.add("-Dotel.jmx.username=" + user);
}
if (password != null) {
arguments.add("-Dotel.jmx.password=" + password);
if (configSource != ConfigSource.STDIN) {
this.withCommand(cmd.toArray(new String[0]));
} else {
Path script = generateShellScript(cmd, config);

this.withCopyFileToContainer(MountableFile.forHostPath(script, 500), "/scraper.sh");
this.withCommand("/scraper.sh");
}

arguments.addAll(addSecureStore(keyStore, /* isKeyStore= */ true));
arguments.addAll(addSecureStore(trustStore, /* isKeyStore= */ false));
logger().info("Starting scraper with command: " + String.join(" ", this.getCommandParts()));
super.start();
}

if (sslRmiRegistry) {
arguments.add("-Dotel.jmx.remote.registry.ssl=true");
private Path generateShellScript(List<String> cmd, Map<String, String> config) {
// generate shell script to feed standard input with config
List<String> lines = new ArrayList<>();
lines.add("#!/bin/bash");
lines.add(String.join(" ", cmd) + "<<EOF");
lines.addAll(toKeyValueString(config));
lines.add("EOF");

Path script;
try {
script = Files.createTempFile("scraper", ".sh");
Files.write(script, lines);
} catch (IOException e) {
throw new IllegalStateException(e);
}

if (!customYamlFiles.isEmpty()) {
for (String yaml : customYamlFiles) {
this.withCopyFileToContainer(MountableFile.forClasspathResource(yaml), yaml);
}
arguments.add("-Dotel.jmx.config=" + String.join(",", customYamlFiles));
logger().info("Scraper executed with /scraper.sh shell script");
for (int i = 0; i < lines.size(); i++) {
logger().info("/scrapper.sh:{} {}", i, lines.get(i));
}
return script;
}

private List<String> createCommand(Map<String, String> config) {
List<String> cmd = new ArrayList<>();
cmd.add("java");

switch (configSource) {
case SYSTEM_PROPERTIES:
cmd.addAll(
toKeyValueString(config).stream().map(s -> "-D" + s).collect(Collectors.toList()));
break;
case PROPERTIES_FILE:
try {
Path configFile = Files.createTempFile("config", ".properties");
Files.write(configFile, toKeyValueString(config));
this.withCopyFileToContainer(MountableFile.forHostPath(configFile), "/config.properties");
} catch (IOException e) {
throw new IllegalStateException(e);
}
break;
case STDIN:
// nothing needed here
break;
case ENVIRONMENT_VARIABLES:
Map<String, String> env = new HashMap<>();
Map<String, String> other = new HashMap<>();
config.forEach(
(k, v) -> {
if (k.startsWith("otel.")) {
env.put(k.toUpperCase(Locale.ROOT).replace(".", "_"), v);
} else {
other.put(k, v);
}
});

if (!other.isEmpty()) {
env.put(
"JAVA_TOOL_OPTIONS",
toKeyValueString(other).stream().map(s -> "-D" + s).collect(Collectors.joining(" ")));
}
this.withEnv(env);
env.forEach((k, v) -> logger().info("Using environment variable {} = {} ", k, v));

break;
}

if (extraJars.isEmpty()) {
// using "java -jar" to start
arguments.add("-jar");
arguments.add("/scraper.jar");
cmd.add("-jar");
cmd.add("/scraper.jar");
} else {
// using "java -cp" to start
arguments.add("-cp");
arguments.add("/scraper.jar:" + String.join(":", extraJars));
arguments.add("io.opentelemetry.contrib.jmxscraper.JmxScraper");
cmd.add("-cp");
cmd.add("/scraper.jar:" + String.join(":", extraJars));
cmd.add("io.opentelemetry.contrib.jmxscraper.JmxScraper");
}

switch (configSource) {
case SYSTEM_PROPERTIES:
case ENVIRONMENT_VARIABLES:
// no extra program argument needed
break;
case PROPERTIES_FILE:
cmd.add("-config");
cmd.add("/config.properties");
break;
case STDIN:
cmd.add("-config");
cmd.add("-");
break;
}

if (testJmx) {
arguments.add("-test");
cmd.add("-test");
this.waitingFor(Wait.forLogMessage(".*JMX connection test.*", 1));
} else {
this.waitingFor(
Wait.forLogMessage(".*JMX scraping started.*", 1)
.withStartupTimeout(Duration.ofSeconds(10)));
}
return cmd;
}

this.withCommand(arguments.toArray(new String[0]));
private @NotNull Map<String, String> initConfig() {
Map<String, String> config = new HashMap<>();
config.put("otel.metrics.exporter", "otlp");
config.put("otel.exporter.otlp.endpoint", endpoint);

logger().info("Starting scraper with command: " + String.join(" ", arguments));
if (!targetSystems.isEmpty()) {
config.put("otel.jmx.target.system", String.join(",", targetSystems));
}

super.start();
if (serviceUrl == null) {
throw new IllegalStateException("Missing service URL");
}
config.put("otel.jmx.service.url", serviceUrl);

// always use a very short export interval for testing
config.put("otel.metric.export.interval", "1s");

if (user != null) {
config.put("otel.jmx.username", user);
}
if (password != null) {
config.put("otel.jmx.password", password);
}

addSecureStore(keyStore, /* isKeyStore= */ true, config);
addSecureStore(trustStore, /* isKeyStore= */ false, config);

if (sslRmiRegistry) {
config.put("otel.jmx.remote.registry.ssl", "true");
}

if (!customYamlFiles.isEmpty()) {
for (String yaml : customYamlFiles) {
this.withCopyFileToContainer(MountableFile.forClasspathResource(yaml), yaml);
}
config.put("otel.jmx.config", String.join(",", customYamlFiles));
}
return config;
}

private List<String> addSecureStore(TestKeyStore keyStore, boolean isKeyStore) {
private void addSecureStore(
TestKeyStore keyStore, boolean isKeyStore, Map<String, String> config) {
if (keyStore == null) {
return Collections.emptyList();
return;
}
Path path = keyStore.getPath();
String containerPath = "/" + path.getFileName().toString();
this.withCopyFileToContainer(MountableFile.forHostPath(path), containerPath);

String prefix = String.format("-Djavax.net.ssl.%sStore", isKeyStore ? "key" : "trust");
return Arrays.asList(
prefix + "=" + containerPath, prefix + "Password=" + keyStore.getPassword());
String prefix = String.format("javax.net.ssl.%sStore", isKeyStore ? "key" : "trust");

config.put(prefix, containerPath);
config.put(prefix + "Password", keyStore.getPassword());
}

private static List<String> toKeyValueString(Map<String, String> options) {
return options.entrySet().stream()
.map(e -> String.format("%s=%s", e.getKey(), e.getValue()))
.collect(Collectors.toList());
}
}
Loading