diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerBuildTask.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerBuildTask.java index 87d7d8d8ed47c..e4314b9dcfaef 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerBuildTask.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerBuildTask.java @@ -22,6 +22,7 @@ import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; import org.gradle.api.provider.SetProperty; +import org.gradle.api.services.ServiceReference; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputDirectory; import org.gradle.api.tasks.Optional; @@ -39,6 +40,7 @@ import java.io.IOException; import java.nio.file.Files; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -68,10 +70,20 @@ public DockerBuildTask(WorkerExecutor workerExecutor, ObjectFactory objectFactor this.dockerContext = objectFactory.directoryProperty(); this.buildArgs = objectFactory.mapProperty(String.class, String.class); this.markerFile.set(projectLayout.getBuildDirectory().file("markers/" + this.getName() + ".marker")); + onlyIf("Docker supports all requested platforms", task -> { + var platforms = getPlatforms().getOrElse(Collections.emptySet()); + if (platforms.isEmpty()) { + return false; + } + DockerSupportService support = getDockerSupport().get(); + return platforms.stream() + .allMatch(platform -> Architecture.fromDockerPlatform(platform).map(support::isArchitectureSupported).orElse(false)); + }); } @TaskAction public void build() { + String dockerExecutable = getDockerSupport().get().getResolvedDockerExecutable(); workerExecutor.noIsolation().submit(DockerBuildAction.class, params -> { params.getDockerContext().set(dockerContext); params.getMarkerFile().set(markerFile); @@ -82,6 +94,7 @@ public void build() { params.getBaseImages().set(Arrays.asList(baseImages)); params.getBuildArgs().set(buildArgs); params.getPlatforms().set(getPlatforms()); + params.getDockerExecutable().set(dockerExecutable); }); } @@ -148,6 +161,9 @@ public RegularFileProperty getMarkerFile() { return markerFile; } + @ServiceReference(DockerSupportPlugin.DOCKER_SUPPORT_SERVICE_NAME) + public abstract Property getDockerSupport(); + public abstract static class DockerBuildAction implements WorkAction { private final ExecOperations execOperations; @@ -163,12 +179,13 @@ public DockerBuildAction(ExecOperations execOperations) { */ private void pullBaseImage(String baseImage) { final int maxAttempts = 10; + String docker = getParameters().getDockerExecutable().get(); for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { LoggedExec.exec(execOperations, spec -> { maybeConfigureDockerConfig(spec); - spec.executable("docker"); + spec.executable(docker); spec.args("pull"); spec.environment("DOCKER_BUILDKIT", "1"); spec.args(baseImage); @@ -205,7 +222,7 @@ public void execute() { LoggedExec.exec(execOperations, spec -> { maybeConfigureDockerConfig(spec); - spec.executable("docker"); + spec.executable(parameters.getDockerExecutable().get()); spec.environment("DOCKER_BUILDKIT", "1"); if (isCrossPlatform) { spec.args("buildx"); @@ -260,9 +277,10 @@ private boolean isCrossPlatform() { private String getImageChecksum(String imageTag) { final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + String docker = getParameters().getDockerExecutable().get(); execOperations.exec(spec -> { - spec.setCommandLine("docker", "inspect", "--format", "{{ .Id }}", imageTag); + spec.setCommandLine(docker, "inspect", "--format", "{{ .Id }}", imageTag); spec.setStandardOutput(stdout); spec.setIgnoreExitValue(false); }); @@ -289,5 +307,7 @@ interface Parameters extends WorkParameters { SetProperty getPlatforms(); Property getPush(); + + Property getDockerExecutable(); } } diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportPlugin.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportPlugin.java index 7ec35ccd32e10..bf6165447fa9f 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportPlugin.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportPlugin.java @@ -43,7 +43,7 @@ public void apply(Project project) { params.getIsCI().set(buildParams.getCi()); })); - // Ensure that if we are trying to run any DockerBuildTask tasks, we assert an available Docker installation exists + // Ensure that if we are trying to run Docker build tasks, we assert an available Docker installation exists project.getGradle().getTaskGraph().whenReady(graph -> { List dockerTasks = graph.getAllTasks() .stream() diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportService.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportService.java index f40f5d932b701..8d13f11800c39 100644 --- a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportService.java +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/DockerSupportService.java @@ -49,8 +49,8 @@ public abstract class DockerSupportService implements BuildService { private static final Logger LOGGER = Logging.getLogger(DockerSupportService.class); - // Defines the possible locations of the Docker CLI. These will be searched in order. - private static final String[] DOCKER_BINARIES = { "/usr/bin/docker", "/usr/local/bin/docker" }; + // Defines the possible locations of the Docker CLI. Searched in order for resolution and availability. + private static final String[] DOCKER_BINARIES = { "/usr/local/bin/docker", "/usr/bin/docker", "/opt/homebrew/bin/docker" }; private static final String[] DOCKER_COMPOSE_BINARIES = { "/usr/local/bin/docker-compose", "/usr/bin/docker-compose", @@ -72,7 +72,7 @@ public DockerSupportService(ProviderFactory providerFactory) { * * @return the results of the search. */ - public DockerAvailability getDockerAvailability() { + public synchronized DockerAvailability getDockerAvailability() { if (this.dockerAvailability == null) { String dockerPath; String dockerComposePath = null; @@ -305,15 +305,43 @@ static Map parseOsRelease(final List osReleaseLines) { } /** - * Searches the entries in {@link #DOCKER_BINARIES} for the Docker CLI. This method does - * not check whether the Docker installation appears usable, see {@link #getDockerAvailability()} - * instead. + * Resolves the Docker executable so it can be found even when PATH is minimal (e.g. Gradle + * workers or IDE). Searches PATH first for an executable named "docker", then falls back to + * {@link #DOCKER_BINARIES}. Use this when invoking docker from tasks so the binary is found + * regardless of worker environment. + * + * @return the absolute path to the Docker CLI if found and executable, otherwise "docker". + */ + public String getResolvedDockerExecutable() { + String pathEnv = System.getenv("PATH"); + if (pathEnv != null && pathEnv.isEmpty() == false) { + String separator = System.getProperty("path.separator", ":"); + for (String dir : pathEnv.split(separator)) { + File candidate = new File(dir.trim(), "docker"); + if (candidate.isFile() && candidate.canExecute()) { + return candidate.getAbsolutePath(); + } + } + } + for (String path : DOCKER_BINARIES) { + File f = new File(path); + if (f.isFile() && f.canExecute()) { + return f.getAbsolutePath(); + } + } + return "docker"; + } + + /** + * Searches for the Docker CLI using the same logic as {@link #getResolvedDockerExecutable()}. + * This method does not check whether the Docker installation appears usable, see + * {@link #getDockerAvailability()} instead. * * @return the path to a CLI, if available. */ private Optional getDockerPath() { - // Check if the Docker binary exists - return Stream.of(DOCKER_BINARIES).filter(path -> new File(path).exists()).findFirst(); + String resolved = getResolvedDockerExecutable(); + return "docker".equals(resolved) ? Optional.empty() : Optional.of(resolved); } /** diff --git a/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/NativeImageBuildTask.java b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/NativeImageBuildTask.java new file mode 100644 index 0000000000000..184933989ff8d --- /dev/null +++ b/build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/docker/NativeImageBuildTask.java @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +package org.elasticsearch.gradle.internal.docker; + +import org.elasticsearch.gradle.Architecture; +import org.elasticsearch.gradle.LoggedExec; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.services.ServiceReference; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.process.ExecSpec; +import org.gradle.workers.WorkAction; +import org.gradle.workers.WorkParameters; +import org.gradle.workers.WorkerExecutor; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +/** + * Builds a GraalVM native-image binary by running native-image inside a Docker + * container. Uses the Gradle worker API so multiple architecture builds can + * run in parallel, and is cacheable for remote build cache. + * + * We assume native images to be optional, and a fallback to be available, so + * this task will be skipped if Docker is not available or the target platform + * is not supported rather than failing. + */ +@CacheableTask +public abstract class NativeImageBuildTask extends DefaultTask { + + private FileCollection classpath; + + @Inject + public NativeImageBuildTask() { + onlyIf( + "Docker supports target platform", + task -> Architecture.fromDockerPlatform(getPlatform().getOrNull()) + .map(arch -> getDockerSupport().get().isArchitectureSupported(arch)) + .orElse(false) + && getDockerSupport().get().getDockerAvailability().isAvailable() + ); + } + + @Classpath + public FileCollection getClasspath() { + return classpath; + } + + public void setClasspath(FileCollection classpath) { + this.classpath = classpath; + } + + @Input + public abstract Property getImageTag(); + + @Input + public abstract Property getPlatform(); + + @Input + public abstract Property getMainClass(); + + /** + * When true, pass {@code --static} to native-image to produce a fully static binary. + * Defaults to false. + */ + @Input + @Optional + public abstract Property getStatic(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @ServiceReference(DockerSupportPlugin.DOCKER_SUPPORT_SERVICE_NAME) + public abstract Property getDockerSupport(); + + @Inject + public abstract WorkerExecutor getWorkerExecutor(); + + @TaskAction + public void execute() { + String dockerExecutable = getDockerSupport().get().getResolvedDockerExecutable(); + getWorkerExecutor().noIsolation().submit(NativeImageBuildAction.class, params -> { + params.getClasspath().setFrom(getClasspath()); + params.getImageTag().set(getImageTag()); + params.getPlatform().set(getPlatform()); + params.getMainClass().set(getMainClass()); + params.getStatic().set(getStatic().getOrElse(false)); + params.getOutputFile().set(getOutputFile()); + params.getDockerExecutable().set(dockerExecutable); + }); + } + + interface Parameters extends WorkParameters { + ConfigurableFileCollection getClasspath(); + + Property getImageTag(); + + Property getPlatform(); + + Property getMainClass(); + + Property getStatic(); + + RegularFileProperty getOutputFile(); + + Property getDockerExecutable(); + } + + public abstract static class NativeImageBuildAction implements WorkAction { + + private final ExecOperations execOperations; + + @Inject + public NativeImageBuildAction(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @Override + public void execute() { + Parameters params = getParameters(); + String imageTag = params.getImageTag().get(); + String platform = params.getPlatform().get(); + String mainClass = params.getMainClass().get(); + File outputFile = params.getOutputFile().get().getAsFile(); + File outputDir = outputFile.getParentFile(); + + if (outputDir.exists() == false && outputDir.mkdirs() == false) { + throw new GradleException("Failed to create output directory: " + outputDir); + } + + List classpathFiles = params.getClasspath().getFiles().stream().filter(File::exists).collect(Collectors.toList()); + if (classpathFiles.isEmpty()) { + throw new GradleException("Native-image classpath is empty"); + } + + // Build classpath string for inside the container: /cp/0:/cp/1:... + List cpPaths = new ArrayList<>(); + for (int i = 0; i < classpathFiles.size(); i++) { + cpPaths.add("/cp/" + i); + } + // Container is always Linux + String cpString = String.join(":", cpPaths); + + List args = new ArrayList<>(); + args.add("run"); + args.add("--rm"); + for (int i = 0; i < classpathFiles.size(); i++) { + File f = classpathFiles.get(i); + String path = f.getAbsolutePath(); + if (File.separatorChar == '\\') { + path = path.replace("\\", "/"); + } + args.add("-v"); + args.add(path + ":/cp/" + i + ":ro"); + } + args.add("-v"); + String outPath = outputDir.getAbsolutePath(); + if (File.separatorChar == '\\') { + outPath = outPath.replace("\\", "/"); + } + args.add(outPath + ":/output"); + args.add("--platform"); + args.add(platform); + args.add(imageTag); + args.add("--no-fallback"); + if (params.getStatic().get()) { + args.add("--static"); + } + args.add("-cp"); + args.add(cpString); + args.add("-o"); + args.add("/output/" + outputFile.getName()); + args.add(mainClass); + + LoggedExec.exec(execOperations, spec -> { + maybeConfigureDockerConfig(spec); + spec.executable(params.getDockerExecutable().get()); + spec.args(args); + }); + } + + private void maybeConfigureDockerConfig(ExecSpec spec) { + String dockerConfig = System.getenv("DOCKER_CONFIG"); + if (dockerConfig != null) { + spec.environment("DOCKER_CONFIG", dockerConfig); + } + } + } +} diff --git a/build-tools/src/main/java/org/elasticsearch/gradle/Architecture.java b/build-tools/src/main/java/org/elasticsearch/gradle/Architecture.java index c2654f9ae851f..b17365b4f7a73 100644 --- a/build-tools/src/main/java/org/elasticsearch/gradle/Architecture.java +++ b/build-tools/src/main/java/org/elasticsearch/gradle/Architecture.java @@ -9,6 +9,8 @@ package org.elasticsearch.gradle; +import java.util.Optional; + public enum Architecture { X64("x86_64", "linux/amd64", "amd64", "x64"), @@ -35,4 +37,23 @@ public static Architecture current() { }; } + /** + * Returns the architecture that matches the given Docker platform string (e.g. "linux/amd64"). + * + * @param platform the Docker platform string from e.g. {@code docker buildx inspect} + * @return the matching architecture, or empty if unknown + */ + public static Optional fromDockerPlatform(String platform) { + if (platform == null || platform.isBlank()) { + return Optional.empty(); + } + String trimmed = platform.trim(); + for (Architecture a : values()) { + if (a.dockerPlatform.equals(trimmed)) { + return Optional.of(a); + } + } + return Optional.empty(); + } + } diff --git a/distribution/build.gradle b/distribution/build.gradle index b419fb6be6738..bb2b75b7a8826 100644 --- a/distribution/build.gradle +++ b/distribution/build.gradle @@ -295,7 +295,7 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { * Properties to expand when copying packaging files * *****************************************************************************/ configurations { - ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsWindowsServiceCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole', 'libsNative', 'libsEntitlementAgent'].each { + ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsServerLauncher', 'libsServerLauncherNativeX64', 'libsServerLauncherNativeAarch64', 'libsWindowsServiceCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole', 'libsNative', 'libsEntitlementAgent'].each { create(it) { canBeConsumed = false canBeResolved = true @@ -335,6 +335,9 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { libsVersionChecker project(':distribution:tools:java-version-checker') libsCliLauncher project(':distribution:tools:cli-launcher') libsServerCli project(':distribution:tools:server-cli') + libsServerLauncher project(':distribution:tools:server-launcher') + libsServerLauncherNativeX64 project(path: ':distribution:tools:server-launcher', configuration: 'nativeLinuxX64') + libsServerLauncherNativeAarch64 project(path: ':distribution:tools:server-launcher', configuration: 'nativeLinuxAarch64') libsWindowsServiceCli project(':distribution:tools:windows-service-cli') libsAnsiConsole project(':distribution:tools:ansi-console') libsPluginCli project(':distribution:tools:plugin-cli') @@ -364,6 +367,22 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { into('tools/server-cli') { from(configurations.libsServerCli) } + into('tools/server-launcher') { + from(configurations.libsServerLauncher) + // Linux-only: include native binary for the matching architecture + if (os == 'linux') { + if (architecture == 'x64') { + from(configurations.libsServerLauncherNativeX64) + } else if (architecture == 'aarch64') { + from(configurations.libsServerLauncherNativeAarch64) + } + } + eachFile { + if (it.name == 'server-launcher' || it.name == 'server-launcher.exe') { + it.permissions.unix(0755) + } + } + } into('tools/windows-service-cli') { from(configurations.libsWindowsServiceCli) } diff --git a/distribution/docker/build.gradle b/distribution/docker/build.gradle index 4d2a8dad29088..4f3c42b0cbef8 100644 --- a/distribution/docker/build.gradle +++ b/distribution/docker/build.gradle @@ -503,13 +503,6 @@ void addBuildDockerImageTask(Architecture architecture, DockerBase base) { } else { baseImages = [] } - - Provider serviceProvider = GradleUtils.getBuildService( - project.gradle.sharedServices, - DockerSupportPlugin.DOCKER_SUPPORT_SERVICE_NAME - ) - onlyIf("$architecture supported") { serviceProvider.get().isArchitectureSupported(architecture) } - } if (base != DockerBase.IRON_BANK && base != DockerBase.CLOUD_ESS) { @@ -576,11 +569,6 @@ void addBuildCloudDockerImageTasks(Architecture architecture) { baseImages = [] tags = generateTags(dockerBase, architecture) platforms.add(architecture.dockerPlatform) - Provider serviceProvider = GradleUtils.getBuildService( - project.gradle.sharedServices, - DockerSupportPlugin.DOCKER_SUPPORT_SERVICE_NAME - ) - onlyIf("$architecture supported") { serviceProvider.get().isArchitectureSupported(architecture) } } diff --git a/distribution/src/bin/elasticsearch b/distribution/src/bin/elasticsearch index 4f48aa8453d73..52b53ca5b9ecb 100755 --- a/distribution/src/bin/elasticsearch +++ b/distribution/src/bin/elasticsearch @@ -1,5 +1,23 @@ #!/bin/bash -CLI_NAME=server -CLI_LIBS=lib/tools/server-cli -source "`dirname "$0"`"/elasticsearch-cli +source "`dirname "$0"`"/elasticsearch-env + +# use a small heap size for the CLI tools, and thus the serial collector to +# avoid stealing many CPU cycles; a user can override by setting CLI_JAVA_OPTS +CLI_JAVA_OPTS="-Xms4m -Xmx64m -XX:+UseSerialGC ${CLI_JAVA_OPTS}" + +export JAVA ES_HOME ES_PATH_CONF ES_DISTRIBUTION_TYPE JAVA_TYPE CLI_JAVA_OPTS + +NATIVE_LAUNCHER="$ES_HOME/lib/tools/server-launcher/server-launcher" +LAUNCHER_LIBS=$ES_HOME/lib/tools/server-launcher/* + +if [ -x "$NATIVE_LAUNCHER" ]; then + exec "$NATIVE_LAUNCHER" "$@" +else + exec \ + "$JAVA" \ + $CLI_JAVA_OPTS \ + -cp "$LAUNCHER_LIBS" \ + org.elasticsearch.server.launcher.ServerLauncher \ + "$@" +fi diff --git a/distribution/src/bin/elasticsearch.bat b/distribution/src/bin/elasticsearch.bat index c12d7088f6b3e..eb11582145ca0 100644 --- a/distribution/src/bin/elasticsearch.bat +++ b/distribution/src/bin/elasticsearch.bat @@ -3,13 +3,18 @@ setlocal enabledelayedexpansion setlocal enableextensions -set CLI_NAME=server -set CLI_LIBS=lib/tools/server-cli -call "%~dp0elasticsearch-cli.bat" ^ - %%* ^ - || goto exit +call "%~dp0elasticsearch-env.bat" || exit /b 1 + +rem use a small heap size for the CLI tools, and thus the serial collector to +rem avoid stealing many CPU cycles; a user can override by setting CLI_JAVA_OPTS +set CLI_JAVA_OPTS=-Xms4m -Xmx64m -XX:+UseSerialGC %CLI_JAVA_OPTS% + +%JAVA% ^ + %CLI_JAVA_OPTS% ^ + -cp "%ES_HOME%\lib\tools\server-launcher\*" ^ + org.elasticsearch.server.launcher.ServerLauncher ^ + %* endlocal endlocal -:exit exit /b %ERRORLEVEL% diff --git a/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/CliToolLauncher.java b/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/CliToolLauncher.java index 7ffc9276e2f40..9eb0b854d1567 100644 --- a/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/CliToolLauncher.java +++ b/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/CliToolLauncher.java @@ -21,6 +21,8 @@ import java.io.Closeable; import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; import java.util.Map; /** @@ -49,7 +51,14 @@ class CliToolLauncher { * @param args args to the tool * @throws Exception if the tool fails with an unknown error */ + @SuppressForbidden(reason = "uses System.out and System.err") public static void main(String[] args) throws Exception { + OutputStream originalStdOut = null; + if (isRedirectStdoutToStderr()) { + originalStdOut = System.out; + setOutToStderr(); + } + ProcessInfo pinfo = ProcessInfo.fromSystem(); // configure logging as early as possible @@ -59,7 +68,7 @@ public static void main(String[] args) throws Exception { String libs = pinfo.sysprops().getOrDefault("cli.libs", ""); command = CliToolProvider.load(pinfo.sysprops(), toolname, libs).create(); - Terminal terminal = Terminal.DEFAULT; + Terminal terminal = originalStdOut != null ? new RedirectedStdoutTerminal(originalStdOut) : Terminal.DEFAULT; Runtime.getRuntime().addShutdownHook(createShutdownHook(terminal, command)); int exitCode = command.main(args, terminal, pinfo); @@ -69,6 +78,21 @@ public static void main(String[] args) throws Exception { } } + /** + * Returns true when stdout should be redirected to stderr so that the real + * stdout can be used for binary output (e.g. the launch descriptor). + */ + @SuppressForbidden(reason = "Check redirect env and sysprop") + static boolean isRedirectStdoutToStderr() { + return "true".equalsIgnoreCase(System.getenv("ES_REDIRECT_STDOUT_TO_STDERR")) + || "true".equalsIgnoreCase(System.getProperty("cli.redirectStdoutToStderr", "")); + } + + @SuppressForbidden(reason = "Redirect stdout to stderr so binary output can use real stdout") + private static void setOutToStderr() { + System.setOut(new PrintStream(System.err)); + } + // package private for tests static String getToolName(Map sysprops) { String toolname = sysprops.getOrDefault("cli.name", ""); diff --git a/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/RedirectedStdoutTerminal.java b/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/RedirectedStdoutTerminal.java new file mode 100644 index 0000000000000..b1db85ae2c59a --- /dev/null +++ b/distribution/tools/cli-launcher/src/main/java/org/elasticsearch/launcher/RedirectedStdoutTerminal.java @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.launcher; + +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.core.SuppressForbidden; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.Charset; + +/** + * A terminal that sends all print output to stderr and exposes the real stdout + * via {@link #getOutputStream()}. Used when the process is run with + * ES_REDIRECT_STDOUT_TO_STDERR (or cli.redirectStdoutToStderr) so that + * binary data (e.g. the launch descriptor) can be written to stdout while + * user-visible output goes to stderr. + */ +class RedirectedStdoutTerminal extends Terminal { + + private final OutputStream stdoutForBinary; + + @SuppressForbidden(reason = "Use stderr for all print output; stdout reserved for binary (e.g. descriptor)") + RedirectedStdoutTerminal(OutputStream stdoutForBinary) { + super( + new InputStreamReader(System.in, Charset.defaultCharset()), + new PrintWriter(System.err, true), + new PrintWriter(System.err, true) + ); + this.stdoutForBinary = stdoutForBinary; + } + + @Override + @SuppressForbidden(reason = "Expose stdin for binary input (e.g. keystore prompts)") + public InputStream getInputStream() { + return System.in; + } + + @Override + public OutputStream getOutputStream() { + return stdoutForBinary; + } +} diff --git a/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/CliToolLauncherTests.java b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/CliToolLauncherTests.java index df11a529d7a19..569c5fa742005 100644 --- a/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/CliToolLauncherTests.java +++ b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/CliToolLauncherTests.java @@ -10,10 +10,15 @@ package org.elasticsearch.launcher; import org.elasticsearch.cli.MockTerminal; +import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.test.ESTestCase; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -23,7 +28,9 @@ import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertArrayEquals; +@SuppressForbidden(reason = "uses System.out and System.err") public class CliToolLauncherTests extends ESTestCase { public void testCliNameSysprop() { @@ -61,4 +68,100 @@ public void testShutdownHookError() { // ensure that we dump the stack trace too assertThat(terminal.getErrorOutput(), containsString("at org.elasticsearch.launcher.CliToolLauncherTests.lambda")); } + + public void testConsoleOutputWithoutRedirectFlag() throws Exception { + Path esHome = createTempDir(); + ByteArrayOutputStream stdoutCapture = new ByteArrayOutputStream(); + ByteArrayOutputStream stderrCapture = new ByteArrayOutputStream(); + PrintStream savedOut = System.out; + PrintStream savedErr = System.err; + String savedEsPathHome = System.getProperty("es.path.home"); + String savedCliName = System.getProperty("cli.name"); + String savedCliLibs = System.getProperty("cli.libs"); + try { + System.setOut(new PrintStream(stdoutCapture, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(stderrCapture, true, StandardCharsets.UTF_8)); + System.setProperty("es.path.home", esHome.toString()); + System.setProperty("cli.name", "redirect-test"); + System.setProperty("cli.libs", ""); + CliToolLauncher.main(new String[0]); + } finally { + System.setOut(savedOut); + System.setErr(savedErr); + restoreOrClear("es.path.home", savedEsPathHome); + restoreOrClear("cli.name", savedCliName); + restoreOrClear("cli.libs", savedCliLibs); + } + // Without redirect, getOutputStream() returns current System.out (our capture), so sentinel bytes appear on stdout + String stdout = stdoutCapture.toString(StandardCharsets.UTF_8); + assertThat(stdout, containsString(new String(RedirectTestCommand.SENTINEL_BYTES, StandardCharsets.UTF_8))); + } + + public void testWithRedirectFlagGetOutputStreamDirectsToStdout() throws Exception { + Path esHome = createTempDir(); + ByteArrayOutputStream stdoutCapture = new ByteArrayOutputStream(); + ByteArrayOutputStream stderrCapture = new ByteArrayOutputStream(); + PrintStream savedOut = System.out; + PrintStream savedErr = System.err; + String savedEsPathHome = System.getProperty("es.path.home"); + String savedCliName = System.getProperty("cli.name"); + String savedCliLibs = System.getProperty("cli.libs"); + String savedRedirect = System.getProperty("cli.redirectStdoutToStderr"); + try { + System.setOut(new PrintStream(stdoutCapture, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(stderrCapture, true, StandardCharsets.UTF_8)); + System.setProperty("es.path.home", esHome.toString()); + System.setProperty("cli.name", "redirect-test"); + System.setProperty("cli.libs", ""); + System.setProperty("cli.redirectStdoutToStderr", "true"); + CliToolLauncher.main(new String[0]); + } finally { + System.setOut(savedOut); + System.setErr(savedErr); + restoreOrClear("es.path.home", savedEsPathHome); + restoreOrClear("cli.name", savedCliName); + restoreOrClear("cli.libs", savedCliLibs); + restoreOrClear("cli.redirectStdoutToStderr", savedRedirect); + } + byte[] stdoutBytes = stdoutCapture.toByteArray(); + assertArrayEquals(RedirectTestCommand.SENTINEL_BYTES, stdoutBytes); + } + + public void testWithRedirectFlagUserOutputGoesToStderr() throws Exception { + Path esHome = createTempDir(); + ByteArrayOutputStream stdoutCapture = new ByteArrayOutputStream(); + ByteArrayOutputStream stderrCapture = new ByteArrayOutputStream(); + PrintStream savedOut = System.out; + PrintStream savedErr = System.err; + String savedEsPathHome = System.getProperty("es.path.home"); + String savedCliName = System.getProperty("cli.name"); + String savedCliLibs = System.getProperty("cli.libs"); + String savedRedirect = System.getProperty("cli.redirectStdoutToStderr"); + try { + System.setOut(new PrintStream(stdoutCapture, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(stderrCapture, true, StandardCharsets.UTF_8)); + System.setProperty("es.path.home", esHome.toString()); + System.setProperty("cli.name", "redirect-test"); + System.setProperty("cli.libs", ""); + System.setProperty("cli.redirectStdoutToStderr", "true"); + CliToolLauncher.main(new String[0]); + } finally { + System.setOut(savedOut); + System.setErr(savedErr); + restoreOrClear("es.path.home", savedEsPathHome); + restoreOrClear("cli.name", savedCliName); + restoreOrClear("cli.libs", savedCliLibs); + restoreOrClear("cli.redirectStdoutToStderr", savedRedirect); + } + String stderr = stderrCapture.toString(StandardCharsets.UTF_8); + assertThat(stderr, containsString(RedirectTestCommand.USER_OUTPUT)); + } + + private static void restoreOrClear(String key, String value) { + if (value != null) { + System.setProperty(key, value); + } else { + System.clearProperty(key); + } + } } diff --git a/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCliToolProvider.java b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCliToolProvider.java new file mode 100644 index 0000000000000..dffb8ec5891c5 --- /dev/null +++ b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCliToolProvider.java @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.launcher; + +import org.elasticsearch.cli.CliToolProvider; +import org.elasticsearch.cli.Command; + +/** + * Test CliToolProvider that supplies {@link RedirectTestCommand} for redirect tests. + */ +public class RedirectTestCliToolProvider implements CliToolProvider { + + @Override + public String name() { + return "redirect-test"; + } + + @Override + public Command create() { + return new RedirectTestCommand(); + } +} diff --git a/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCommand.java b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCommand.java new file mode 100644 index 0000000000000..630775aa0f3f0 --- /dev/null +++ b/distribution/tools/cli-launcher/src/test/java/org/elasticsearch/launcher/RedirectTestCommand.java @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.launcher; + +import joptsimple.OptionSet; + +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * A test command that prints user output and optionally writes sentinel bytes + * to the terminal's output stream. Used to verify redirect behavior. + */ +public class RedirectTestCommand extends Command { + + static final String USER_OUTPUT = "user-output"; + static final byte[] SENTINEL_BYTES = "DESC".getBytes(StandardCharsets.UTF_8); + + RedirectTestCommand() { + super("redirect test command"); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, ProcessInfo processInfo) throws Exception { + terminal.println(USER_OUTPUT); + var out = terminal.getOutputStream(); + if (out != null) { + out.write(SENTINEL_BYTES); + out.flush(); + } + } + + @Override + public void close() throws IOException {} +} diff --git a/distribution/tools/cli-launcher/src/test/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider b/distribution/tools/cli-launcher/src/test/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider new file mode 100644 index 0000000000000..30ccdeac0ac3f --- /dev/null +++ b/distribution/tools/cli-launcher/src/test/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider @@ -0,0 +1 @@ +org.elasticsearch.launcher.RedirectTestCliToolProvider diff --git a/distribution/tools/server-cli/build.gradle b/distribution/tools/server-cli/build.gradle index 299d511ba5dbe..f9e2721f00e74 100644 --- a/distribution/tools/server-cli/build.gradle +++ b/distribution/tools/server-cli/build.gradle @@ -13,6 +13,7 @@ apply plugin: 'elasticsearch.build' dependencies { compileOnly project(":server") compileOnly project(":libs:cli") + implementation project(":libs:server-launcher-common") testImplementation project(":test:framework") } diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmArgumentParsingSystemMemoryInfo.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmArgumentParsingSystemMemoryInfo.java deleted file mode 100644 index b361d1d4bf275..0000000000000 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmArgumentParsingSystemMemoryInfo.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -package org.elasticsearch.server.cli; - -import java.util.List; - -public abstract class JvmArgumentParsingSystemMemoryInfo implements SystemMemoryInfo { - private final List userDefinedJvmOptions; - - public JvmArgumentParsingSystemMemoryInfo(List userDefinedJvmOptions) { - this.userDefinedJvmOptions = userDefinedJvmOptions; - } - - protected long getBytesFromSystemProperty(String systemProperty, long defaultValue) { - return userDefinedJvmOptions.stream() - .filter(option -> option.startsWith("-D" + systemProperty + "=")) - .map(totalMemoryOverheadBytesOption -> { - try { - long bytes = Long.parseLong(totalMemoryOverheadBytesOption.split("=", 2)[1]); - if (bytes < 0) { - throw new IllegalArgumentException("Negative bytes size specified in [" + totalMemoryOverheadBytesOption + "]"); - } - return bytes; - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Unable to parse number of bytes from [" + totalMemoryOverheadBytesOption + "]", e); - } - }) - .reduce((previous, current) -> current) // this is effectively findLast(), so that ES_JAVA_OPTS overrides jvm.options - .orElse(defaultValue); - } -} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmOptionsParser.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmOptionsParser.java index 3927bde0a054d..e97dc4c06f651 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmOptionsParser.java +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/JvmOptionsParser.java @@ -142,10 +142,7 @@ private List jvmOptions( } final List substitutedJvmOptions = substitutePlaceholders(jvmOptions, Collections.unmodifiableMap(substitutions)); - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - substitutedJvmOptions, - new OverridableSystemMemoryInfo(substitutedJvmOptions, new DefaultSystemMemoryInfo()) - ); + final SystemMemoryInfo memoryInfo = new OverridableSystemMemoryInfo(substitutedJvmOptions, new DefaultSystemMemoryInfo()); substitutedJvmOptions.addAll(machineDependentHeap.determineHeapSettings(args.nodeSettings(), memoryInfo, substitutedJvmOptions)); final List ergonomicJvmOptions = JvmErgonomics.choose(substitutedJvmOptions, args.nodeSettings()); final List systemJvmOptions = SystemJvmOptions.systemJvmOptions(args.nodeSettings(), cliSysprops); diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfo.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfo.java deleted file mode 100644 index 5508c4f946680..0000000000000 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfo.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -package org.elasticsearch.server.cli; - -import java.util.List; - -/** - * A {@link SystemMemoryInfo} implementation that reduces the reported available system memory by a set amount. This is intended to account - * for overhead cost of other bundled Elasticsearch processes, such as the CLI tool launcher. By default, this is - * {@link org.elasticsearch.server.cli.OverheadSystemMemoryInfo#SERVER_CLI_OVERHEAD } but can be overridden via the - * {@code es.total_memory_overhead_bytes} system property. - */ -public class OverheadSystemMemoryInfo extends JvmArgumentParsingSystemMemoryInfo { - static final long SERVER_CLI_OVERHEAD = 100 * 1024L * 1024L; - - private final SystemMemoryInfo delegate; - - public OverheadSystemMemoryInfo(List userDefinedJvmOptions, SystemMemoryInfo delegate) { - super(userDefinedJvmOptions); - this.delegate = delegate; - } - - @Override - public long availableSystemMemory() { - long overheadBytes = getBytesFromSystemProperty("es.total_memory_overhead_bytes", SERVER_CLI_OVERHEAD); - return delegate.availableSystemMemory() - overheadBytes; - } -} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfo.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfo.java index 2d9e32dd63be1..24dcac6013604 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfo.java +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfo.java @@ -17,17 +17,33 @@ * has been specified using the {@code es.total_memory_bytes} system property, or * else returns the value provided by a fallback provider. */ -public final class OverridableSystemMemoryInfo extends JvmArgumentParsingSystemMemoryInfo { +public final class OverridableSystemMemoryInfo implements SystemMemoryInfo { + private final List userDefinedJvmOptions; private final SystemMemoryInfo fallbackSystemMemoryInfo; public OverridableSystemMemoryInfo(final List userDefinedJvmOptions, SystemMemoryInfo fallbackSystemMemoryInfo) { - super(userDefinedJvmOptions); + this.userDefinedJvmOptions = Objects.requireNonNull(userDefinedJvmOptions); this.fallbackSystemMemoryInfo = Objects.requireNonNull(fallbackSystemMemoryInfo); } @Override public long availableSystemMemory() { - return getBytesFromSystemProperty("es.total_memory_bytes", fallbackSystemMemoryInfo.availableSystemMemory()); + + return userDefinedJvmOptions.stream() + .filter(option -> option.startsWith("-Des.total_memory_bytes=")) + .map(totalMemoryBytesOption -> { + try { + long bytes = Long.parseLong(totalMemoryBytesOption.split("=", 2)[1]); + if (bytes < 0) { + throw new IllegalArgumentException("Negative memory size specified in [" + totalMemoryBytesOption + "]"); + } + return bytes; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Unable to parse number of bytes from [" + totalMemoryBytesOption + "]", e); + } + }) + .reduce((previous, current) -> current) // this is effectively findLast(), so that ES_JAVA_OPTS overrides jvm.options + .orElse(fallbackSystemMemoryInfo.availableSystemMemory()); } } diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCli.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCli.java index 358358c81407a..fd8b2fc344cb0 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCli.java +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCli.java @@ -22,22 +22,33 @@ import org.elasticsearch.cli.ProcessInfo; import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.settings.SecureSettings; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.env.Environment; import org.elasticsearch.monitor.jvm.JvmInfo; +import org.elasticsearch.server.launcher.common.LaunchDescriptor; +import java.io.DataOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; /** - * The main CLI for running Elasticsearch. + * The main CLI for preparing the Elasticsearch server launch. + * + *

This program (the "preparer") does all the heavy lifting: parsing options, loading secure + * settings, auto-configuring security, syncing plugins, computing JVM options, and building the + * full command line. It then writes a {@link LaunchDescriptor} to stdout and exits. The + * server-launcher runs the preparer with stdout redirected and reads the descriptor from the + * preparer's stdout pipe, then spawns the actual server process. */ class ServerCli extends EnvironmentAwareCommand { @@ -47,10 +58,6 @@ class ServerCli extends EnvironmentAwareCommand { private final OptionSpecBuilder quietOption; private final OptionSpec enrollmentTokenOption; - // flag for indicating shutdown has begun. we use an AtomicBoolean to double as a synchronization object - private final AtomicBoolean shuttingDown = new AtomicBoolean(false); - private volatile ServerProcess server; - // visible for testing ServerCli() { super("Starts Elasticsearch"); // we configure logging later, so we override the base class from configuring logging @@ -103,35 +110,7 @@ public void execute(Terminal terminal, OptionSet options, Environment env, Proce syncPlugins(terminal, env, processInfo); ServerArgs args = createArgs(options, env, secrets, processInfo); - synchronized (shuttingDown) { - // if we are shutting down there is no reason to start the server - if (shuttingDown.get()) { - terminal.println("CLI is shutting down, skipping starting server process"); - return; - } - this.server = startServer(terminal, processInfo, args); - } - } - - if (options.has(daemonizeOption)) { - server.detach(); - return; - } - - // Call the GC to try and free up as much heap as we can since we don't intend to do much if any more allocation after this - System.gc(); - // we are running in the foreground, so wait for the server to exit - int exitCode = server.waitFor(); - onExit(exitCode); - } - - /** - * A post-exit hook to perform additional processing before the command terminates - * @param exitCode the server process exit code - */ - protected void onExit(int exitCode) throws UserException { - if (exitCode != ExitCodes.OK) { - throw new UserException(exitCode, "Elasticsearch exited unexpectedly"); + prepareLaunch(terminal, processInfo, args, options.has(daemonizeOption)); } } @@ -245,36 +224,80 @@ private ServerArgs createArgs(OptionSet options, Environment env, SecureSettings return new ServerArgs(daemonize, quiet, pidFile, secrets, env.settings(), env.configDir(), env.logsDir()); } - @Override - public void close() throws IOException { - synchronized (shuttingDown) { - shuttingDown.set(true); - if (server != null) { - server.stop(); - } - } - } - - // allow subclasses to access the started process - protected ServerProcess getServer() { - return server; - } - // protected to allow tests to override protected Command loadTool(Map sysprops, String toolname, String libs) { return CliToolProvider.load(sysprops, toolname, libs).create(); } + /** + * Builds a {@link LaunchDescriptor} with all the information the launcher needs to spawn + * the server process and writes it to the terminal's output stream (stdout when the + * launcher runs the preparer with redirect mode). + */ // protected to allow tests to override - protected ServerProcess startServer(Terminal terminal, ProcessInfo processInfo, ServerArgs args) throws Exception { + protected void prepareLaunch(Terminal terminal, ProcessInfo processInfo, ServerArgs args, boolean daemonize) throws Exception { var tempDir = ServerProcessUtils.setupTempDir(processInfo); var jvmOptions = JvmOptionsParser.determineJvmOptions(args, processInfo, tempDir, new MachineDependentHeap()); - var serverProcessBuilder = new ServerProcessBuilder().withTerminal(terminal) - .withProcessInfo(processInfo) - .withServerArgs(args) - .withTempDir(tempDir) - .withJvmOptions(jvmOptions); - return serverProcessBuilder.start(); + + String command = getJavaCommand(processInfo); + List jvmArgs = getJvmArgs(processInfo); + Map environment = getEnvironment(processInfo, tempDir); + byte[] serverArgsBytes = serializeServerArgs(args); + + LaunchDescriptor descriptor = new LaunchDescriptor( + command, + jvmOptions, + jvmArgs, + environment, + args.logsDir().toString(), + tempDir.toString(), + daemonize, + serverArgsBytes + ); + + var out = terminal.getOutputStream(); + if (out == null) { + throw new IllegalStateException("terminal.getOutputStream() is null; preparer must be run with ES_REDIRECT_STDOUT_TO_STDERR"); + } + DataOutputStream dos = new DataOutputStream(out); + descriptor.writeTo(dos); + dos.flush(); + } + + protected static String getJavaCommand(ProcessInfo processInfo) { + Path javaHome = Path.of(processInfo.sysprops().get("java.home")); + boolean isWindows = processInfo.sysprops().get("os.name").startsWith("Windows"); + return javaHome.resolve("bin").resolve("java" + (isWindows ? ".exe" : "")).toString(); + } + + protected static List getJvmArgs(ProcessInfo processInfo) { + Path esHome = processInfo.workingDir(); + return List.of( + "--module-path", + esHome.resolve("lib").toString(), + "--add-modules=jdk.net", + "--add-modules=jdk.management.agent", + "--add-modules=ALL-MODULE-PATH", + "-m", + "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch" + ); + } + + protected static Map getEnvironment(ProcessInfo processInfo, Path tempDir) { + Map envVars = new HashMap<>(processInfo.envVars()); + envVars.remove("ES_TMPDIR"); + if (envVars.containsKey("LIBFFI_TMPDIR") == false) { + envVars.put("LIBFFI_TMPDIR", tempDir.toString()); + } + envVars.remove("ES_JAVA_OPTS"); + return envVars; + } + + protected static byte[] serializeServerArgs(ServerArgs args) throws IOException { + try (BytesStreamOutput out = new BytesStreamOutput()) { + args.writeTo(out); + return BytesReference.toBytes(out.bytes()); + } } // protected to allow tests to override diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcessBuilder.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcessBuilder.java deleted file mode 100644 index 375a12ea5cc7b..0000000000000 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcessBuilder.java +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -package org.elasticsearch.server.cli; - -import org.elasticsearch.bootstrap.ServerArgs; -import org.elasticsearch.cli.ExitCodes; -import org.elasticsearch.cli.ProcessInfo; -import org.elasticsearch.cli.Terminal; -import org.elasticsearch.cli.UserException; -import org.elasticsearch.common.Strings; -import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; -import org.elasticsearch.core.PathUtils; -import org.elasticsearch.core.SuppressForbidden; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; - -/** - * This class is used to create a {@link ServerProcess}. - * Each ServerProcessBuilder instance manages a collection of process attributes. The {@link ServerProcessBuilder#start()} method creates - * a new {@link ServerProcess} instance with those attributes. - * - * Each process builder manages these process attributes: - * - a temporary directory - * - process info to pass through to the new Java subprocess - * - the command line arguments to run Elasticsearch - * - a list of JVM options to be passed to the Elasticsearch Java process - * - a {@link Terminal} to read input and write output from/to the cli console - */ -public class ServerProcessBuilder { - private Path tempDir; - private ServerArgs serverArgs; - private ProcessInfo processInfo; - private List jvmOptions; - private Terminal terminal; - - // this allows mocking the process building by tests - interface ProcessStarter { - Process start(ProcessBuilder pb) throws IOException; - } - - /** - * Specifies the temporary directory to be used by the server process - */ - public ServerProcessBuilder withTempDir(Path tempDir) { - this.tempDir = tempDir; - return this; - } - - /** - * Specifies the process info to pass through to the new Java subprocess - */ - public ServerProcessBuilder withProcessInfo(ProcessInfo processInfo) { - this.processInfo = processInfo; - return this; - } - - /** - * Specifies the command line arguments to run Elasticsearch - */ - public ServerProcessBuilder withServerArgs(ServerArgs serverArgs) { - this.serverArgs = serverArgs; - return this; - } - - /** - * Specifies the JVM options to be passed to the Elasticsearch Java process - */ - public ServerProcessBuilder withJvmOptions(List jvmOptions) { - this.jvmOptions = jvmOptions; - return this; - } - - /** - * Specifies the {@link Terminal} to use for reading input and writing output from/to the cli console - */ - public ServerProcessBuilder withTerminal(Terminal terminal) { - this.terminal = terminal; - return this; - } - - private Map getEnvironment() { - Map envVars = new HashMap<>(processInfo.envVars()); - - envVars.remove("ES_TMPDIR"); - if (envVars.containsKey("LIBFFI_TMPDIR") == false) { - envVars.put("LIBFFI_TMPDIR", tempDir.toString()); - } - envVars.remove("ES_JAVA_OPTS"); - - return envVars; - } - - private List getJvmArgs() { - Path esHome = processInfo.workingDir(); - return List.of( - "--module-path", - esHome.resolve("lib").toString(), - // Special circumstances require some modules (not depended on by the main server module) to be explicitly added: - "--add-modules=jdk.net", // needed to reflectively set extended socket options - "--add-modules=jdk.management.agent", // needed by external debug tools to grab thread and heap dumps - // we control the module path, which may have additional modules not required by server - "--add-modules=ALL-MODULE-PATH", - "-m", - "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch" - ); - } - - private String getCommand() { - Path javaHome = PathUtils.get(processInfo.sysprops().get("java.home")); - - boolean isWindows = processInfo.sysprops().get("os.name").startsWith("Windows"); - return javaHome.resolve("bin").resolve("java" + (isWindows ? ".exe" : "")).toString(); - } - - /** - * Start a server in a new process. - * - * @return A running server process that is ready for requests - * @throws UserException If the process failed during bootstrap - */ - public ServerProcess start() throws UserException { - return start(ProcessBuilder::start); - } - - private void ensureWorkingDirExists() throws UserException { - Path workingDir = serverArgs.logsDir(); - try { - Files.createDirectories(workingDir); - } catch (FileAlreadyExistsException e) { - throw new UserException(ExitCodes.CONFIG, "Logs dir [" + workingDir + "] exists but is not a directory", e); - } catch (IOException e) { - throw new UserException(ExitCodes.CONFIG, "Unable to create logs dir [" + workingDir + "]", e); - } - } - - private static void checkRequiredArgument(Object argument, String argumentName) { - if (argument == null) { - throw new IllegalStateException( - Strings.format("'%s' is a required argument and needs to be specified before calling start()", argumentName) - ); - } - } - - // package private for testing - ServerProcess start(ProcessStarter processStarter) throws UserException { - checkRequiredArgument(tempDir, "tempDir"); - checkRequiredArgument(serverArgs, "serverArgs"); - checkRequiredArgument(processInfo, "processInfo"); - checkRequiredArgument(jvmOptions, "jvmOptions"); - checkRequiredArgument(terminal, "terminal"); - - ensureWorkingDirExists(); - - Process jvmProcess = null; - ErrorPumpThread errorPump; - - boolean success = false; - try { - jvmProcess = createProcess(getCommand(), getJvmArgs(), jvmOptions, getEnvironment(), serverArgs.logsDir(), processStarter); - errorPump = new ErrorPumpThread(terminal, jvmProcess.getErrorStream()); - errorPump.start(); - sendArgs(serverArgs, jvmProcess.getOutputStream()); - - boolean serverOk = errorPump.waitUntilReady(); - if (serverOk == false) { - // something bad happened, wait for the process to exit then rethrow - int exitCode = jvmProcess.waitFor(); - throw new UserException(exitCode, "Elasticsearch died while starting up"); - } - success = true; - } catch (InterruptedException e) { - throw new RuntimeException(e); - } catch (IOException e) { - throw new UncheckedIOException(e); - } finally { - if (success == false && jvmProcess != null && jvmProcess.isAlive()) { - jvmProcess.destroyForcibly(); - } - } - - return new ServerProcess(jvmProcess, errorPump); - } - - private static Process createProcess( - String command, - List jvmArgs, - List jvmOptions, - Map environment, - Path workingDir, - ProcessStarter processStarter - ) throws InterruptedException, IOException { - - var builder = new ProcessBuilder(Stream.concat(Stream.of(command), Stream.concat(jvmOptions.stream(), jvmArgs.stream())).toList()); - builder.environment().putAll(environment); - setWorkingDir(builder, workingDir); - builder.redirectOutput(ProcessBuilder.Redirect.INHERIT); - - return processStarter.start(builder); - } - - @SuppressForbidden(reason = "ProcessBuilder takes File") - private static void setWorkingDir(ProcessBuilder builder, Path path) { - builder.directory(path.toFile()); - } - - private static void sendArgs(ServerArgs args, OutputStream processStdin) { - // DO NOT close the underlying process stdin, since we need to be able to write to it to signal exit - var out = new OutputStreamStreamOutput(processStdin); - try { - args.writeTo(out); - out.flush(); - } catch (IOException ignore) { - // A failure to write here means the process has problems, and it will die anyway. We let this fall through - // so the pump thread can complete, writing out the actual error. All we get here is the failure to write to - // the process pipe, which isn't helpful to print. - } - } -} diff --git a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfoTests.java b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfoTests.java deleted file mode 100644 index 447adf5f6ead4..0000000000000 --- a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverheadSystemMemoryInfoTests.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -package org.elasticsearch.server.cli; - -import org.elasticsearch.test.ESTestCase; - -import java.util.List; - -import static org.elasticsearch.server.cli.OverheadSystemMemoryInfo.SERVER_CLI_OVERHEAD; -import static org.hamcrest.Matchers.is; - -public class OverheadSystemMemoryInfoTests extends ESTestCase { - - private static final long TRUE_SYSTEM_MEMORY = 1024 * 1024 * 1024L; - - public void testNoOptions() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo(List.of(), delegateSystemMemoryInfo()); - assertThat(memoryInfo.availableSystemMemory(), is(TRUE_SYSTEM_MEMORY - SERVER_CLI_OVERHEAD)); - } - - public void testNoOverrides() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo(List.of("-Da=b", "-Dx=y"), delegateSystemMemoryInfo()); - assertThat(memoryInfo.availableSystemMemory(), is(TRUE_SYSTEM_MEMORY - SERVER_CLI_OVERHEAD)); - } - - public void testValidSingleOverride() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - List.of("-Des.total_memory_overhead_bytes=50000"), - delegateSystemMemoryInfo() - ); - assertThat(memoryInfo.availableSystemMemory(), is(TRUE_SYSTEM_MEMORY - 50000)); - } - - public void testValidOverrideInList() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - List.of("-Da=b", "-Des.total_memory_overhead_bytes=50000", "-Dx=y"), - delegateSystemMemoryInfo() - ); - assertThat(memoryInfo.availableSystemMemory(), is(TRUE_SYSTEM_MEMORY - 50000)); - } - - public void testMultipleValidOverridesInList() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - List.of("-Des.total_memory_overhead_bytes=50000", "-Da=b", "-Des.total_memory_overhead_bytes=100000", "-Dx=y"), - delegateSystemMemoryInfo() - ); - assertThat(memoryInfo.availableSystemMemory(), is(TRUE_SYSTEM_MEMORY - 100000)); - } - - public void testNegativeOverride() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - List.of("-Da=b", "-Des.total_memory_overhead_bytes=-123", "-Dx=y"), - delegateSystemMemoryInfo() - ); - try { - memoryInfo.availableSystemMemory(); - fail("expected to fail"); - } catch (IllegalArgumentException e) { - assertThat(e.getMessage(), is("Negative bytes size specified in [-Des.total_memory_overhead_bytes=-123]")); - } - } - - public void testUnparsableOverride() { - final SystemMemoryInfo memoryInfo = new OverheadSystemMemoryInfo( - List.of("-Da=b", "-Des.total_memory_overhead_bytes=invalid", "-Dx=y"), - delegateSystemMemoryInfo() - ); - try { - memoryInfo.availableSystemMemory(); - fail("expected to fail"); - } catch (IllegalArgumentException e) { - assertThat(e.getMessage(), is("Unable to parse number of bytes from [-Des.total_memory_overhead_bytes=invalid]")); - } - } - - private static SystemMemoryInfo delegateSystemMemoryInfo() { - return () -> TRUE_SYSTEM_MEMORY; - } -} diff --git a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfoTests.java b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfoTests.java index b36947505f045..fb0a3fc630c7b 100644 --- a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfoTests.java +++ b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/OverridableSystemMemoryInfoTests.java @@ -64,7 +64,7 @@ public void testNegativeOverride() { memoryInfo.availableSystemMemory(); fail("expected to fail"); } catch (IllegalArgumentException e) { - assertThat(e.getMessage(), is("Negative bytes size specified in [-Des.total_memory_bytes=-123]")); + assertThat(e.getMessage(), is("Negative memory size specified in [-Des.total_memory_bytes=-123]")); } } diff --git a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerCliTests.java b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerCliTests.java index 2792dcd04986f..4a7b60d8979d9 100644 --- a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerCliTests.java +++ b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerCliTests.java @@ -34,12 +34,9 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.concurrent.BrokenBarrierException; -import java.util.concurrent.CyclicBarrier; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -48,13 +45,9 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.emptyString; -import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.matchesRegex; import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.sameInstance; public class ServerCliTests extends CommandTestCase { @@ -150,17 +143,13 @@ public void testPidDirectories() throws Exception { assertOk("-p", "pid"); } - public void assertDaemonized(boolean daemonized, String... args) throws Exception { - argsValidator = serverArgs -> assertThat(serverArgs.daemonize(), equalTo(daemonized)); - assertOk(args); - assertThat(mockServer.detachCalled, is(daemonized)); - assertThat(mockServer.waitForCalled, not(equalTo(daemonized))); - } - public void testDaemonize() throws Exception { - assertDaemonized(true, "-d"); - assertDaemonized(true, "--daemonize"); - assertDaemonized(false); + argsValidator = serverArgs -> assertThat(serverArgs.daemonize(), equalTo(true)); + assertOk("-d"); + argsValidator = serverArgs -> assertThat(serverArgs.daemonize(), equalTo(true)); + assertOk("--daemonize"); + argsValidator = serverArgs -> assertThat(serverArgs.daemonize(), equalTo(false)); + assertOk(); } public void testQuiet() throws Exception { @@ -306,11 +295,12 @@ public void testKeystorePassword() throws Exception { assertKeystorePassword("a-dummy-password"); } - public void testCloseStopsServer() throws Exception { + public void testCloseIsNoop() throws Exception { + // The preparer no longer manages the server process, so close() is a no-op Command command = newCommand(); command.main(new String[0], terminal, new ProcessInfo(sysprops, envVars, esHomeDir)); command.close(); - assertThat(mockServer.stopCalled, is(true)); + // No assertions about server stopping - that's the launcher's job now } public void testIgnoreNullExceptionOutput() throws Exception { @@ -323,30 +313,11 @@ public void testIgnoreNullExceptionOutput() throws Exception { assertThat(terminal.getErrorOutput(), not(containsString("null"))); } - public void testOptionsBuildingInterrupted() throws IOException { - Command command = new TestServerCli() { - @Override - protected ServerProcess startServer(Terminal terminal, ProcessInfo processInfo, ServerArgs args) throws Exception { - throw new InterruptedException("interrupted while get jvm options"); - } - }; - - int exitCode = command.main(new String[0], terminal, new ProcessInfo(sysprops, envVars, esHomeDir)); - assertThat(exitCode, is(ExitCodes.CODE_ERROR)); - - String[] lines = terminal.getErrorOutput().split(System.lineSeparator()); - assertThat(List.of(lines), hasSize(greaterThan(10))); // at least decent sized stacktrace - assertThat(lines[0], is("java.lang.InterruptedException: interrupted while get jvm options")); - assertThat(lines[1], matchesRegex("\\tat org.elasticsearch.server.cli.ServerCliTests.+startServer\\(ServerCliTests.java:\\d+\\)")); - assertThat(lines[lines.length - 1], matchesRegex("\tat java.base/java.lang.Thread.run\\(Thread.java:\\d+\\)")); - - command.close(); - } - - public void testServerExitsNonZero() throws Exception { - mockServerExitCode = 140; - int exitCode = executeMain(); - assertThat(exitCode, equalTo(140)); + public void testPrepareLaunchCalled() throws Exception { + AtomicBoolean prepareLaunchCalled = new AtomicBoolean(false); + argsValidator = args -> prepareLaunchCalled.set(true); + assertOk(); + assertThat(prepareLaunchCalled.get(), is(true)); } public void testSecureSettingsLoaderChoice() throws Exception { @@ -388,52 +359,6 @@ public void testSecureSettingsLoaderWithNullPassword() throws Exception { assertEquals("", loader.password); } - public void testProcessCreationRace() throws Exception { - for (int i = 0; i < 10; ++i) { - CyclicBarrier raceStart = new CyclicBarrier(2); - TestServerCli cli = new TestServerCli() { - @Override - void syncPlugins(Terminal terminal, Environment env, ProcessInfo processInfo) throws Exception { - super.syncPlugins(terminal, env, processInfo); - raceStart.await(); - } - - @Override - public void close() throws IOException { - try { - raceStart.await(); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new AssertionError(ie); - } catch (BrokenBarrierException e) { - throw new AssertionError(e); - } - super.close(); - } - }; - Thread closeThread = new Thread(() -> { - try { - cli.close(); - } catch (IOException e) { - throw new AssertionError(e); - } - }); - closeThread.start(); - cli.main(new String[] {}, terminal, new ProcessInfo(sysprops, envVars, esHomeDir)); - closeThread.join(); - - if (cli.getServer() == null) { - // close won the race, so server should never have been started - assertThat(cli.startServerCalled, is(false)); - } else { - // creation won the race, so check we correctly waited on it and stopped - assertThat(cli.getServer(), sameInstance(mockServer)); - assertThat(mockServer.waitForCalled, is(true)); - assertThat(mockServer.stopCalled, is(true)); - } - } - } - private MockSecureSettingsLoader loadWithMockSecureSettingsLoader() throws Exception { var loader = new MockSecureSettingsLoader(); this.mockSecureSettingsLoader = loader; @@ -454,8 +379,6 @@ interface AutoConfigMethod { } Consumer argsValidator; - private final MockServerProcess mockServer = new MockServerProcess(); - int mockServerExitCode = 0; AutoConfigMethod autoConfigCallback; private final MockAutoConfigCli AUTO_CONFIG_CLI = new MockAutoConfigCli(); @@ -472,7 +395,6 @@ public void resetCommand() { argsValidator = null; autoConfigCallback = null; syncPluginsCallback = null; - mockServerExitCode = 0; } private class MockAutoConfigCli extends EnvironmentAwareCommand { @@ -490,7 +412,6 @@ protected void execute(Terminal terminal, OptionSet options, ProcessInfo process @Override public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception { - // TODO: fake errors, check password from terminal, allow tests to make elasticsearch.yml change if (autoConfigCallback != null) { autoConfigCallback.autoconfig(terminal, options, env, processInfo); } @@ -515,48 +436,7 @@ public void execute(Terminal terminal, OptionSet options, Environment env, Proce } } - private class MockServerProcess extends ServerProcess { - volatile boolean detachCalled = false; - volatile boolean waitForCalled = false; - volatile boolean stopCalled = false; - - MockServerProcess() { - super(null, null); - } - - @Override - public long pid() { - return 12345; - } - - @Override - public void detach() { - assert detachCalled == false; - detachCalled = true; - } - - @Override - public int waitFor() { - assert waitForCalled == false; - waitForCalled = true; - return mockServerExitCode; - } - - @Override - public void stop() { - assert stopCalled == false; - stopCalled = true; - } - - void reset() { - detachCalled = false; - waitForCalled = false; - stopCalled = false; - } - } - private class TestServerCli extends ServerCli { - boolean startServerCalled = false; @Override protected Command loadTool(Map sysprops, String toolname, String libs) { @@ -588,7 +468,6 @@ Environment autoConfigureSecurity( void syncPlugins(Terminal terminal, Environment env, ProcessInfo processInfo) throws Exception { if (mockSecureSettingsLoader != null && mockSecureSettingsLoader instanceof MockSecureSettingsLoader mock) { mock.verifiedEnv = true; - // equals as a pointer, environment shouldn't be changed if autoconfigure is not supported assertFalse(mockSecureSettingsLoader.supportsSecurityAutoConfiguration()); assertTrue(mock.environment == env); } @@ -606,13 +485,10 @@ protected SecureSettingsLoader secureSettingsLoader(Environment env) { } @Override - protected ServerProcess startServer(Terminal terminal, ProcessInfo processInfo, ServerArgs args) throws Exception { - startServerCalled = true; + protected void prepareLaunch(Terminal terminal, ProcessInfo processInfo, ServerArgs args, boolean daemonize) throws Exception { if (argsValidator != null) { argsValidator.accept(args); } - mockServer.reset(); - return mockServer; } } @@ -632,8 +508,6 @@ static class MockSecureSettingsLoader implements SecureSettingsLoader { @Override public SecureSettingsLoader.LoadedSecrets load(Environment environment, Terminal terminal) throws IOException { loaded = true; - // Stash the environment pointer, so we can compare it. Environment shouldn't be changed for - // loaders that don't autoconfigure. this.environment = environment; SecureString password = null; @@ -685,7 +559,6 @@ public LoadedSecrets load(Environment environment, Terminal terminal) throws Exc @Override public SecureSettings bootstrap(Environment environment, SecureString password) throws Exception { this.bootstrapped = true; - // make sure we don't fail in fips mode when we run with an empty password if (inFipsJvm() && (password == null || password.isEmpty())) { return KeyStoreWrapper.create(); } diff --git a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerProcessTests.java b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerProcessTests.java deleted file mode 100644 index 86bc336f0b4ba..0000000000000 --- a/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerProcessTests.java +++ /dev/null @@ -1,448 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -package org.elasticsearch.server.cli; - -import org.elasticsearch.bootstrap.BootstrapInfo; -import org.elasticsearch.bootstrap.ServerArgs; -import org.elasticsearch.cli.ExitCodes; -import org.elasticsearch.cli.MockTerminal; -import org.elasticsearch.cli.ProcessInfo; -import org.elasticsearch.cli.UserException; -import org.elasticsearch.common.io.stream.InputStreamStreamInput; -import org.elasticsearch.common.settings.KeyStoreWrapper; -import org.elasticsearch.common.settings.SecureSettings; -import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.core.IOUtils; -import org.elasticsearch.test.ESTestCase; -import org.junit.AfterClass; -import org.junit.Before; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; -import java.io.PrintStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import static org.elasticsearch.bootstrap.BootstrapInfo.SERVER_READY_MARKER; -import static org.elasticsearch.server.cli.ProcessUtil.nonInterruptibleVoid; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasEntry; -import static org.hamcrest.Matchers.hasItems; -import static org.hamcrest.Matchers.hasKey; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; - -public class ServerProcessTests extends ESTestCase { - - private static final ExecutorService mockJvmProcessExecutor = Executors.newSingleThreadExecutor(); - final MockTerminal terminal = MockTerminal.create(); - protected final Map sysprops = new HashMap<>(); - protected final Map envVars = new HashMap<>(); - Path esHomeDir; - Path logsDir; - Settings.Builder nodeSettings; - ProcessValidator processValidator; - MainMethod mainCallback; - Runnable forceStopCallback; - MockElasticsearchProcess process; - SecureSettings secrets; - - interface MainMethod { - void main(ServerArgs args, InputStream stdin, PrintStream stderr, AtomicInteger exitCode) throws IOException; - } - - interface ProcessValidator { - void validate(ProcessBuilder processBuilder) throws IOException; - } - - int runForeground() throws Exception { - var server = startProcess(false, false); - return server.waitFor(); - } - - @Before - public void resetEnv() { - esHomeDir = createTempDir(); - terminal.reset(); - sysprops.clear(); - sysprops.put("os.name", "Linux"); - sysprops.put("java.home", "javahome"); - sysprops.put("es.path.home", esHomeDir.toString()); - logsDir = esHomeDir.resolve("logs"); - envVars.clear(); - nodeSettings = Settings.builder(); - processValidator = null; - mainCallback = null; - forceStopCallback = null; - secrets = KeyStoreWrapper.create(); - } - - @AfterClass - public static void cleanupExecutor() { - mockJvmProcessExecutor.shutdown(); - } - - // a "process" that is really another thread - private class MockElasticsearchProcess extends Process { - private final PipedOutputStream processStdin = new PipedOutputStream(); - private final PipedInputStream processStderr = new PipedInputStream(); - private final PipedInputStream stdin = new PipedInputStream(); - private final PipedOutputStream stderr = new PipedOutputStream(); - - private final AtomicInteger exitCode = new AtomicInteger(); - private final AtomicReference processException = new AtomicReference<>(); - private final AtomicReference assertion = new AtomicReference<>(); - private final Future main; - - MockElasticsearchProcess() throws IOException { - stdin.connect(processStdin); - stderr.connect(processStderr); - this.main = mockJvmProcessExecutor.submit(() -> { - var in = new InputStreamStreamInput(stdin); - try { - var serverArgs = new ServerArgs(in); - try (var err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { - if (mainCallback != null) { - mainCallback.main(serverArgs, stdin, err, exitCode); - } else { - err.println(SERVER_READY_MARKER); - } - } - } catch (IOException e) { - processException.set(e); - } catch (AssertionError e) { - assertion.set(e); - } - IOUtils.closeWhileHandlingException(stdin, stderr); - }); - } - - @Override - public OutputStream getOutputStream() { - return processStdin; - } - - @Override - public InputStream getInputStream() { - return InputStream.nullInputStream(); - } - - @Override - public InputStream getErrorStream() { - return processStderr; - } - - @Override - public long pid() { - return 12345; - } - - @Override - public int waitFor() throws InterruptedException { - try { - main.get(); - } catch (ExecutionException e) { - throw new AssertionError(e); - } catch (CancellationException e) { - return 137; // process killed - } - if (processException.get() != null) { - throw new AssertionError("Process failed", processException.get()); - } - if (assertion.get() != null) { - throw assertion.get(); - } - return exitCode.get(); - } - - @Override - public int exitValue() { - if (main.isDone() == false) { - throw new IllegalThreadStateException(); // match spec - } - return exitCode.get(); - } - - @Override - public void destroy() { - fail("Tried to kill ES process directly"); - } - - public Process destroyForcibly() { - main.cancel(true); - IOUtils.closeWhileHandlingException(stdin, stderr); - forceStopCallback.run(); - return this; - } - } - - ProcessInfo createProcessInfo() { - return new ProcessInfo(Map.copyOf(sysprops), Map.copyOf(envVars), esHomeDir); - } - - ServerArgs createServerArgs(boolean daemonize, boolean quiet) { - return new ServerArgs(daemonize, quiet, null, secrets, nodeSettings.build(), esHomeDir.resolve("config"), logsDir); - } - - ServerProcess startProcess(boolean daemonize, boolean quiet) throws Exception { - var pinfo = createProcessInfo(); - ServerProcessBuilder.ProcessStarter starter = pb -> { - if (processValidator != null) { - processValidator.validate(pb); - } - process = new MockElasticsearchProcess(); - return process; - }; - var serverProcessBuilder = new ServerProcessBuilder().withTerminal(terminal) - .withProcessInfo(pinfo) - .withServerArgs(createServerArgs(daemonize, quiet)) - .withJvmOptions(List.of()) - .withTempDir(ServerProcessUtils.setupTempDir(pinfo)); - return serverProcessBuilder.start(starter); - } - - public void testProcessBuilder() throws Exception { - processValidator = pb -> { - assertThat(pb.redirectInput(), equalTo(ProcessBuilder.Redirect.PIPE)); - assertThat(pb.redirectOutput(), equalTo(ProcessBuilder.Redirect.INHERIT)); - assertThat(pb.redirectError(), equalTo(ProcessBuilder.Redirect.PIPE)); - assertThat(String.valueOf(pb.directory()), equalTo(esHomeDir.resolve("logs").toString())); - }; - mainCallback = (args, stdin, stderr, exitCode) -> { - try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { - err.println("stderr message"); - err.println(SERVER_READY_MARKER); - } - }; - runForeground(); - assertThat(terminal.getErrorOutput(), containsString("stderr message")); - } - - public void testPid() throws Exception { - var server = startProcess(true, false); - assertThat(server.pid(), equalTo(12345L)); - server.stop(); - } - - public void testBootstrapError() throws Exception { - mainCallback = (args, stdin, stderr, exitCode) -> { - stderr.println("a bootstrap exception"); - exitCode.set(ExitCodes.CONFIG); - }; - var e = expectThrows(UserException.class, this::runForeground); - assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); - assertThat(terminal.getErrorOutput(), containsString("a bootstrap exception")); - } - - public void testStartError() { - processValidator = pb -> { throw new IOException("something went wrong"); }; - var e = expectThrows(UncheckedIOException.class, this::runForeground); - assertThat(e.getCause().getMessage(), equalTo("something went wrong")); - } - - public void testEnvPassthrough() throws Exception { - envVars.put("MY_ENV", "foo"); - processValidator = pb -> { assertThat(pb.environment(), hasEntry(equalTo("MY_ENV"), equalTo("foo"))); }; - runForeground(); - } - - public void testLibffiEnv() throws Exception { - processValidator = pb -> { - assertThat(pb.environment(), hasKey("LIBFFI_TMPDIR")); - Path libffi = Paths.get(pb.environment().get("LIBFFI_TMPDIR")); - assertThat(Files.exists(libffi), is(true)); - }; - runForeground(); - envVars.put("LIBFFI_TMPDIR", "mylibffi_tmp"); - processValidator = pb -> { assertThat(pb.environment(), hasEntry(equalTo("LIBFFI_TMPDIR"), equalTo("mylibffi_tmp"))); }; - runForeground(); - } - - public void testEnvCleared() throws Exception { - Path customTmpDir = createTempDir(); - envVars.put("ES_TMPDIR", customTmpDir.toString()); - envVars.put("ES_JAVA_OPTS", "-Dmyoption=foo"); - - processValidator = pb -> { - assertThat(pb.environment(), not(hasKey("ES_TMPDIR"))); - assertThat(pb.environment(), not(hasKey("ES_JAVA_OPTS"))); - }; - runForeground(); - } - - public void testCommandLineSysprops() throws Exception { - ServerProcessBuilder.ProcessStarter starter = pb -> { - assertThat(pb.command(), hasItems("-Dfoo1=bar", "-Dfoo2=baz")); - process = new MockElasticsearchProcess(); - return process; - }; - var serverProcessBuilder = new ServerProcessBuilder().withTerminal(terminal) - .withProcessInfo(createProcessInfo()) - .withServerArgs(createServerArgs(false, false)) - .withJvmOptions(List.of("-Dfoo1=bar", "-Dfoo2=baz")) - .withTempDir(Path.of(".")); - serverProcessBuilder.start(starter).waitFor(); - } - - public void testServerProcessBuilderMissingArgumentError() throws Exception { - ServerProcessBuilder.ProcessStarter starter = pb -> new MockElasticsearchProcess(); - var serverProcessBuilder = new ServerProcessBuilder().withTerminal(terminal) - .withProcessInfo(createProcessInfo()) - .withServerArgs(createServerArgs(false, false)) - .withTempDir(Path.of(".")); - var ex = expectThrows(IllegalStateException.class, () -> serverProcessBuilder.start(starter).waitFor()); - assertThat(ex.getMessage(), equalTo("'jvmOptions' is a required argument and needs to be specified before calling start()")); - } - - public void testCommandLine() throws Exception { - String mainClass = "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch"; - String modulePath = esHomeDir.resolve("lib").toString(); - Path javaBin = Paths.get("javahome").resolve("bin"); - AtomicReference expectedJava = new AtomicReference<>(javaBin.resolve("java").toString()); - processValidator = pb -> { assertThat(pb.command(), hasItems(expectedJava.get(), "--module-path", modulePath, "-m", mainClass)); }; - runForeground(); - - sysprops.put("os.name", "Windows 10"); - sysprops.put("java.io.tmpdir", createTempDir().toString()); - expectedJava.set(javaBin.resolve("java.exe").toString()); - runForeground(); - } - - public void testDetach() throws Exception { - mainCallback = (args, stdin, stderr, exitCode) -> { - assertThat(args.daemonize(), equalTo(true)); - stderr.println(SERVER_READY_MARKER); - stderr.println("final message"); - stderr.close(); - // will block until stdin closed manually after test - assertThat(stdin.read(), equalTo(-1)); - }; - var server = startProcess(true, false); - server.detach(); - assertThat(terminal.getErrorOutput(), containsString("final message")); - server.stop(); // this should be a noop, and will fail the stdin read assert above if shutdown sent - process.processStdin.close(); // unblock the "process" thread so it can exit - } - - public void testStop() throws Exception { - CountDownLatch mainReady = new CountDownLatch(1); - mainCallback = (args, stdin, stderr, exitCode) -> { - stderr.println(SERVER_READY_MARKER); - nonInterruptibleVoid(mainReady::await); - stderr.println("final message"); - }; - var server = startProcess(false, false); - mainReady.countDown(); - server.stop(); - assertThat(process.main.isDone(), is(true)); // stop should have waited - assertThat(terminal.getErrorOutput(), containsString("final message")); - } - - public void testForceStop() throws Exception { - CountDownLatch blockMain = new CountDownLatch(1); - CountDownLatch inMain = new CountDownLatch(1); - mainCallback = (args, stdin, stderr, exitCode) -> { - stderr.println(SERVER_READY_MARKER); - inMain.countDown(); - nonInterruptibleVoid(blockMain::await); - }; - var server = startProcess(false, false); - nonInterruptibleVoid(inMain::await); - forceStopCallback = blockMain::countDown; - server.forceStop(); - - assertThat(process.main.isCancelled(), is(true)); // stop should have waited - } - - public void testWaitFor() throws Exception { - CountDownLatch mainReady = new CountDownLatch(1); - mainCallback = (args, stdin, stderr, exitCode) -> { - stderr.println(SERVER_READY_MARKER); - mainReady.countDown(); - assertThat(stdin.read(), equalTo((int) BootstrapInfo.SERVER_SHUTDOWN_MARKER)); - stderr.println("final message"); - }; - var server = startProcess(false, false); - - CompletableFuture stopping = new CompletableFuture<>(); - new Thread(() -> { - try { - // simulate stop run as shutdown hook in another thread, eg from Ctrl-C - nonInterruptibleVoid(mainReady::await); - server.stop(); - stopping.complete(null); - } catch (Throwable e) { - stopping.completeExceptionally(e); - } - }).start(); - int exitCode = server.waitFor(); - assertThat(process.main.isDone(), is(true)); - assertThat(exitCode, equalTo(0)); - assertThat(terminal.getErrorOutput(), containsString("final message")); - // rethrow any potential exception observed while stopping - stopping.get(); - } - - public void testProcessDies() throws Exception { - CountDownLatch mainExit = new CountDownLatch(1); - mainCallback = (args, stdin, stderr, exitCode) -> { - stderr.println(SERVER_READY_MARKER); - stderr.println("fatal message"); - stderr.close(); // mimic pipe break if cli process dies - nonInterruptibleVoid(mainExit::await); - exitCode.set(-9); - }; - var server = startProcess(false, false); - mainExit.countDown(); - int exitCode = server.waitFor(); - assertThat(exitCode, equalTo(-9)); - } - - public void testLogsDirIsFile() throws Exception { - Files.createFile(logsDir); - var e = expectThrows(UserException.class, this::runForeground); - assertThat(e.getMessage(), containsString("exists but is not a directory")); - } - - public void testLogsDirCreateParents() throws Exception { - Path testDir = createTempDir(); - logsDir = testDir.resolve("subdir/logs"); - processValidator = pb -> assertThat(String.valueOf(pb.directory()), equalTo(logsDir.toString())); - runForeground(); - } - - public void testLogsCreateFailure() throws Exception { - Path testDir = createTempDir(); - Path parentFile = testDir.resolve("exists"); - Files.createFile(parentFile); - logsDir = parentFile.resolve("logs"); - var e = expectThrows(UserException.class, this::runForeground); - assertThat(e.getMessage(), containsString("Unable to create logs dir")); - } -} diff --git a/distribution/tools/server-launcher/build.gradle b/distribution/tools/server-launcher/build.gradle new file mode 100644 index 0000000000000..ea07d3d5dac14 --- /dev/null +++ b/distribution/tools/server-launcher/build.gradle @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import org.elasticsearch.gradle.VersionProperties +import org.elasticsearch.gradle.internal.docker.NativeImageBuildTask +import org.elasticsearch.gradle.internal.precommit.CheckForbiddenApisTask + +apply plugin: 'elasticsearch.build' + +configurations { + nativeLinuxX64 { + canBeConsumed = true + canBeResolved = false + } + nativeLinuxAarch64 { + canBeConsumed = true + canBeResolved = false + } +} + +dependencies { + implementation project(':libs:server-launcher-common') + testImplementation project(':test:framework') +} + +tasks.withType(CheckForbiddenApisTask).configureEach { + // ServerLauncher uses various forbidden APIs such as System.exit() and printing directly to stdout and stderr. + enabled = false +} + +// --- GraalVM native-image build (Linux only, Docker-based) --- +def graalVmMajor = VersionProperties.getBundledJdkMajorVersion() +def nativeImageImage = "ghcr.io/graalvm/native-image-community:${graalVmMajor}-ol8" +def launcherMainClass = 'org.elasticsearch.server.launcher.ServerLauncher' + +def nativeImageLinuxX64 = tasks.register('nativeImageLinuxX64', NativeImageBuildTask) { + description = 'Builds Linux x86_64 native server-launcher binary via Docker' + group = 'build' + classpath = sourceSets.main.runtimeClasspath + imageTag = nativeImageImage + platform = 'linux/amd64' + mainClass = launcherMainClass + outputFile = layout.buildDirectory.file('native/linux-x86_64/server-launcher') +} + +def nativeImageLinuxAarch64 = tasks.register('nativeImageLinuxAarch64', NativeImageBuildTask) { + description = 'Builds Linux aarch64 native server-launcher binary via Docker' + group = 'build' + classpath = sourceSets.main.runtimeClasspath + imageTag = nativeImageImage + platform = 'linux/arm64' + mainClass = launcherMainClass + outputFile = layout.buildDirectory.file('native/linux-aarch64/server-launcher') +} + +artifacts { + nativeLinuxX64(nativeImageLinuxX64) + nativeLinuxAarch64(nativeImageLinuxAarch64) +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ErrorPumpThread.java similarity index 57% rename from distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java rename to distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ErrorPumpThread.java index 1498fc8ae86dc..126c599f574b2 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java +++ b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ErrorPumpThread.java @@ -7,49 +7,49 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -package org.elasticsearch.server.cli; +package org.elasticsearch.server.launcher; -import org.elasticsearch.bootstrap.BootstrapInfo; -import org.elasticsearch.cli.Terminal; -import org.elasticsearch.cli.Terminal.Verbosity; -import org.elasticsearch.common.regex.Regex; +import org.elasticsearch.server.launcher.common.ProcessUtil; import java.io.BufferedReader; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.function.Predicate; - -import static org.elasticsearch.bootstrap.BootstrapInfo.SERVER_READY_MARKER; -import static org.elasticsearch.server.cli.ProcessUtil.nonInterruptibleVoid; +import java.util.regex.Pattern; /** - * A thread which reads stderr of the jvm process and writes it to this process' stderr. + * A thread which reads stderr of the server JVM process and writes it to this process' stderr. * *

The thread watches for a special state marker from the process. The ascii character - * {@link BootstrapInfo#SERVER_READY_MARKER} signals the server is ready and the cli may - * detach if daemonizing. All other messages are passed through to stderr. + * {@code \u0018} signals the server is ready and the launcher may detach if daemonizing. + * All other messages are passed through to stderr. */ -class ErrorPumpThread extends Thread implements Closeable { +public class ErrorPumpThread extends Thread implements Closeable { + /** Messages / lines predicate to filter from the output. */ + private static final Predicate filter = Pattern.compile( + "WARNING: Using incubator modules: jdk\\.incubator\\.vector" + // requires log4j2 upgrade, see https://github.com/elastic/elasticsearch/issues/132035 + + "|WARNING: Use of the three-letter time zone ID .* is deprecated and it will be removed in a future release" + ).asMatchPredicate(); + + static final char SERVER_READY_MARKER = '\u0018'; + private final BufferedReader reader; - private final Terminal terminal; + private final PrintStream errOutput; - // a latch which changes state when the server is ready or has had a bootstrap error private final CountDownLatch readyOrDead = new CountDownLatch(1); - - // a flag denoting whether the ready marker has been received by the server process private volatile boolean ready; - - // an unexpected io failure that occurred while pumping stderr private volatile IOException ioFailure; - ErrorPumpThread(Terminal terminal, InputStream errInput) { - super("server-cli[stderr_pump]"); + public ErrorPumpThread(InputStream errInput, PrintStream errOutput) { + super("server-launcher[stderr_pump]"); this.reader = new BufferedReader(new InputStreamReader(errInput, StandardCharsets.UTF_8)); - this.terminal = terminal; + this.errOutput = errOutput; } private void checkForIoFailure() throws IOException { @@ -69,11 +69,11 @@ public void close() throws IOException { /** * Waits until the server ready marker has been received. * - * {@code true} if successful, {@code false} if a startup error occurred + * @return {@code true} if successful, {@code false} if a startup error occurred * @throws IOException if there was a problem reading from stderr of the process */ - boolean waitUntilReady() throws IOException { - nonInterruptibleVoid(readyOrDead::await); + public boolean waitUntilReady() throws IOException { + ProcessUtil.nonInterruptibleVoid(readyOrDead::await); checkForIoFailure(); return ready; } @@ -81,17 +81,10 @@ boolean waitUntilReady() throws IOException { /** * Waits for the stderr pump thread to exit. */ - void drain() { - nonInterruptibleVoid(this::join); + public void drain() { + ProcessUtil.nonInterruptibleVoid(this::join); } - /** Messages / lines predicate to filter from the output. */ - private static Predicate filter = Regex.simpleMatcher( - "WARNING: Using incubator modules: jdk.incubator.vector", - // requires log4j2 upgrade, see https://github.com/elastic/elasticsearch/issues/132035 - "WARNING: Use of the three-letter time zone ID * is deprecated and it will be removed in a future release" - ); - @Override public void run() { try { @@ -101,13 +94,13 @@ public void run() { ready = true; readyOrDead.countDown(); } else if (filter.test(line) == false) { - terminal.errorPrintln(Verbosity.SILENT, line, false); + errOutput.println(line); } } } catch (IOException e) { ioFailure = e; } finally { - terminal.flush(); + errOutput.flush(); readyOrDead.countDown(); } } diff --git a/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerLauncher.java b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerLauncher.java new file mode 100644 index 0000000000000..550c4c28bd8fc --- /dev/null +++ b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerLauncher.java @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.server.launcher; + +import org.elasticsearch.server.launcher.common.LaunchDescriptor; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +/** + * Minimal launcher for the Elasticsearch server process. + * + *

This program is exec'd directly by the startup script. It spawns the preparer (server-cli) + * as a child process, reads the resulting {@link LaunchDescriptor}, spawns the server JVM process, + * pipes the serialized ServerArgs bytes to the server's stdin, pumps stderr for the ready marker, + * and waits for the server to exit. + * + *

This program has zero Elasticsearch dependencies beyond the shared launcher-common library. + * + *

Subclasses (e.g. {@code ServerlessServerLauncher}) can override lifecycle hooks to customize + * behavior without duplicating shared logic. + * + * @param the descriptor type, must extend {@link LaunchDescriptor} + */ +public class ServerLauncher { + + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private volatile ServerProcess server; + + public static void main(String[] args) throws Exception { + new ServerLauncher().run(args); + } + + protected void run(String[] args) throws Exception { + Process preparerProcess = startPreparer(args); + D descriptor = readDescriptorFromPreparer(preparerProcess); + if (descriptor == null) { + return; + } + + installShutdownHook(); + + Process[] rawProcessHolder = new Process[1]; + server = startServer(descriptor, pb -> { + try { + Process p = startProcess(pb); + rawProcessHolder[0] = p; + return p; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + onServerStarted(descriptor, server, rawProcessHolder[0]); + + if (descriptor.daemonize()) { + server.detach(); + onDaemonize(descriptor); + return; + } + + int exitCode = server.waitFor(); + onServerExit(descriptor, exitCode); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + // ---- Overridable lifecycle hooks ---- + + /** + * Returns the CLI name passed as {@code -Dcli.name} to the preparer process. + */ + protected String cliName() { + return "server"; + } + + /** + * Returns the CLI libs path passed as {@code -Dcli.libs} to the preparer process. + */ + protected String cliLibs() { + return "lib/tools/server-cli"; + } + + /** + * Returns the name for the JVM shutdown hook thread. + */ + protected String shutdownHookName() { + return "server-launcher-shutdown"; + } + + /** + * Returns extra system property names to forward from the launcher to the preparer process. + */ + protected List extraPreparerSystemProperties() { + return List.of(); + } + + /** + * Reads a launch descriptor from the preparer's stdout stream. + * Returns null if no bytes were written (e.g. --version or --help was used). + *

+ * Subclasses override this to read their specific descriptor type. + */ + @SuppressWarnings("unchecked") + protected D readDescriptorFromStream(InputStream in) throws IOException { + byte[] bytes = in.readAllBytes(); + if (bytes.length == 0) { + return null; + } + return (D) LaunchDescriptor.readFrom(new DataInputStream(new ByteArrayInputStream(bytes))); + } + + /** + * Starts a process from the given builder. Subclasses can override to capture the raw process. + */ + protected Process startProcess(ProcessBuilder pb) throws IOException { + return pb.start(); + } + + /** + * Called after the server process has started and the ready marker has been received. + */ + protected void onServerStarted(D descriptor, ServerProcess server, Process rawProcess) throws IOException {} + + /** + * Called before returning in daemon mode, after the server has been detached. + */ + protected void onDaemonize(D descriptor) {} + + /** + * Called after the server process exits (non-daemon mode). + */ + protected void onServerExit(D descriptor, int exitCode) {} + + // ---- Shared logic (private) ---- + + private Process startPreparer(String[] userArgs) throws IOException { + String java = requireEnv("JAVA"); + String esHome = requireEnv("ES_HOME"); + String esPathConf = requireEnv("ES_PATH_CONF"); + String esDistType = System.getenv("ES_DISTRIBUTION_TYPE"); + String javaType = System.getenv("JAVA_TYPE"); + String cliJavaOpts = System.getenv("CLI_JAVA_OPTS"); + + String classpath = esHome + + File.separator + + "lib" + + File.separator + + "*" + + File.pathSeparator + + esHome + + File.separator + + "lib" + + File.separator + + "cli-launcher" + + File.separator + + "*"; + + List command = new ArrayList<>(); + command.add(java); + + if (cliJavaOpts != null && cliJavaOpts.isBlank() == false) { + Collections.addAll(command, cliJavaOpts.trim().split("\\s+")); + } + + command.add("-Dcli.name=" + cliName()); + command.add("-Dcli.libs=" + cliLibs()); + command.add("-Des.path.home=" + esHome); + command.add("-Des.path.conf=" + esPathConf); + if (esDistType != null) { + command.add("-Des.distribution.type=" + esDistType); + } + if (javaType != null) { + command.add("-Des.java.type=" + javaType); + } + + for (String prop : extraPreparerSystemProperties()) { + String value = System.getProperty(prop); + if (value != null) { + command.add("-D" + prop + "=" + value); + } + } + + command.add("-cp"); + command.add(classpath); + command.add("org.elasticsearch.launcher.CliToolLauncher"); + + command.addAll(Arrays.asList(userArgs)); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + pb.redirectOutput(ProcessBuilder.Redirect.PIPE); + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + pb.environment().put("ES_REDIRECT_STDOUT_TO_STDERR", "true"); + + return pb.start(); + } + + private D readDescriptorFromPreparer(Process preparerProcess) throws Exception { + D descriptor = null; + Exception readException = null; + try { + descriptor = readDescriptorFromStream(preparerProcess.getInputStream()); + } catch (Exception e) { + readException = e; + } + int preparerExit = preparerProcess.waitFor(); + if (preparerExit != 0) { + System.exit(preparerExit); + } + if (readException != null) { + if (readException instanceof IOException io) { + throw new UncheckedIOException(io); + } + throw new RuntimeException(readException); + } + return descriptor; + } + + private void installShutdownHook() { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + synchronized (shuttingDown) { + shuttingDown.set(true); + if (server != null) { + try { + server.stop(); + } catch (IOException e) { + System.err.println("Error stopping server: " + e.getMessage()); + } + } + } + }, shutdownHookName())); + } + + /** + * Starts the server process from the given descriptor and process starter function. + * Package-visible for testing. + */ + ServerProcess startServer(LaunchDescriptor descriptor, Function processStarter) throws Exception { + ensureWorkingDirExists(descriptor.workingDir()); + + List command = new ArrayList<>(); + command.add(descriptor.command()); + command.addAll(descriptor.jvmOptions()); + command.addAll(descriptor.jvmArgs()); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.environment().clear(); + pb.environment().putAll(descriptor.environment()); + pb.directory(new File(descriptor.workingDir())); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + + Process jvmProcess = null; + ErrorPumpThread errorPump; + boolean success = false; + + try { + jvmProcess = processStarter.apply(pb); + errorPump = new ErrorPumpThread(jvmProcess.getErrorStream(), System.err); + errorPump.start(); + sendServerArgs(descriptor.serverArgsBytes(), jvmProcess.getOutputStream()); + + boolean serverOk = errorPump.waitUntilReady(); + if (serverOk == false) { + int exitCode = jvmProcess.waitFor(); + System.err.println("Elasticsearch died while starting up, exit code: " + exitCode); + System.exit(exitCode != 0 ? exitCode : 1); + } + success = true; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + if (success == false && jvmProcess != null && jvmProcess.isAlive()) { + jvmProcess.destroyForcibly(); + } + } + + return new ServerProcess(jvmProcess, errorPump); + } + + private static void ensureWorkingDirExists(String workingDir) throws Exception { + Path path = Path.of(workingDir); + if (Files.exists(path) && Files.isDirectory(path) == false) { + System.err.println("Error: working directory exists but is not a directory: " + workingDir); + System.exit(1); + } + Files.createDirectories(path); + } + + private static void sendServerArgs(byte[] serverArgsBytes, OutputStream processStdin) { + try { + processStdin.write(serverArgsBytes); + processStdin.flush(); + } catch (IOException ignore) { + // A failure to write here means the process has problems, and it will die anyway. + // The error pump thread will report the actual error. + } + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + System.err.println("Error: required environment variable " + name + " is not set"); + System.exit(1); + } + return value; + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerProcess.java similarity index 54% rename from distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java rename to distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerProcess.java index 89377b5b3f8bb..ac3c7566ca856 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java +++ b/distribution/tools/server-launcher/src/main/java/org/elasticsearch/server/launcher/ServerProcess.java @@ -7,25 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -package org.elasticsearch.server.cli; +package org.elasticsearch.server.launcher; -import org.elasticsearch.bootstrap.BootstrapInfo; -import org.elasticsearch.core.IOUtils; +import org.elasticsearch.server.launcher.common.ProcessUtil; import java.io.IOException; import java.io.OutputStream; -import static org.elasticsearch.server.cli.ProcessUtil.nonInterruptible; - /** * A helper to control a {@link Process} running the main Elasticsearch server. * - *

The process can be started by calling {@link ServerProcessBuilder#start()}. - * The process is controlled by internally sending arguments and control signals on stdin, - * and receiving control signals on stderr. The start method does not return until the - * server is ready to process requests and has exited the bootstrap thread. - * - *

The caller starting a {@link ServerProcess} can then do one of several things: + *

The caller can: *

    *
  • Block on the server process exiting, by calling {@link #waitFor()}
  • *
  • Detach from the server process by calling {@link #detach()}
  • @@ -34,16 +26,13 @@ */ public class ServerProcess { - // the actual java process of the server - private final Process jvmProcess; + static final char SERVER_SHUTDOWN_MARKER = '\u001B'; - // the thread pumping stderr watching for state change messages + private final Process jvmProcess; private final ErrorPumpThread errorPump; - - // a flag marking whether the streams of the java subprocess have been closed private volatile boolean detached = false; - ServerProcess(Process jvmProcess, ErrorPumpThread errorPump) { + public ServerProcess(Process jvmProcess, ErrorPumpThread errorPump) { this.jvmProcess = jvmProcess; this.errorPump = errorPump; } @@ -57,13 +46,14 @@ public long pid() { /** * Detaches the server process from the current process, enabling the current process to exit. - * - * @throws IOException If an I/O error occurred while reading stderr or closing any of the standard streams */ public synchronized void detach() throws IOException { errorPump.drain(); try { - IOUtils.close(jvmProcess.getOutputStream(), jvmProcess.getInputStream(), jvmProcess.getErrorStream(), errorPump); + jvmProcess.getOutputStream().close(); + jvmProcess.getInputStream().close(); + jvmProcess.getErrorStream().close(); + errorPump.close(); } finally { detached = true; } @@ -74,44 +64,29 @@ public synchronized void detach() throws IOException { */ public int waitFor() throws IOException { errorPump.drain(); - int exitCode = nonInterruptible(jvmProcess::waitFor); + int exitCode = ProcessUtil.nonInterruptible(jvmProcess::waitFor); errorPump.close(); return exitCode; } /** - * Stop the subprocess. - * - *

    This sends a special code, {@link BootstrapInfo#SERVER_SHUTDOWN_MARKER} to the stdin - * of the process, then waits for the process to exit. - * - *

    Note that if {@link #detach()} has been called, this method is a no-op. + * Stop the subprocess by sending the shutdown marker to stdin, then wait for exit. */ public synchronized void stop() throws IOException { if (detached) { return; } - sendShutdownMarker(); - waitFor(); // ignore exit code, we are already shutting down - } - - /** - * Stop the subprocess, sending a SIGKILL. - */ - public void forceStop() throws IOException { - assert detached == false; - jvmProcess.destroyForcibly(); waitFor(); } private void sendShutdownMarker() { try { OutputStream os = jvmProcess.getOutputStream(); - os.write(BootstrapInfo.SERVER_SHUTDOWN_MARKER); + os.write(SERVER_SHUTDOWN_MARKER); os.flush(); } catch (IOException e) { - // process is already effectively dead, fall through to wait for it, or should we SIGKILL? + // process is already effectively dead, fall through to wait for it } } } diff --git a/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ErrorPumpThreadTests.java b/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ErrorPumpThreadTests.java new file mode 100644 index 0000000000000..27c81de929226 --- /dev/null +++ b/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ErrorPumpThreadTests.java @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.server.launcher; + +import org.elasticsearch.test.ESTestCase; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +/** + * Tests for {@link ErrorPumpThread}. + */ +public class ErrorPumpThreadTests extends ESTestCase { + + private PipedOutputStream processStderr; + private ByteArrayOutputStream capturedOutput; + private ErrorPumpThread pump; + + private void startPump() throws IOException { + processStderr = new PipedOutputStream(); + PipedInputStream pumpInput = new PipedInputStream(processStderr); + capturedOutput = new ByteArrayOutputStream(); + pump = new ErrorPumpThread(pumpInput, new PrintStream(capturedOutput, true, StandardCharsets.UTF_8)); + pump.start(); + } + + private void writeLine(String line) throws IOException { + processStderr.write((line + "\n").getBytes(StandardCharsets.UTF_8)); + processStderr.flush(); + } + + private void closeAndDrain() throws IOException { + processStderr.close(); + pump.drain(); + pump.close(); + } + + private String output() { + return capturedOutput.toString(StandardCharsets.UTF_8); + } + + public void testReadyMarkerSignalsReady() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + assertThat(pump.waitUntilReady(), is(true)); + closeAndDrain(); + } + + public void testReadyMarkerNotPassedToOutput() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + closeAndDrain(); + assertThat(output(), not(containsString(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)))); + } + + public void testStreamCloseWithoutMarkerSignalsNotReady() throws Exception { + startPump(); + writeLine("some startup error"); + closeAndDrain(); + assertThat(pump.waitUntilReady(), is(false)); + } + + public void testRegularMessagesPassThrough() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + writeLine("hello world"); + writeLine("another message"); + closeAndDrain(); + assertThat(output(), containsString("hello world")); + assertThat(output(), containsString("another message")); + } + + public void testFiltersIncubatorModulesWarning() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + writeLine("WARNING: Using incubator modules: jdk.incubator.vector"); + writeLine("legitimate message"); + closeAndDrain(); + assertThat(output(), not(containsString("incubator"))); + assertThat(output(), containsString("legitimate message")); + } + + public void testFiltersTimezoneDeprecationWarning() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + writeLine("WARNING: Use of the three-letter time zone ID EST is deprecated and it will be removed in a future release"); + writeLine("WARNING: Use of the three-letter time zone ID PST is deprecated and it will be removed in a future release"); + writeLine("keep this line"); + closeAndDrain(); + assertThat(output(), not(containsString("time zone"))); + assertThat(output(), containsString("keep this line")); + } + + public void testDoesNotFilterPartialMatch() throws Exception { + startPump(); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + writeLine("WARNING: Using incubator modules: jdk.incubator.vector and more stuff"); + writeLine("prefix WARNING: Using incubator modules: jdk.incubator.vector"); + closeAndDrain(); + assertThat(output(), containsString("and more stuff")); + assertThat(output(), containsString("prefix WARNING")); + } + + public void testMessagesBeforeMarkerPassThrough() throws Exception { + startPump(); + writeLine("early warning"); + writeLine(String.valueOf(ErrorPumpThread.SERVER_READY_MARKER)); + pump.waitUntilReady(); + closeAndDrain(); + assertThat(output(), containsString("early warning")); + } +} diff --git a/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ServerLauncherTests.java b/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ServerLauncherTests.java new file mode 100644 index 0000000000000..c91a81005e303 --- /dev/null +++ b/distribution/tools/server-launcher/src/test/java/org/elasticsearch/server/launcher/ServerLauncherTests.java @@ -0,0 +1,447 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.server.launcher; + +import org.elasticsearch.server.launcher.common.LaunchDescriptor; +import org.elasticsearch.server.launcher.common.ProcessUtil; +import org.elasticsearch.test.ESTestCase; +import org.junit.AfterClass; +import org.junit.Before; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +/** + * Tests for {@link ServerLauncher} and implicitly {@link ServerProcess}. + * Uses an injectable process starter so no real subprocess is spawned. + */ +public class ServerLauncherTests extends ESTestCase { + + private static final ExecutorService MOCK_EXECUTOR = Executors.newSingleThreadExecutor(); + private static final long MOCK_PID = 12345L; + + private ServerLauncher launcher; + private Path workingDir; + private Path tempDir; + private ByteArrayOutputStream capturedStderr; + private PrintStream originalErr; + private volatile ServerProcess activeServer; + + @FunctionalInterface + private interface ServerBehavior { + void run(InputStream stdin, OutputStream stderr, AtomicInteger exitCode) throws IOException; + } + + /** + * A mock Process that uses pipes for stdin/stderr and a background thread + * that reads server args, writes the ready marker, then runs a configurable behavior. + */ + private static class MockServerProcess extends Process { + private final PipedOutputStream processStdin = new PipedOutputStream(); + private final PipedInputStream processStderr = new PipedInputStream(); + private final PipedInputStream stdin = new PipedInputStream(); + private final PipedOutputStream stderr = new PipedOutputStream(); + private final AtomicInteger exitCode = new AtomicInteger(0); + private final Future serverThread; + + MockServerProcess(LaunchDescriptor descriptor, ServerBehavior behavior) throws IOException { + stdin.connect(processStdin); + stderr.connect(processStderr); + int argsLen = descriptor.serverArgsBytes().length; + this.serverThread = MOCK_EXECUTOR.submit(() -> { + try { + byte[] buf = new byte[Math.max(1, argsLen)]; + int n = 0; + while (n < argsLen) { + int r = stdin.read(buf, n, argsLen - n); + if (r <= 0) break; + n += r; + } + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println(ErrorPumpThread.SERVER_READY_MARKER); + behavior.run(stdin, stderr, exitCode); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + try { + stdin.close(); + stderr.close(); + } catch (IOException ignored) {} + } + }); + } + + @Override + public OutputStream getOutputStream() { + return processStdin; + } + + @Override + public InputStream getInputStream() { + return InputStream.nullInputStream(); + } + + @Override + public InputStream getErrorStream() { + return processStderr; + } + + @Override + public long pid() { + return MOCK_PID; + } + + @Override + public int waitFor() throws InterruptedException { + try { + serverThread.get(); + } catch (ExecutionException e) { + throw new AssertionError("Mock server thread failed", e.getCause()); + } + return exitCode.get(); + } + + @Override + public int exitValue() { + if (serverThread.isDone() == false) { + throw new IllegalThreadStateException(); + } + return exitCode.get(); + } + + @Override + public void destroy() { + throw new UnsupportedOperationException("destroy not used in tests"); + } + + @Override + public Process destroyForcibly() { + serverThread.cancel(true); + return this; + } + } + + @AfterClass + public static void shutdownExecutor() { + MOCK_EXECUTOR.shutdown(); + } + + @Before + public void setUpTestDirs() throws Exception { + launcher = new ServerLauncher<>(); + workingDir = createTempDir(); + tempDir = createTempDir(); + capturedStderr = new ByteArrayOutputStream(); + originalErr = System.err; + System.setErr(new PrintStream(capturedStderr, true, StandardCharsets.UTF_8)); + } + + @Override + public void tearDown() throws Exception { + try { + ServerProcess server = activeServer; + if (server != null) { + activeServer = null; + server.stop(); + } + } finally { + try { + System.setErr(originalErr); + } finally { + super.tearDown(); + } + } + } + + private LaunchDescriptor descriptor( + String command, + List jvmOptions, + List jvmArgs, + Map environment, + boolean daemonize, + byte[] serverArgsBytes + ) { + return new LaunchDescriptor( + command, + jvmOptions != null ? jvmOptions : List.of(), + jvmArgs != null ? jvmArgs : List.of(), + environment != null ? environment : Map.of(), + workingDir.toString(), + tempDir.toString(), + daemonize, + serverArgsBytes != null ? serverArgsBytes : new byte[0] + ); + } + + private String getCapturedStderr() { + System.err.flush(); + return capturedStderr.toString(StandardCharsets.UTF_8); + } + + private static MockServerProcess createMock(LaunchDescriptor d, ServerBehavior behavior) { + try { + return new MockServerProcess(d, behavior); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private ServerProcess startServer(LaunchDescriptor d, Function processStarter) throws Exception { + ServerProcess server = launcher.startServer(d, processStarter); + activeServer = server; + return server; + } + + public void testProcessBuilder() throws Exception { + AtomicReference capturedPb = new AtomicReference<>(); + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), false, new byte[0]); + ServerBehavior behavior = (stdin, stderr, exitCode) -> { + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("stderr message"); + } + stdin.read(); + }; + ServerProcess server = startServer(d, pb -> { + capturedPb.set(pb); + return createMock(d, behavior); + }); + assertThat(capturedPb.get().redirectInput(), equalTo(ProcessBuilder.Redirect.PIPE)); + assertThat(capturedPb.get().redirectOutput(), equalTo(ProcessBuilder.Redirect.INHERIT)); + assertThat(capturedPb.get().redirectError(), equalTo(ProcessBuilder.Redirect.PIPE)); + assertThat(capturedPb.get().directory().toString(), equalTo(workingDir.toString())); + server.stop(); + assertThat(getCapturedStderr(), containsString("stderr message")); + } + + public void testPid() throws Exception { + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), true, new byte[0]); + ServerBehavior behavior = (stdin, stderr, exitCode) -> stdin.read(); + ServerProcess server = startServer(d, pb -> createMock(d, behavior)); + assertThat(server.pid(), equalTo(MOCK_PID)); + server.stop(); + } + + public void testStartError() throws Exception { + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), false, new byte[0]); + Exception e = expectThrows(UncheckedIOException.class, () -> startServer(d, pb -> { + throw new UncheckedIOException(new IOException("something went wrong")); + })); + assertThat(e.getCause().getMessage(), equalTo("something went wrong")); + } + + public void testEnvPassthrough() throws Exception { + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of("MY_ENV", "foo"), false, new byte[0]); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].environment(), hasEntry(equalTo("MY_ENV"), equalTo("foo"))); + server.stop(); + } + + public void testLibffiEnv() throws Exception { + Path libffiDir = createTempDir(); + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = descriptor( + "/usr/bin/java", + List.of(), + List.of(), + Map.of("LIBFFI_TMPDIR", libffiDir.toString()), + false, + new byte[0] + ); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].environment(), hasKey("LIBFFI_TMPDIR")); + assertThat(Files.exists(Path.of(capturedPb[0].environment().get("LIBFFI_TMPDIR"))), is(true)); + server.stop(); + } + + public void testEnvCleared() throws Exception { + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of("SOME_VAR", "value"), false, new byte[0]); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].environment(), hasEntry(equalTo("SOME_VAR"), equalTo("value"))); + assertThat(capturedPb[0].environment(), not(hasKey("ES_TMPDIR"))); + assertThat(capturedPb[0].environment(), not(hasKey("ES_JAVA_OPTS"))); + server.stop(); + } + + public void testCommandLineSysprops() throws Exception { + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = descriptor("/usr/bin/java", List.of("-Dfoo1=bar", "-Dfoo2=baz"), List.of(), Map.of(), false, new byte[0]); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].command(), hasItems("-Dfoo1=bar", "-Dfoo2=baz")); + server.stop(); + } + + public void testCommandLine() throws Exception { + String mainClass = "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch"; + String modulePath = workingDir.resolve("lib").toString(); + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = descriptor( + workingDir.resolve("bin/java").toString(), + List.of(), + List.of("--module-path", modulePath, "-m", mainClass), + Map.of(), + false, + new byte[0] + ); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].command(), hasItems("--module-path", modulePath, "-m", mainClass)); + server.stop(); + } + + public void testDetach() throws Exception { + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), true, new byte[0]); + AtomicReference mockRef = new AtomicReference<>(); + ServerBehavior behavior = (stdin, stderr, exitCode) -> { + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("final message"); + } + assertThat(stdin.read(), equalTo(-1)); + }; + ServerProcess server = startServer(d, pb -> { + mockRef.set(createMock(d, behavior)); + return mockRef.get(); + }); + server.detach(); + assertThat(getCapturedStderr(), containsString("final message")); + server.stop(); + ProcessUtil.nonInterruptible(() -> mockRef.get().waitFor()); + } + + public void testStop() throws Exception { + CountDownLatch mainReady = new CountDownLatch(1); + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), false, new byte[0]); + ServerBehavior behavior = (stdin, stderr, exitCode) -> { + ProcessUtil.nonInterruptibleVoid(mainReady::await); + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("final message"); + } + int b = stdin.read(); + assertThat(b, equalTo((int) ServerProcess.SERVER_SHUTDOWN_MARKER)); + }; + MockServerProcess mock = createMock(d, behavior); + ServerProcess server = startServer(d, pb -> mock); + mainReady.countDown(); + server.stop(); + assertThat(mock.serverThread.isDone(), is(true)); + assertThat(getCapturedStderr(), containsString("final message")); + } + + public void testWaitFor() throws Exception { + CountDownLatch mainReady = new CountDownLatch(1); + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), false, new byte[0]); + ServerBehavior behavior = (stdin, stderr, exitCode) -> { + mainReady.countDown(); + assertThat(stdin.read(), equalTo((int) ServerProcess.SERVER_SHUTDOWN_MARKER)); + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("final message"); + } + }; + MockServerProcess mock = createMock(d, behavior); + ServerProcess server = startServer(d, pb -> mock); + CompletableFuture exitFuture = CompletableFuture.supplyAsync(() -> { + ProcessUtil.nonInterruptibleVoid(mainReady::await); + try { + server.stop(); + return 0; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + int exitCode = server.waitFor(); + assertThat(exitCode, equalTo(0)); + assertThat(mock.serverThread.isDone(), is(true)); + assertThat(getCapturedStderr(), containsString("final message")); + exitFuture.get(); + } + + public void testProcessDies() throws Exception { + CountDownLatch mainExit = new CountDownLatch(1); + LaunchDescriptor d = descriptor("/usr/bin/java", List.of(), List.of(), Map.of(), false, new byte[0]); + ServerBehavior behavior = (stdin, stderr, exitCode) -> { + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("fatal message"); + } + ProcessUtil.nonInterruptibleVoid(mainExit::await); + exitCode.set(-9); + }; + MockServerProcess mock = createMock(d, behavior); + ServerProcess server = startServer(d, pb -> mock); + mainExit.countDown(); + int exitCode = server.waitFor(); + assertThat(exitCode, equalTo(-9)); + } + + public void testLogsDirCreateParents() throws Exception { + Path testDir = createTempDir(); + Path logsDir = testDir.resolve("subdir/logs"); + ProcessBuilder[] capturedPb = new ProcessBuilder[1]; + LaunchDescriptor d = new LaunchDescriptor( + "/usr/bin/java", + List.of(), + List.of(), + Map.of(), + logsDir.toString(), + tempDir.toString(), + false, + new byte[0] + ); + ServerProcess server = startServer(d, pb -> { + capturedPb[0] = pb; + return createMock(d, (stdin, stderr, exitCode) -> stdin.read()); + }); + assertThat(capturedPb[0].directory().toString(), equalTo(logsDir.toString())); + server.stop(); + } +} diff --git a/distribution/tools/windows-service-cli/build.gradle b/distribution/tools/windows-service-cli/build.gradle index dcfaf244b7eec..2d090fc4a8af8 100644 --- a/distribution/tools/windows-service-cli/build.gradle +++ b/distribution/tools/windows-service-cli/build.gradle @@ -4,6 +4,8 @@ dependencies { compileOnly project(":server") compileOnly project(":libs:cli") compileOnly project(":distribution:tools:server-cli") + compileOnly project(":distribution:tools:server-launcher") + compileOnly project(":libs:server-launcher-common") testImplementation project(":test:framework") } diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemon.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemon.java index 2854d76c110d1..8484f222c00ea 100644 --- a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemon.java +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemon.java @@ -14,17 +14,28 @@ import org.elasticsearch.bootstrap.ServerArgs; import org.elasticsearch.cli.ProcessInfo; import org.elasticsearch.cli.Terminal; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.settings.KeyStoreWrapper; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.env.Environment; import org.elasticsearch.server.cli.JvmOptionsParser; import org.elasticsearch.server.cli.MachineDependentHeap; -import org.elasticsearch.server.cli.ServerProcess; -import org.elasticsearch.server.cli.ServerProcessBuilder; import org.elasticsearch.server.cli.ServerProcessUtils; +import org.elasticsearch.server.launcher.ErrorPumpThread; +import org.elasticsearch.server.launcher.ServerProcess; +import java.io.File; import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Starts an Elasticsearch process, but does not wait for it to exit. @@ -47,16 +58,111 @@ public void execute(Terminal terminal, OptionSet options, Environment env, Proce var args = new ServerArgs(false, true, null, loadedSecrets, env.settings(), env.configDir(), env.logsDir()); var tempDir = ServerProcessUtils.setupTempDir(processInfo); var jvmOptions = JvmOptionsParser.determineJvmOptions(args, processInfo, tempDir, new MachineDependentHeap()); - var serverProcessBuilder = new ServerProcessBuilder().withTerminal(terminal) - .withProcessInfo(processInfo) - .withServerArgs(args) - .withTempDir(tempDir) - .withJvmOptions(jvmOptions); - this.server = serverProcessBuilder.start(); + + String command = getJavaCommand(processInfo); + List jvmArgs = getJvmArgs(processInfo); + Map environment = getEnvironment(processInfo, tempDir); + byte[] serverArgsBytes = serializeServerArgs(args); + + this.server = startServer(command, jvmOptions, jvmArgs, environment, args.logsDir(), serverArgsBytes); // start does not return until the server is ready, and we do not wait for the process } } + private static ServerProcess startServer( + String command, + List jvmOptions, + List jvmArgs, + Map environment, + Path workingDir, + byte[] serverArgsBytes + ) throws Exception { + Files.createDirectories(workingDir); + + List cmd = new ArrayList<>(); + cmd.add(command); + cmd.addAll(jvmOptions); + cmd.addAll(jvmArgs); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().clear(); + pb.environment().putAll(environment); + pb.directory(new File(workingDir.toString())); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + + Process jvmProcess = null; + ErrorPumpThread errorPump; + boolean success = false; + + try { + jvmProcess = pb.start(); + errorPump = new ErrorPumpThread(jvmProcess.getErrorStream(), System.err); + errorPump.start(); + sendServerArgs(serverArgsBytes, jvmProcess.getOutputStream()); + + boolean serverOk = errorPump.waitUntilReady(); + if (serverOk == false) { + int exitCode = jvmProcess.waitFor(); + throw new RuntimeException("Elasticsearch died while starting up, exit code: " + exitCode); + } + success = true; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + if (success == false && jvmProcess != null && jvmProcess.isAlive()) { + jvmProcess.destroyForcibly(); + } + } + + return new ServerProcess(jvmProcess, errorPump); + } + + private static String getJavaCommand(ProcessInfo processInfo) { + Path javaHome = Path.of(processInfo.sysprops().get("java.home")); + return javaHome.resolve("bin").resolve("java.exe").toString(); + } + + private static List getJvmArgs(ProcessInfo processInfo) { + Path esHome = processInfo.workingDir(); + return List.of( + "--module-path", + esHome.resolve("lib").toString(), + "--add-modules=jdk.net", + "--add-modules=jdk.management.agent", + "--add-modules=ALL-MODULE-PATH", + "-m", + "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch" + ); + } + + private static Map getEnvironment(ProcessInfo processInfo, Path tempDir) { + Map envVars = new HashMap<>(processInfo.envVars()); + envVars.remove("ES_TMPDIR"); + if (envVars.containsKey("LIBFFI_TMPDIR") == false) { + envVars.put("LIBFFI_TMPDIR", tempDir.toString()); + } + envVars.remove("ES_JAVA_OPTS"); + return envVars; + } + + private static byte[] serializeServerArgs(ServerArgs args) throws IOException { + try (BytesStreamOutput out = new BytesStreamOutput()) { + args.writeTo(out); + return BytesReference.toBytes(out.bytes()); + } + } + + private static void sendServerArgs(byte[] serverArgsBytes, OutputStream processStdin) { + try { + processStdin.write(serverArgsBytes); + processStdin.flush(); + } catch (IOException ignore) { + // A failure to write here means the process has problems, and it will die anyway. + } + } + @Override public void close() throws IOException { if (server != null) { diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceInstallCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceInstallCommand.java index 75f508ad21499..af233f3e7c325 100644 --- a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceInstallCommand.java +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceInstallCommand.java @@ -64,7 +64,7 @@ protected String getAdditionalArgs(String serviceId, ProcessInfo pinfo) { addArg(args, "--StopMode", "jvm"); addQuotedArg(args, "--StartPath", quote(pinfo.workingDir().toString())); addArg(args, "++JvmOptions", "-Dcli.name=windows-service-daemon"); - addArg(args, "++JvmOptions", "-Dcli.libs=lib/tools/server-cli,lib/tools/windows-service-cli"); + addArg(args, "++JvmOptions", "-Dcli.libs=lib/tools/server-cli,lib/tools/server-launcher,lib/tools/windows-service-cli"); addArg(args, "++Environment", String.format(java.util.Locale.ROOT, "HOSTNAME=%s", pinfo.envVars().get("COMPUTERNAME"))); String serviceUsername = pinfo.envVars().get("SERVICE_USERNAME"); diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceInstallCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceInstallCommandTests.java index 4d3d6400c55c4..26e6fffc4424c 100644 --- a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceInstallCommandTests.java +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceInstallCommandTests.java @@ -111,7 +111,7 @@ public void testJvmOptions() throws Exception { options, containsInAnyOrder( "-Dcli.name=windows-service-daemon", - "-Dcli.libs=lib/tools/server-cli,lib/tools/windows-service-cli", + "-Dcli.libs=lib/tools/server-cli,lib/tools/server-launcher,lib/tools/windows-service-cli", String.join(";", expectedOptions) ) ); diff --git a/libs/server-launcher-common/build.gradle b/libs/server-launcher-common/build.gradle new file mode 100644 index 0000000000000..1d60e53bc7e55 --- /dev/null +++ b/libs/server-launcher-common/build.gradle @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +apply plugin: 'elasticsearch.build' + +// This library has zero ES dependencies - only JDK classes +dependencies { + testImplementation(project(":test:framework")) +} + +// Since this library does not depend on :server, it cannot run the jarHell task +tasks.named("jarHell").configure { enabled = false } + +tasks.named('forbiddenApisMain').configure { + replaceSignatureFiles 'jdk-signatures' +} + +["javadoc", "loggerUsageCheck"].each { tsk -> + tasks.named(tsk).configure { enabled = false } +} diff --git a/libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/LaunchDescriptor.java b/libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/LaunchDescriptor.java new file mode 100644 index 0000000000000..2abdb83a375c5 --- /dev/null +++ b/libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/LaunchDescriptor.java @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.server.launcher.common; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Holds all the information needed by the launcher to spawn the Elasticsearch server process. + *

    + * This class is serialized to a binary format by the preparer (server-cli) and deserialized + * by the launcher (server-launcher). It uses only JDK classes so that the launcher has no + * Elasticsearch dependencies. + *

    + * Subclasses (e.g. {@code ServerlessLaunchDescriptor}) can extend this class and use + * {@link #writeFieldsTo(DataOutputStream)} / {@link #readFieldsFrom(DataInputStream)} + * to serialize/deserialize base fields without the magic number prefix. + */ +public class LaunchDescriptor { + + public static final String DESCRIPTOR_FILENAME = "launch-descriptor.bin"; + + private static final int MAGIC = 0x45534C44; // "ESLD" - ElasticSearch Launch Descriptor + + private final String command; + private final List jvmOptions; + private final List jvmArgs; + private final Map environment; + private final String workingDir; + private final String tempDir; + private final boolean daemonize; + private final byte[] serverArgsBytes; + + public LaunchDescriptor( + String command, + List jvmOptions, + List jvmArgs, + Map environment, + String workingDir, + String tempDir, + boolean daemonize, + byte[] serverArgsBytes + ) { + this.command = command; + this.jvmOptions = List.copyOf(jvmOptions); + this.jvmArgs = List.copyOf(jvmArgs); + this.environment = Map.copyOf(environment); + this.workingDir = workingDir; + this.tempDir = tempDir; + this.daemonize = daemonize; + this.serverArgsBytes = serverArgsBytes.clone(); + } + + /** + * Copy constructor for use by subclasses. + */ + protected LaunchDescriptor(LaunchDescriptor other) { + this.command = other.command; + this.jvmOptions = other.jvmOptions; + this.jvmArgs = other.jvmArgs; + this.environment = other.environment; + this.workingDir = other.workingDir; + this.tempDir = other.tempDir; + this.daemonize = other.daemonize; + this.serverArgsBytes = other.serverArgsBytes; + } + + public String command() { + return command; + } + + public List jvmOptions() { + return jvmOptions; + } + + public List jvmArgs() { + return jvmArgs; + } + + public Map environment() { + return environment; + } + + public String workingDir() { + return workingDir; + } + + public String tempDir() { + return tempDir; + } + + public boolean daemonize() { + return daemonize; + } + + public byte[] serverArgsBytes() { + return serverArgsBytes; + } + + /** + * Writes this descriptor to a binary file. + */ + public void writeTo(Path path) throws IOException { + try (OutputStream fos = Files.newOutputStream(path); DataOutputStream out = new DataOutputStream(fos)) { + writeTo(out); + } + } + + /** + * Writes this descriptor to a DataOutputStream, including the magic number prefix. + */ + public void writeTo(DataOutputStream out) throws IOException { + out.writeInt(MAGIC); + writeFieldsTo(out); + out.flush(); + } + + /** + * Writes the fields of this descriptor without the magic number prefix. + * Subclasses can call this to embed base fields in their own wire format. + */ + protected void writeFieldsTo(DataOutputStream out) throws IOException { + out.writeUTF(command); + writeStringList(out, jvmOptions); + writeStringList(out, jvmArgs); + writeStringMap(out, environment); + out.writeUTF(workingDir); + out.writeUTF(tempDir); + out.writeBoolean(daemonize); + out.writeInt(serverArgsBytes.length); + out.write(serverArgsBytes); + } + + /** + * Reads a descriptor from a binary file. + */ + public static LaunchDescriptor readFrom(Path path) throws IOException { + try (InputStream fis = Files.newInputStream(path); DataInputStream in = new DataInputStream(fis)) { + return readFrom(in); + } + } + + /** + * Reads a descriptor from a DataInputStream, checking the magic number prefix. + */ + public static LaunchDescriptor readFrom(DataInputStream in) throws IOException { + checkMagic(in, MAGIC); + return readFieldsFrom(in); + } + + /** + * Reads descriptor fields without the magic number prefix. + * Subclasses can call this to read base fields from their own wire format. + */ + protected static LaunchDescriptor readFieldsFrom(DataInputStream in) throws IOException { + String command = in.readUTF(); + List jvmOptions = readStringList(in); + List jvmArgs = readStringList(in); + Map environment = readStringMap(in); + String workingDir = in.readUTF(); + String tempDir = in.readUTF(); + boolean daemonize = in.readBoolean(); + int serverArgsBytesLen = in.readInt(); + byte[] serverArgsBytes = in.readNBytes(serverArgsBytesLen); + if (serverArgsBytes.length != serverArgsBytesLen) { + throw new IOException("Truncated server args: expected " + serverArgsBytesLen + " bytes, got " + serverArgsBytes.length); + } + + return new LaunchDescriptor(command, jvmOptions, jvmArgs, environment, workingDir, tempDir, daemonize, serverArgsBytes); + } + + /** + * Checks the magic number from the stream and throws if it doesn't match. + */ + protected static void checkMagic(DataInputStream in, int expectedMagic) throws IOException { + int magic = in.readInt(); + if (magic != expectedMagic) { + throw new IOException("Invalid launch descriptor: bad magic number"); + } + } + + /** + * Returns a human-readable representation of this descriptor in a section-based text format. + * Useful for debugging with the launcher's --dump flag. + */ + public String toHumanReadable() { + StringBuilder sb = new StringBuilder(); + + appendSection(sb, "command", command); + appendSection(sb, "working_dir", workingDir); + appendSection(sb, "temp_dir", tempDir); + appendSection(sb, "daemonize", String.valueOf(daemonize)); + appendSection(sb, "server_args_bytes", "<" + serverArgsBytes.length + " bytes>"); + + sb.append("[jvm_options]\n"); + for (String opt : jvmOptions) { + sb.append(opt).append('\n'); + } + sb.append('\n'); + + sb.append("[jvm_args]\n"); + for (String arg : jvmArgs) { + sb.append(arg).append('\n'); + } + sb.append('\n'); + + sb.append("[environment]\n"); + for (Map.Entry entry : environment.entrySet()) { + sb.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + } + + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LaunchDescriptor that = (LaunchDescriptor) o; + return daemonize == that.daemonize + && Objects.equals(command, that.command) + && Objects.equals(jvmOptions, that.jvmOptions) + && Objects.equals(jvmArgs, that.jvmArgs) + && Objects.equals(environment, that.environment) + && Objects.equals(workingDir, that.workingDir) + && Objects.equals(tempDir, that.tempDir) + && Arrays.equals(serverArgsBytes, that.serverArgsBytes); + } + + @Override + public int hashCode() { + int result = Objects.hash(command, jvmOptions, jvmArgs, environment, workingDir, tempDir, daemonize); + result = 31 * result + Arrays.hashCode(serverArgsBytes); + return result; + } + + @Override + public String toString() { + return "LaunchDescriptor{command='" + command + "', workingDir='" + workingDir + "', daemonize=" + daemonize + "}"; + } + + protected static void writeNullableString(DataOutputStream out, String value) throws IOException { + out.writeBoolean(value != null); + if (value != null) { + out.writeUTF(value); + } + } + + protected static String readNullableString(DataInputStream in) throws IOException { + boolean present = in.readBoolean(); + return present ? in.readUTF() : null; + } + + private static void appendSection(StringBuilder sb, String name, String value) { + sb.append('[').append(name).append("]\n"); + sb.append(value).append("\n\n"); + } + + private static void writeStringList(DataOutputStream out, List list) throws IOException { + out.writeInt(list.size()); + for (String s : list) { + out.writeUTF(s); + } + } + + private static List readStringList(DataInputStream in) throws IOException { + int size = in.readInt(); + List list = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + list.add(in.readUTF()); + } + return list; + } + + private static void writeStringMap(DataOutputStream out, Map map) throws IOException { + out.writeInt(map.size()); + for (Map.Entry entry : map.entrySet()) { + out.writeUTF(entry.getKey()); + out.writeUTF(entry.getValue()); + } + } + + private static Map readStringMap(DataInputStream in) throws IOException { + int size = in.readInt(); + Map map = new LinkedHashMap<>(size); + for (int i = 0; i < size; i++) { + String key = in.readUTF(); + String value = in.readUTF(); + map.put(key, value); + } + return map; + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java b/libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/ProcessUtil.java similarity index 69% rename from distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java rename to libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/ProcessUtil.java index 9564bdd8aa557..0d678abefd4b0 100644 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java +++ b/libs/server-launcher-common/src/main/java/org/elasticsearch/server/launcher/common/ProcessUtil.java @@ -7,17 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -package org.elasticsearch.server.cli; +package org.elasticsearch.server.launcher.common; -class ProcessUtil { +/** + * Utility methods for dealing with interruptible operations in a no-interruption-policy context. + */ +public class ProcessUtil { - private ProcessUtil() { /* no instance*/ } + private ProcessUtil() { /* no instance */ } - interface Interruptible { + public interface Interruptible { T run() throws InterruptedException; } - interface InterruptibleVoid { + public interface InterruptibleVoid { void run() throws InterruptedException; } @@ -26,7 +29,7 @@ interface InterruptibleVoid { * * This is useful for threads which expect a no interruption policy */ - static T nonInterruptible(Interruptible interruptible) { + public static T nonInterruptible(Interruptible interruptible) { try { return interruptible.run(); } catch (InterruptedException e) { @@ -35,7 +38,7 @@ static T nonInterruptible(Interruptible interruptible) { } } - static void nonInterruptibleVoid(InterruptibleVoid interruptible) { + public static void nonInterruptibleVoid(InterruptibleVoid interruptible) { nonInterruptible(() -> { interruptible.run(); return null; diff --git a/libs/server-launcher-common/src/test/java/org/elasticsearch/server/launcher/common/LaunchDescriptorTests.java b/libs/server-launcher-common/src/test/java/org/elasticsearch/server/launcher/common/LaunchDescriptorTests.java new file mode 100644 index 0000000000000..10e7e62f30f35 --- /dev/null +++ b/libs/server-launcher-common/src/test/java/org/elasticsearch/server/launcher/common/LaunchDescriptorTests.java @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.server.launcher.common; + +import org.elasticsearch.test.ESTestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasToString; + +public class LaunchDescriptorTests extends ESTestCase { + + /** + * Round-trip via DataOutputStream/DataInputStream: write descriptor to bytes, read back, assert all fields match. + */ + public void testRoundTripViaStreams() throws IOException { + LaunchDescriptor original = descriptorWithSampleData(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos)) { + original.writeTo(out); + } + LaunchDescriptor readBack; + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + readBack = LaunchDescriptor.readFrom(in); + } + assertDescriptorEquals(original, readBack); + } + + /** + * Round-trip via Path: write descriptor to a temp file, read back, assert all fields match. + */ + public void testRoundTripViaPath() throws IOException { + LaunchDescriptor original = descriptorWithSampleData(); + Path path = createTempDir().resolve(LaunchDescriptor.DESCRIPTOR_FILENAME); + original.writeTo(path); + LaunchDescriptor readBack = LaunchDescriptor.readFrom(path); + assertDescriptorEquals(original, readBack); + } + + /** + * Minimal descriptor with empty lists, empty map, and small server args round-trips correctly. + */ + public void testRoundTripMinimalDescriptor() throws IOException { + LaunchDescriptor original = new LaunchDescriptor( + "/usr/bin/java", + List.of(), + List.of(), + Map.of(), + "/var/log/elasticsearch", + "/tmp/elasticsearch", + false, + new byte[] { 0x00, 0x01 } + ); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos)) { + original.writeTo(out); + } + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + LaunchDescriptor readBack = LaunchDescriptor.readFrom(in); + assertDescriptorEquals(original, readBack); + } + } + + /** + * Reading from a stream with wrong magic number throws IOException with a clear message. + */ + public void testInvalidMagicThrows() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(baos)) { + out.writeInt(0xDEADBEEF); + out.writeUTF("/usr/bin/java"); + } + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + IOException e = expectThrows(IOException.class, () -> LaunchDescriptor.readFrom(in)); + assertThat(e, hasToString(containsString("bad magic number"))); + } + } + + private static LaunchDescriptor descriptorWithSampleData() { + return new LaunchDescriptor( + "/opt/elasticsearch/jdk/bin/java", + List.of("-Xms1g", "-Xmx1g", "-XX:+UseG1GC"), + List.of("--module-path", "/opt/elasticsearch/lib", "-m", "org.elasticsearch.server/org.elasticsearch.bootstrap.Elasticsearch"), + Map.of("ES_PATH_CONF", "/etc/elasticsearch", "ES_JAVA_OPTS", ""), + "/var/log/elasticsearch", + "/tmp/elasticsearch-12345", + true, + new byte[] { 1, 2, 3, 4, 5 } + ); + } + + private static void assertDescriptorEquals(LaunchDescriptor expected, LaunchDescriptor actual) { + assertThat(actual.command(), equalTo(expected.command())); + assertThat(actual.jvmOptions(), equalTo(expected.jvmOptions())); + assertThat(actual.jvmArgs(), equalTo(expected.jvmArgs())); + assertThat(actual.environment(), equalTo(expected.environment())); + assertThat(actual.workingDir(), equalTo(expected.workingDir())); + assertThat(actual.tempDir(), equalTo(expected.tempDir())); + assertThat(actual.daemonize(), equalTo(expected.daemonize())); + assertTrue("serverArgsBytes mismatch", Arrays.equals(expected.serverArgsBytes(), actual.serverArgsBytes())); + } +} diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java index f444812a307fd..cf716b982fb4a 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java @@ -105,7 +105,7 @@ public void test32SpecialCharactersInJdkPath() throws Exception { mv(installation.bundledJdk, relocatedJdk); // ask for elasticsearch version to avoid starting the app final Result runResult = sh.run(bin.elasticsearch.toString() + " -V"); - assertThat(runResult.stdout(), startsWith("Version: ")); + assertThat(runResult.stderr(), startsWith("Version: ")); } finally { mv(relocatedJdk, installation.bundledJdk); } @@ -507,8 +507,8 @@ public void test74CustomJvmOptionsTotalMemoryOverride() throws Exception { final String nodesStatsResponse = makeRequest("https://localhost:9200/_nodes/stats"); assertThat(nodesStatsResponse, containsString("\"adjusted_total_in_bytes\":891289600")); final String nodesResponse = makeRequest("https://localhost:9200/_nodes"); - // 40% of (850MB - 100MB overhead) = 40% of 750MB - assertThat(nodesResponse, containsString("\"heap_init_in_bytes\":314572800")); + // 40% of 850MB + assertThat(nodesResponse, containsString("\"heap_init_in_bytes\":356515840")); stopElasticsearch(); } finally { diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/DockerTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/DockerTests.java index 7284182362ba5..901f21a4cda87 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/DockerTests.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/DockerTests.java @@ -1031,8 +1031,8 @@ public void test140CgroupOsStatsAreAvailable() throws Exception { public void test150MachineDependentHeap() throws Exception { final List xArgs = machineDependentHeapTest("1536m", List.of()); - // This is roughly 0.5 * (1536 - 100) where 100 MB is the server-cli overhead - assertThat(xArgs, hasItems("-Xms718m", "-Xmx718m")); + // This is roughly 0.5 * 1536 + assertThat(xArgs, hasItems("-Xms768m", "-Xmx768m")); } /** @@ -1043,12 +1043,12 @@ public void test150MachineDependentHeap() throws Exception { public void test151MachineDependentHeapWithSizeOverride() throws Exception { final List xArgs = machineDependentHeapTest( "942m", - // 799014912 = 762m, 52428800 = 50m - List.of("-Des.total_memory_bytes=799014912", "-Des.total_memory_overhead_bytes=52428800") + // 799014912 = 762m + List.of("-Des.total_memory_bytes=799014912") ); - // This is roughly 0.4 * (762 - 50) - assertThat(xArgs, hasItems("-Xms284m", "-Xmx284m")); + // This is roughly 0.4 * 762, in particular it's NOT 0.4 * 942 + assertThat(xArgs, hasItems("-Xms304m", "-Xmx304m")); } private List machineDependentHeapTest(final String containerMemory, final List extraJvmOptions) throws Exception { diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/KeystoreManagementTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/KeystoreManagementTests.java index a47dd0e57642e..28bd4815e2f57 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/KeystoreManagementTests.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/KeystoreManagementTests.java @@ -173,7 +173,7 @@ public void test24EncryptedKeystoreAllowsHelpMessage() throws Exception { assertPasswordProtectedKeystore(); Shell.Result r = installation.executables().elasticsearch.run("--help"); - assertThat(r.stdout(), startsWith("Starts Elasticsearch")); + assertThat(r.stderr(), startsWith("Starts Elasticsearch")); } public void test30KeystorePasswordFromFile() throws Exception { diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/PackageTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/PackageTests.java index 24e86b967ee19..65cb241a69d77 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/PackageTests.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/PackageTests.java @@ -258,8 +258,8 @@ public void test71JvmOptionsTotalMemoryOverride() throws Exception { final String nodesStatsResponse = makeRequest("https://localhost:9200/_nodes/stats"); assertThat(nodesStatsResponse, containsString("\"adjusted_total_in_bytes\":891289600")); - // 40% of (850MB - 100MB overhead) = 40% of 750MB - assertThat(sh.run("ps auwwx").stdout(), containsString("-Xms300m -Xmx300m")); + // 40% of 850MB + assertThat(sh.run("ps auwwx").stdout(), containsString("-Xms340m -Xmx340m")); stopElasticsearch(); }); diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/util/Archives.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/util/Archives.java index 69932ea34de5f..e9d4bfc1dc1e1 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/util/Archives.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/util/Archives.java @@ -194,6 +194,13 @@ private static void verifyOssInstallation(Installation es, Distribution distribu Stream.of("NOTICE.txt", "LICENSE.txt", "README.asciidoc") .forEach(doc -> assertThat(es.home.resolve(doc), file(File, owner, owner, p644))); + + // Linux distributions (x86 and aarch64) include a native server-launcher binary; ensure it exists + // so we do not silently fall back to Java when the native image build was skipped or broken + if (distribution.platform == Distribution.Platform.LINUX) { + Path nativeLauncher = es.lib.resolve("tools").resolve("server-launcher").resolve("server-launcher"); + assertThat("native server-launcher must exist on Linux distribution", nativeLauncher, file(File, owner, owner, p755)); + } } private static void verifyDefaultInstallation(Installation es, Distribution distribution, String owner) { diff --git a/settings.gradle b/settings.gradle index 329b3571c2fbb..a28a619206c6a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -93,6 +93,7 @@ List projects = [ 'distribution:tools:java-version-checker', 'distribution:tools:cli-launcher', 'distribution:tools:server-cli', + 'distribution:tools:server-launcher', 'distribution:tools:windows-service-cli', 'distribution:tools:plugin-cli', 'distribution:tools:plugin-cli:bc',