diff --git a/core/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java b/core/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java index 2366f68847d..d573e968ff3 100644 --- a/core/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java +++ b/core/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java @@ -67,6 +67,7 @@ final class Bootstrap { private volatile Node node; private final CountDownLatch keepAliveLatch = new CountDownLatch(1); private final Thread keepAliveThread; + private final Spawner spawner = new Spawner(); /** creates a new instance */ Bootstrap() { @@ -155,6 +156,23 @@ final class Bootstrap { private void setup(boolean addShutdownHook, Environment environment) throws BootstrapException { Settings settings = environment.settings(); + + try { + spawner.spawnNativePluginControllers(environment); + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + try { + spawner.close(); + } catch (IOException e) { + throw new ElasticsearchException("Failed to destroy spawned controllers", e); + } + } + }); + } catch (IOException e) { + throw new BootstrapException(e); + } + initializeNatives( environment.tmpFile(), BootstrapSettings.MEMORY_LOCK_SETTING.get(settings), diff --git a/core/src/main/java/org/elasticsearch/bootstrap/Spawner.java b/core/src/main/java/org/elasticsearch/bootstrap/Spawner.java new file mode 100644 index 00000000000..a518f32bb40 --- /dev/null +++ b/core/src/main/java/org/elasticsearch/bootstrap/Spawner.java @@ -0,0 +1,128 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.bootstrap; + +import org.apache.lucene.util.Constants; +import org.apache.lucene.util.IOUtils; +import org.elasticsearch.env.Environment; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Spawns native plugin controller processes if present. Will only work prior to seccomp being set up. + */ +final class Spawner implements Closeable { + + private static final String PROGRAM_NAME = Constants.WINDOWS ? "controller.exe" : "controller"; + private static final String PLATFORM_NAME = makePlatformName(Constants.OS_NAME, Constants.OS_ARCH); + private static final String TMP_ENVVAR = "TMPDIR"; + + /** + * References to the processes that have been spawned, so that we can destroy them. + */ + private final List processes = new ArrayList<>(); + + @Override + public void close() throws IOException { + try { + IOUtils.close(() -> processes.stream().map(s -> (Closeable)s::destroy).iterator()); + } finally { + processes.clear(); + } + } + + /** + * For each plugin, attempt to spawn the controller daemon. Silently ignore any plugins + * that don't include a controller for the correct platform. + */ + void spawnNativePluginControllers(Environment environment) throws IOException { + if (Files.exists(environment.pluginsFile())) { + try (DirectoryStream stream = Files.newDirectoryStream(environment.pluginsFile())) { + for (Path plugin : stream) { + Path spawnPath = makeSpawnPath(plugin); + if (Files.isRegularFile(spawnPath)) { + spawnNativePluginController(spawnPath, environment.tmpFile()); + } + } + } + } + } + + /** + * Attempt to spawn the controller daemon for a given plugin. The spawned process + * will remain connected to this JVM via its stdin, stdout and stderr, but the + * references to these streams are not available to code outside this package. + */ + private void spawnNativePluginController(Path spawnPath, Path tmpPath) throws IOException { + ProcessBuilder pb = new ProcessBuilder(spawnPath.toString()); + + // The only environment variable passes on the path to the temporary directory + pb.environment().clear(); + pb.environment().put(TMP_ENVVAR, tmpPath.toString()); + + // The output stream of the Process object corresponds to the daemon's stdin + processes.add(pb.start()); + } + + List getProcesses() { + return Collections.unmodifiableList(processes); + } + + /** + * Make the full path to the program to be spawned. + */ + static Path makeSpawnPath(Path plugin) { + return plugin.resolve("platform").resolve(PLATFORM_NAME).resolve("bin").resolve(PROGRAM_NAME); + } + + /** + * Make the platform name in the format used in Kibana downloads, for example: + * - darwin-x86_64 + * - linux-x86-64 + * - windows-x86_64 + * For *nix platforms this is more-or-less `uname -s`-`uname -m` converted to lower case. + * However, for consistency between different operating systems on the same architecture + * "amd64" is replaced with "x86_64" and "i386" with "x86". + * For Windows it's "windows-" followed by either "x86" or "x86_64". + */ + static String makePlatformName(String osName, String osArch) { + String os = osName.toLowerCase(Locale.ROOT); + if (os.startsWith("windows")) { + os = "windows"; + } else if (os.equals("mac os x")) { + os = "darwin"; + } + String cpu = osArch.toLowerCase(Locale.ROOT); + if (cpu.equals("amd64")) { + cpu = "x86_64"; + } else if (cpu.equals("i386")) { + cpu = "x86"; + } + return os + "-" + cpu; + } +} diff --git a/core/src/test/java/org/elasticsearch/bootstrap/SpawnerTests.java b/core/src/test/java/org/elasticsearch/bootstrap/SpawnerTests.java new file mode 100644 index 00000000000..680bd4f5562 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/bootstrap/SpawnerTests.java @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.bootstrap; + +import org.apache.lucene.util.Constants; +import org.elasticsearch.test.ESTestCase; + +import java.util.Locale; + +/** + * Doesn't actually test spawning a process, as seccomp is installed before tests run and forbids it. + */ +public class SpawnerTests extends ESTestCase { + + public void testMakePlatformName() { + String platformName = Spawner.makePlatformName(Constants.OS_NAME, Constants.OS_ARCH); + + assertFalse(platformName, platformName.isEmpty()); + assertTrue(platformName, platformName.equals(platformName.toLowerCase(Locale.ROOT))); + assertTrue(platformName, platformName.indexOf("-") > 0); + assertTrue(platformName, platformName.indexOf("-") < platformName.length() - 1); + assertFalse(platformName, platformName.contains(" ")); + } + + public void testMakeSpecificPlatformNames() { + assertEquals("darwin-x86_64", Spawner.makePlatformName("Mac OS X", "x86_64")); + assertEquals("linux-x86_64", Spawner.makePlatformName("Linux", "amd64")); + assertEquals("linux-x86", Spawner.makePlatformName("Linux", "i386")); + assertEquals("windows-x86_64", Spawner.makePlatformName("Windows Server 2008 R2", "amd64")); + assertEquals("windows-x86", Spawner.makePlatformName("Windows Server 2008", "x86")); + assertEquals("windows-x86_64", Spawner.makePlatformName("Windows 8.1", "amd64")); + assertEquals("sunos-x86_64", Spawner.makePlatformName("SunOS", "amd64")); + } +} diff --git a/qa/no-bootstrap-tests/build.gradle b/qa/no-bootstrap-tests/build.gradle new file mode 100644 index 00000000000..16ac5e27693 --- /dev/null +++ b/qa/no-bootstrap-tests/build.gradle @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * Tests that need to run without the Elasticsearch bootstrap, for + * example to test process spawning. + */ + +apply plugin: 'elasticsearch.standalone-test' + diff --git a/qa/no-bootstrap-tests/src/test/java/org/elasticsearch/bootstrap/SpawnerNoBootstrapTests.java b/qa/no-bootstrap-tests/src/test/java/org/elasticsearch/bootstrap/SpawnerNoBootstrapTests.java new file mode 100644 index 00000000000..5e08696ebf3 --- /dev/null +++ b/qa/no-bootstrap-tests/src/test/java/org/elasticsearch/bootstrap/SpawnerNoBootstrapTests.java @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.bootstrap; + +import org.apache.lucene.util.Constants; +import org.apache.lucene.util.LuceneTestCase; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Create a simple "daemon controller", put it in the right place and check that it runs. + * + * Extends LuceneTestCase rather than ESTestCase as ESTestCase installs seccomp, and that + * prevents the Spawner class doing its job. Also needs to run in a separate JVM to other + * tests that extend ESTestCase for the same reason. + */ +public class SpawnerNoBootstrapTests extends LuceneTestCase { + + private static final String CONTROLLER_SOURCE = "#!/bin/bash\n" + + "\n" + + "echo I am alive\n" + + "\n" + + "read SOMETHING\n"; + + public void testControllerSpawn() throws IOException, InterruptedException { + // On Windows you cannot directly run a batch file - you have to run cmd.exe with the batch file + // as an argument and that's out of the remit of the controller daemon process spawner. If + // you need to build on Windows, just don't run this test. The process spawner itself will work + // with native processes. + assumeFalse("This test does not work on Windows", Constants.WINDOWS); + + Path esHome = createTempDir().resolve("esHome"); + Settings.Builder settingsBuilder = Settings.builder(); + settingsBuilder.put(Environment.PATH_HOME_SETTING.getKey(), esHome.toString()); + Settings settings = settingsBuilder.build(); + + Environment environment = new Environment(settings); + + // This plugin WILL have a controller daemon + Path plugin = environment.pluginsFile().resolve("test_plugin"); + Files.createDirectories(plugin); + Path controllerProgram = Spawner.makeSpawnPath(plugin); + createControllerProgram(controllerProgram); + + // This plugin will NOT have a controller daemon + Path otherPlugin = environment.pluginsFile().resolve("other_plugin"); + Files.createDirectories(otherPlugin); + + Spawner spawner = new Spawner(); + spawner.spawnNativePluginControllers(environment); + + List processes = spawner.getProcesses(); + // 1 because there should only be a reference in the list for the plugin that had the controller daemon, not the other plugin + assertEquals(1, processes.size()); + Process process = processes.get(0); + try (BufferedReader stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line = stdoutReader.readLine(); + assertEquals("I am alive", line); + spawner.close(); + // Fail if the process doesn't die within 1 second - usually it will be even quicker but it depends on OS scheduling + assertTrue(process.waitFor(1, TimeUnit.SECONDS)); + } + } + + private void createControllerProgram(Path outputFile) throws IOException { + Path outputDir = outputFile.getParent(); + Files.createDirectories(outputDir); + Files.write(outputFile, CONTROLLER_SOURCE.getBytes(StandardCharsets.UTF_8)); + Set perms = new HashSet<>(); + perms.add(PosixFilePermission.OWNER_READ); + perms.add(PosixFilePermission.OWNER_WRITE); + perms.add(PosixFilePermission.OWNER_EXECUTE); + perms.add(PosixFilePermission.GROUP_READ); + perms.add(PosixFilePermission.GROUP_EXECUTE); + perms.add(PosixFilePermission.OTHERS_READ); + perms.add(PosixFilePermission.OTHERS_EXECUTE); + Files.setPosixFilePermissions(outputFile, perms); + } +} diff --git a/settings.gradle b/settings.gradle index ad3d1d32211..3aa6c1cb9c9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -56,6 +56,7 @@ List projects = [ 'plugins:store-smb', 'qa:backwards-5.0', 'qa:evil-tests', + 'qa:no-bootstrap-tests', 'qa:rolling-upgrade', 'qa:smoke-test-client', 'qa:smoke-test-http',