diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy index 5b9dd7527ba..5e3323f5365 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy @@ -1,6 +1,7 @@ package datadog.trace.civisibility.utils import datadog.communication.util.IOUtils +import datadog.trace.test.util.PortableCommand import spock.lang.Specification import spock.lang.TempDir @@ -18,7 +19,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT) when: - def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "echo", "this is a test") + def output = shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.echo("this is a test")) then: output.trim() == "this is a test" @@ -29,7 +30,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT) when: - def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, "cat") + def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, *PortableCommand.cat()) then: output.trim() == "this is a test" @@ -40,7 +41,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, 1_000) when: - shellCommandExecutor.executeCommand(IOUtils::readFully, "sleep", "2") + shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.sleep(2)) then: thrown TimeoutException diff --git a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy index eb8af334502..dce93754c55 100644 --- a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy @@ -1,14 +1,15 @@ package datadog.trace.util import datadog.trace.test.util.DDSpecification +import datadog.trace.test.util.PortableCommand import spock.util.concurrent.PollingConditions // This test looks at the private "currentProcess" variable because the alternative // would be calling "ps -e" repeatedly class ProcessSupervisorTest extends DDSpecification { ProcessBuilder createProcessBuilder() { - // Creates a process that never returns - return new ProcessBuilder("tail", "-f", "/dev/null") + // Creates a process that never returns on its own + return new ProcessBuilder(PortableCommand.runForever()) } def "Process killed when supervisor closed"() { diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java new file mode 100644 index 00000000000..cc023a4d4d3 --- /dev/null +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java @@ -0,0 +1,133 @@ +package datadog.trace.test.util; + +import datadog.environment.OperatingSystem; +import datadog.trace.api.internal.VisibleForTesting; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Builds command lines for a small set of command-line utilities so tests can spawn them without + * depending on the host operating system. + * + *

On POSIX platforms the native utilities are used directly. Windows has no usable equivalent + * for any of them — {@code echo} is a {@code cmd.exe} builtin rather than an executable, {@code + * type} cannot read standard input, and {@code timeout} refuses to run when standard input is + * redirected — so there the commands are emulated by {@link PortableCommandRunner} in a child JVM. + * + *

POSIX deliberately keeps the native utilities instead of emulating everywhere: forking a JVM + * is far more expensive than spawning a small native binary, in both startup time and memory, and + * effectively all CI runs on Linux — so the cheap path is the one that matters. It is also exactly + * what these tests spawned before this class existed, which leaves CI behavior unchanged. + * + *

Both paths are observably identical: {@code cat} copies bytes exactly, {@code sleep} takes a + * duration in seconds, and {@code echo} terminates its output with the platform line separator. + * Callers therefore never need to branch on the operating system. + * + *

Supported commands: + * + *

+ */ +public final class PortableCommand { + private static final String MIN_HEAP = "-Xms8m"; + private static final String MAX_HEAP = "-Xmx16m"; + + /** Windows has no usable native equivalent for any of these commands. */ + private static final boolean EMULATED = OperatingSystem.isWindows(); + + private PortableCommand() {} + + public static String[] echo(String value) { + return echo(value, EMULATED); + } + + public static String[] cat() { + return cat(EMULATED); + } + + public static String[] sleep(long durationSec) { + return sleep(durationSec, EMULATED); + } + + public static String[] runForever() { + return runForever(EMULATED); + } + + @VisibleForTesting + static String[] echo(String value, boolean emulated) { + return emulated ? emulate("echo", value) : new String[] {"echo", value}; + } + + @VisibleForTesting + static String[] cat(boolean emulated) { + return emulated ? emulate("cat") : new String[] {"cat"}; + } + + @VisibleForTesting + static String[] sleep(long durationSec, boolean emulated) { + if (durationSec < 0) { + throw new IllegalArgumentException("Sleep duration must not be negative: " + durationSec); + } + // The native sleep takes seconds; the emulated runner takes milliseconds. + return emulated + ? emulate("sleep", Long.toString(durationSec * 1000)) + : new String[] {"sleep", Long.toString(durationSec)}; + } + + @VisibleForTesting + static String[] runForever(boolean emulated) { + return emulated + ? emulate("sleep", Long.toString(Long.MAX_VALUE)) + : new String[] {"tail", "-f", "/dev/null"}; + } + + private static String[] emulate(String... arguments) { + Path executable = javaExecutable(); + Path classpath = classpathEntry(); + + List command = new ArrayList<>(); + command.add(executable.toString()); + command.add(MIN_HEAP); + command.add(MAX_HEAP); + command.add("-cp"); + command.add(classpath.toString()); + command.add(PortableCommandRunner.class.getName()); + command.addAll(Arrays.asList(arguments)); + return command.toArray(new String[0]); + } + + private static Path javaExecutable() { + return javaExecutable(Paths.get(System.getProperty("java.home"))); + } + + @VisibleForTesting + static Path javaExecutable(Path javaHome) { + Path bin = javaHome.resolve("bin"); + for (String name : new String[] {"java", "java.exe"}) { + Path candidate = bin.resolve(name); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new IllegalStateException("Could not find a Java executable under " + bin); + } + + private static Path classpathEntry() { + CodeSource source = PortableCommand.class.getProtectionDomain().getCodeSource(); + try { + return Paths.get(source.getLocation().toURI()); + } catch (Exception e) { + throw new IllegalStateException( + "Cannot determine the classpath of " + PortableCommand.class.getName(), e); + } + } +} diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java new file mode 100644 index 00000000000..f67b4b6963f --- /dev/null +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java @@ -0,0 +1,55 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import datadog.trace.api.internal.VisibleForTesting; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Emulates the utilities described by {@link PortableCommand} inside a JVM, for platforms that have + * no usable native equivalent. Spawned as a child process; not meant to be called directly. + */ +public final class PortableCommandRunner { + private PortableCommandRunner() {} + + @SuppressForbidden + public static void main(String[] arguments) throws IOException, InterruptedException { + execute(arguments, System.in, System.out); + System.out.flush(); + } + + @VisibleForTesting + static void execute(String[] arguments, InputStream input, OutputStream output) + throws IOException, InterruptedException { + if (arguments.length == 0) { + throw new IllegalArgumentException("Missing command"); + } + switch (arguments[0]) { + case "echo": + output.write((argument(arguments) + System.lineSeparator()).getBytes(UTF_8)); + break; + case "cat": + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + break; + case "sleep": + Thread.sleep(Long.parseLong(argument(arguments))); + break; + default: + throw new IllegalArgumentException("Unknown command: " + arguments[0]); + } + } + + private static String argument(String[] arguments) { + if (arguments.length < 2) { + throw new IllegalArgumentException("Command '" + arguments[0] + "' requires an argument"); + } + return arguments[1]; + } +} diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java new file mode 100644 index 00000000000..610e5dad584 --- /dev/null +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java @@ -0,0 +1,79 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the command dispatch. In production the runner only ever executes as a child + * process, where its behavior is covered end to end by the emulated cases of {@link + * PortableCommandTest} — but a child JVM is opaque to both the coverage report and to assertions + * about why a command misbehaved, so the dispatch is driven directly here. + */ +class PortableCommandRunnerTest { + @Test + void echoes() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + PortableCommandRunner.execute(new String[] {"echo", "value"}, emptyInput(), output); + + assertEquals("value" + System.lineSeparator(), new String(output.toByteArray(), UTF_8)); + } + + @Test + void copiesInput() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + InputStream input = new ByteArrayInputStream("payload".getBytes(UTF_8)); + + PortableCommandRunner.execute(new String[] {"cat"}, input, output); + + assertEquals("payload", new String(output.toByteArray(), UTF_8)); + } + + @Test + void sleeps() throws Exception { + long start = System.nanoTime(); + + PortableCommandRunner.execute( + new String[] {"sleep", "50"}, emptyInput(), new ByteArrayOutputStream()); + + assertTrue((System.nanoTime() - start) / 1_000_000 >= 40, "sleep returned immediately"); + } + + @Test + void rejectsMissingCommand() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[0], emptyInput(), new ByteArrayOutputStream())); + } + + @Test + void rejectsUnknownCommand() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[] {"rm"}, emptyInput(), new ByteArrayOutputStream())); + } + + @Test + void rejectsMissingArgument() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[] {"echo"}, emptyInput(), new ByteArrayOutputStream())); + } + + private static InputStream emptyInput() { + return new ByteArrayInputStream(new byte[0]); + } +} diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java new file mode 100644 index 00000000000..784145e8c8b --- /dev/null +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java @@ -0,0 +1,212 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import datadog.environment.OperatingSystem; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.tabletest.junit.TableTest; + +class PortableCommandTest { + private static final long TIMEOUT_SECONDS = 20; + + // Both strategies are exercised on every platform so that the emulated path, which only ships + // on Windows, is still verified by a POSIX CI run. + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testEcho(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.echo("hello", emulated), null); + + assertEquals(0, result.exitCode, result.error); + assertEquals("hello" + System.lineSeparator(), result.output); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testCat(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.cat(emulated), "copied".getBytes(UTF_8)); + + assertEquals(0, result.exitCode, result.error); + assertEquals("copied", result.output); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testSleep(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + long start = System.nanoTime(); + Result result = run(PortableCommand.sleep(1, emulated), null); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertEquals(0, result.exitCode, result.error); + // A floor slightly below the requested duration keeps this robust against clock granularity + // while still failing if sleep is a no-op. + assertTrue(elapsedMillis >= 900, "slept for only " + elapsedMillis + "ms"); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testRunForever(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Process process = new ProcessBuilder(PortableCommand.runForever(emulated)).start(); + try { + assertFalse(process.waitFor(1, SECONDS), "runForever() must not exit on its own"); + + process.destroyForcibly(); + + assertTrue(process.waitFor(TIMEOUT_SECONDS, SECONDS), "process outlived destroyForcibly()"); + } finally { + process.destroyForcibly(); + } + } + + @Test + void publicApiSelectsStrategyForCurrentPlatform() { + boolean emulated = OperatingSystem.isWindows(); + + assertArrayEquals(PortableCommand.echo("v", emulated), PortableCommand.echo("v")); + assertArrayEquals(PortableCommand.cat(emulated), PortableCommand.cat()); + assertArrayEquals(PortableCommand.sleep(10, emulated), PortableCommand.sleep(10)); + assertArrayEquals(PortableCommand.runForever(emulated), PortableCommand.runForever()); + } + + @Test + void rejectsNegativeSleep() { + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, false)); + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, true)); + } + + @Test + void locatesJavaExecutableOfRunningJvm() { + Path executable = PortableCommand.javaExecutable(Paths.get(System.getProperty("java.home"))); + + assertTrue(Files.isRegularFile(executable), executable + " is not a file"); + } + + @Test + void locatesWindowsJavaExecutable(@TempDir Path javaHome) throws Exception { + Path bin = Files.createDirectory(javaHome.resolve("bin")); + Path executable = Files.createFile(bin.resolve("java.exe")); + + assertEquals(executable, PortableCommand.javaExecutable(javaHome)); + } + + @Test + void failsWhenJavaExecutableIsMissing(@TempDir Path javaHome) throws Exception { + Files.createDirectory(javaHome.resolve("bin")); + + assertThrows(IllegalStateException.class, () -> PortableCommand.javaExecutable(javaHome)); + } + + private static void assumeStrategySupported(boolean emulated) { + assumeTrue( + emulated || !OperatingSystem.isWindows(), + "native commands are only available on POSIX platforms"); + } + + private static Result run(String[] command, byte[] input) throws Exception { + Process process = new ProcessBuilder(command).start(); + try { + // Both streams are drained concurrently: waiting for the process to exit before reading + // deadlocks as soon as either pipe buffer fills. + Drain output = Drain.of(process.getInputStream()); + Drain error = Drain.of(process.getErrorStream()); + + try (OutputStream stdin = process.getOutputStream()) { + if (input != null) { + stdin.write(input); + } + } + + assertTrue( + process.waitFor(TIMEOUT_SECONDS, SECONDS), + () -> "command timed out: " + String.join(" ", command)); + return new Result(process.exitValue(), output.text(), error.text()); + } finally { + process.destroyForcibly(); + } + } + + private static final class Result { + final int exitCode; + final String output; + final String error; + + Result(int exitCode, String output, String error) { + this.exitCode = exitCode; + this.output = output; + this.error = error; + } + } + + private static final class Drain extends Thread { + private final InputStream input; + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + private volatile IOException failure; + + private Drain(InputStream input) { + this.input = input; + setDaemon(true); + } + + static Drain of(InputStream input) { + Drain drain = new Drain(input); + drain.start(); + return drain; + } + + @Override + public void run() { + byte[] buffer = new byte[8192]; + try { + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } catch (IOException e) { + failure = e; + } + } + + String text() throws Exception { + join(SECONDS.toMillis(TIMEOUT_SECONDS)); + if (failure != null) { + throw failure; + } + return new String(output.toByteArray(), UTF_8); + } + } +}