diff --git a/TESTING.asciidoc b/TESTING.asciidoc index 1e984a17f3c..5e5207b279e 100644 --- a/TESTING.asciidoc +++ b/TESTING.asciidoc @@ -512,7 +512,9 @@ into it vagrant ssh debian-9 -------------------------------------------- -Now inside the VM, to run the https://github.com/sstephenson/bats[bats] packaging tests +Now inside the VM, start the packaging tests from the terminal. There are two packaging +test projects. The old ones are written with https://github.com/sstephenson/bats[bats] +and only run on linux. To run them do -------------------------------------------- cd $PACKAGING_ARCHIVES @@ -524,18 +526,36 @@ sudo bats $BATS_TESTS/*.bats sudo bats $BATS_TESTS/20_tar_package.bats $BATS_TESTS/25_tar_plugins.bats -------------------------------------------- -To run the Java packaging tests, again inside the VM +The new packaging tests are written in Java and run on both linux and windows. On +linux (again, inside the VM) -------------------------------------------- -bash $PACKAGING_TESTS/run-tests.sh +# run the full suite +sudo bash $PACKAGING_TESTS/run-tests.sh + +# run specific test cases +sudo bash $PACKAGING_TESTS/run-tests.sh \ + org.elasticsearch.packaging.test.DefaultZipTests \ + org.elasticsearch.packaging.test.OssZipTests -------------------------------------------- -or on Windows +or on Windows, from a terminal running as Administrator -------------------------------------------- +# run the full suite powershell -File $Env:PACKAGING_TESTS/run-tests.ps1 + +# run specific test cases +powershell -File $Env:PACKAGING_TESTS/run-tests.ps1 ` + org.elasticsearch.packaging.test.DefaultZipTests ` + org.elasticsearch.packaging.test.OssZipTests -------------------------------------------- +Note that on Windows boxes when running from inside the GUI, you may have to log out and +back in to the `vagrant` user (password `vagrant`) for the environment variables that +locate the packaging tests and distributions to take effect, due to how vagrant provisions +Windows machines. + When you've made changes you want to test, keep the VM up and reload the tests and distributions inside by running (on the host) diff --git a/Vagrantfile b/Vagrantfile index 66ec60820ea..d53c80754e6 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -237,6 +237,7 @@ def linux_common(config, config.vm.provision 'markerfile', type: 'shell', inline: <<-SHELL touch /etc/is_vagrant_vm + touch /is_vagrant_vm # for consistency between linux and windows SHELL # This prevents leftovers from previous tests using the diff --git a/buildSrc/src/main/groovy/org/elasticsearch/gradle/vagrant/VagrantTestPlugin.groovy b/buildSrc/src/main/groovy/org/elasticsearch/gradle/vagrant/VagrantTestPlugin.groovy index 2e02911b7a7..6e8a5fd15ed 100644 --- a/buildSrc/src/main/groovy/org/elasticsearch/gradle/vagrant/VagrantTestPlugin.groovy +++ b/buildSrc/src/main/groovy/org/elasticsearch/gradle/vagrant/VagrantTestPlugin.groovy @@ -52,6 +52,8 @@ class VagrantTestPlugin implements Plugin { static final List DISTRIBUTIONS = unmodifiableList([ 'archives:tar', 'archives:oss-tar', + 'archives:zip', + 'archives:oss-zip', 'packages:rpm', 'packages:oss-rpm', 'packages:deb', @@ -242,13 +244,27 @@ class VagrantTestPlugin implements Plugin { Task createLinuxRunnerScript = project.tasks.create('createLinuxRunnerScript', FileContentsTask) { dependsOn copyPackagingTests file "${testsDir}/run-tests.sh" - contents "java -cp \"\$PACKAGING_TESTS/*\" org.junit.runner.JUnitCore ${-> project.extensions.esvagrant.testClass}" + contents """\ + if [ "\$#" -eq 0 ]; then + test_args=( "${-> project.extensions.esvagrant.testClass}" ) + else + test_args=( "\$@" ) + fi + java -cp "\$PACKAGING_TESTS/*" org.elasticsearch.packaging.VMTestRunner "\${test_args[@]}" + """ } Task createWindowsRunnerScript = project.tasks.create('createWindowsRunnerScript', FileContentsTask) { dependsOn copyPackagingTests file "${testsDir}/run-tests.ps1" + // the use of $args rather than param() here is deliberate because the syntax for array (multivalued) parameters is likely + // a little trappy for those unfamiliar with powershell contents """\ - java -cp "\$Env:PACKAGING_TESTS/*" org.junit.runner.JUnitCore ${-> project.extensions.esvagrant.testClass} + if (\$args.Count -eq 0) { + \$testArgs = @("${-> project.extensions.esvagrant.testClass}") + } else { + \$testArgs = \$args + } + java -cp "\$Env:PACKAGING_TESTS/*" org.elasticsearch.packaging.VMTestRunner @testArgs exit \$LASTEXITCODE """ } @@ -525,9 +541,10 @@ class VagrantTestPlugin implements Plugin { if (LINUX_BOXES.contains(box)) { javaPackagingTest.command = 'ssh' - javaPackagingTest.args = ['--command', 'bash "$PACKAGING_TESTS/run-tests.sh"'] + javaPackagingTest.args = ['--command', 'sudo bash "$PACKAGING_TESTS/run-tests.sh"'] } else { javaPackagingTest.command = 'winrm' + // winrm commands run as administrator javaPackagingTest.args = ['--command', 'powershell -File "$Env:PACKAGING_TESTS/run-tests.ps1"'] } diff --git a/qa/vagrant/build.gradle b/qa/vagrant/build.gradle index 52a6bb1efb5..a8dfe89b678 100644 --- a/qa/vagrant/build.gradle +++ b/qa/vagrant/build.gradle @@ -29,9 +29,9 @@ plugins { dependencies { compile "junit:junit:${versions.junit}" compile "org.hamcrest:hamcrest-core:${versions.hamcrest}" + compile "org.hamcrest:hamcrest-library:${versions.hamcrest}" - // needs to be on the classpath for JarHell - testRuntime project(':libs:elasticsearch-core') + compile project(':libs:elasticsearch-core') // pulls in the jar built by this project and its dependencies packagingTest project(path: project.path, configuration: 'runtime') diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/PackagingTests.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/PackagingTests.java index 0b5e7a3b6e0..fa7f8e8ef78 100644 --- a/qa/vagrant/src/main/java/org/elasticsearch/packaging/PackagingTests.java +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/PackagingTests.java @@ -19,13 +19,20 @@ package org.elasticsearch.packaging; -import org.junit.Test; +import org.elasticsearch.packaging.test.OssTarTests; +import org.elasticsearch.packaging.test.OssZipTests; +import org.elasticsearch.packaging.test.DefaultTarTests; +import org.elasticsearch.packaging.test.DefaultZipTests; -/** - * This class doesn't have any tests yet - */ -public class PackagingTests { +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; - @Test - public void testDummy() {} -} +@RunWith(Suite.class) +@SuiteClasses({ + DefaultTarTests.class, + DefaultZipTests.class, + OssTarTests.class, + OssZipTests.class +}) +public class PackagingTests {} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/VMTestRunner.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/VMTestRunner.java new file mode 100644 index 00000000000..a8fd2c27707 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/VMTestRunner.java @@ -0,0 +1,40 @@ +/* + * 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.packaging; + +import org.junit.runner.JUnitCore; + +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * Ensures that the current JVM is running on a virtual machine before delegating to {@link JUnitCore}. We just check for the existence + * of a special file that we create during VM provisioning. + */ +public class VMTestRunner { + public static void main(String[] args) { + if (Files.exists(Paths.get("/is_vagrant_vm"))) { + JUnitCore.main(args); + } else { + throw new RuntimeException("This filesystem does not have an expected marker file indicating it's a virtual machine. These " + + "tests should only run in a virtual machine because they're destructive."); + } + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/ArchiveTestCase.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/ArchiveTestCase.java new file mode 100644 index 00000000000..f683cb9c145 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/ArchiveTestCase.java @@ -0,0 +1,65 @@ +/* + * 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.packaging.test; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +import org.elasticsearch.packaging.util.Distribution; +import org.elasticsearch.packaging.util.Installation; + +import static org.elasticsearch.packaging.util.Cleanup.cleanEverything; +import static org.elasticsearch.packaging.util.Archives.installArchive; +import static org.elasticsearch.packaging.util.Archives.verifyArchiveInstallation; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assume.assumeThat; + +/** + * Tests that apply to the archive distributions (tar, zip). To add a case for a distribution, subclass and + * override {@link ArchiveTestCase#distribution()}. These tests should be the same across all archive distributions + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public abstract class ArchiveTestCase { + + private static Installation installation; + + /** The {@link Distribution} that should be tested in this case */ + protected abstract Distribution distribution(); + + @BeforeClass + public static void cleanup() { + installation = null; + cleanEverything(); + } + + @Before + public void onlyCompatibleDistributions() { + assumeThat(distribution().packaging.compatible, is(true)); + } + + @Test + public void test10Install() { + installation = installArchive(distribution()); + verifyArchiveInstallation(installation, distribution()); + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultTarTests.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultTarTests.java new file mode 100644 index 00000000000..9b359a329c1 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultTarTests.java @@ -0,0 +1,30 @@ +/* + * 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.packaging.test; + +import org.elasticsearch.packaging.util.Distribution; + +public class DefaultTarTests extends ArchiveTestCase { + + @Override + protected Distribution distribution() { + return Distribution.DEFAULT_TAR; + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultZipTests.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultZipTests.java new file mode 100644 index 00000000000..d9a6353a8c6 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/DefaultZipTests.java @@ -0,0 +1,30 @@ +/* + * 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.packaging.test; + +import org.elasticsearch.packaging.util.Distribution; + +public class DefaultZipTests extends ArchiveTestCase { + + @Override + protected Distribution distribution() { + return Distribution.DEFAULT_ZIP; + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssTarTests.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssTarTests.java new file mode 100644 index 00000000000..86637fc9d48 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssTarTests.java @@ -0,0 +1,30 @@ +/* + * 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.packaging.test; + +import org.elasticsearch.packaging.util.Distribution; + +public class OssTarTests extends ArchiveTestCase { + + @Override + protected Distribution distribution() { + return Distribution.OSS_TAR; + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssZipTests.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssZipTests.java new file mode 100644 index 00000000000..b6cd1e596a0 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/test/OssZipTests.java @@ -0,0 +1,30 @@ +/* + * 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.packaging.test; + +import org.elasticsearch.packaging.util.Distribution; + +public class OssZipTests extends ArchiveTestCase { + + @Override + protected Distribution distribution() { + return Distribution.OSS_ZIP; + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Archives.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Archives.java new file mode 100644 index 00000000000..4a00570bf30 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Archives.java @@ -0,0 +1,239 @@ +/* + * 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.packaging.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.elasticsearch.packaging.util.FileMatcher.Fileness.Directory; +import static org.elasticsearch.packaging.util.FileMatcher.Fileness.File; +import static org.elasticsearch.packaging.util.FileMatcher.file; +import static org.elasticsearch.packaging.util.FileMatcher.p644; +import static org.elasticsearch.packaging.util.FileMatcher.p660; +import static org.elasticsearch.packaging.util.FileMatcher.p755; +import static org.elasticsearch.packaging.util.FileUtils.getCurrentVersion; +import static org.elasticsearch.packaging.util.FileUtils.getDefaultArchiveInstallPath; +import static org.elasticsearch.packaging.util.FileUtils.getPackagingArchivesDir; +import static org.elasticsearch.packaging.util.FileUtils.lsGlob; + +import static org.elasticsearch.packaging.util.FileUtils.mv; +import static org.elasticsearch.packaging.util.Platforms.isDPKG; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.collection.IsEmptyCollection.empty; +import static org.hamcrest.collection.IsCollectionWithSize.hasSize; + +/** + * Installation and verification logic for archive distributions + */ +public class Archives { + + public static Installation installArchive(Distribution distribution) { + return installArchive(distribution, getDefaultArchiveInstallPath(), getCurrentVersion()); + } + + public static Installation installArchive(Distribution distribution, Path fullInstallPath, String version) { + final Shell sh = new Shell(); + + final Path distributionFile = getPackagingArchivesDir().resolve(distribution.filename(version)); + final Path baseInstallPath = fullInstallPath.getParent(); + final Path extractedPath = baseInstallPath.resolve("elasticsearch-" + version); + + assertThat("distribution file must exist", Files.exists(distributionFile), is(true)); + assertThat("elasticsearch must not already be installed", lsGlob(baseInstallPath, "elasticsearch*"), empty()); + + if (distribution.packaging == Distribution.Packaging.TAR) { + + if (Platforms.LINUX) { + sh.run("tar", "-C", baseInstallPath.toString(), "-xzpf", distributionFile.toString()); + } else { + throw new RuntimeException("Distribution " + distribution + " is not supported on windows"); + } + + } else if (distribution.packaging == Distribution.Packaging.ZIP) { + + if (Platforms.LINUX) { + sh.run("unzip", distributionFile.toString(), "-d", baseInstallPath.toString()); + } else { + sh.run("powershell.exe", "-Command", + "Add-Type -AssemblyName 'System.IO.Compression.Filesystem'; " + + "[IO.Compression.ZipFile]::ExtractToDirectory('" + distributionFile + "', '" + baseInstallPath + "')"); + } + + } else { + throw new RuntimeException("Distribution " + distribution + " is not a known archive type"); + } + + assertThat("archive was extracted", Files.exists(extractedPath), is(true)); + + mv(extractedPath, fullInstallPath); + + assertThat("extracted archive moved to install location", Files.exists(fullInstallPath)); + final List installations = lsGlob(baseInstallPath, "elasticsearch*"); + assertThat("only the intended installation exists", installations, hasSize(1)); + assertThat("only the intended installation exists", installations.get(0), is(fullInstallPath)); + + if (Platforms.LINUX) { + setupArchiveUsersLinux(fullInstallPath); + } + + return new Installation(fullInstallPath); + } + + private static void setupArchiveUsersLinux(Path installPath) { + final Shell sh = new Shell(); + + if (sh.runIgnoreExitCode("getent", "group", "elasticsearch").isSuccess() == false) { + if (isDPKG()) { + sh.run("addgroup", "--system", "elasticsearch"); + } else { + sh.run("groupadd", "-r", "elasticsearch"); + } + } + + if (sh.runIgnoreExitCode("id", "elasticsearch").isSuccess() == false) { + if (isDPKG()) { + sh.run("adduser", + "--quiet", + "--system", + "--no-create-home", + "--ingroup", "elasticsearch", + "--disabled-password", + "--shell", "/bin/false", + "elasticsearch"); + } else { + sh.run("useradd", + "--system", + "-M", + "--gid", "elasticsearch", + "--shell", "/sbin/nologin", + "--comment", "elasticsearch user", + "elasticsearch"); + } + } + sh.run("chown", "-R", "elasticsearch:elasticsearch", installPath.toString()); + } + + public static void verifyArchiveInstallation(Installation installation, Distribution distribution) { + // on Windows for now we leave the installation owned by the vagrant user that the tests run as. Since the vagrant account + // is a local administrator, the files really end up being owned by the local administrators group. In the future we'll + // install and run elasticesearch with a role user on Windows + final String owner = Platforms.WINDOWS + ? "BUILTIN\\Administrators" + : "elasticsearch"; + + verifyOssInstallation(installation, distribution, owner); + if (distribution.flavor == Distribution.Flavor.DEFAULT) { + verifyDefaultInstallation(installation, distribution, owner); + } + } + + private static void verifyOssInstallation(Installation es, Distribution distribution, String owner) { + Stream.of( + es.home, + es.config, + es.plugins, + es.modules, + es.logs + ).forEach(dir -> assertThat(dir, file(Directory, owner, owner, p755))); + + assertThat(Files.exists(es.data), is(false)); + assertThat(Files.exists(es.scripts), is(false)); + + assertThat(es.home.resolve("bin"), file(Directory, owner, owner, p755)); + assertThat(es.home.resolve("lib"), file(Directory, owner, owner, p755)); + assertThat(Files.exists(es.config.resolve("elasticsearch.keystore")), is(false)); + + Stream.of( + "bin/elasticsearch", + "bin/elasticsearch-env", + "bin/elasticsearch-keystore", + "bin/elasticsearch-plugin", + "bin/elasticsearch-translog" + ).forEach(executable -> { + + assertThat(es.home.resolve(executable), file(File, owner, owner, p755)); + + if (distribution.packaging == Distribution.Packaging.ZIP) { + assertThat(es.home.resolve(executable + ".bat"), file(File, owner)); + } + }); + + if (distribution.packaging == Distribution.Packaging.ZIP) { + Stream.of( + "bin/elasticsearch-service.bat", + "bin/elasticsearch-service-mgr.exe", + "bin/elasticsearch-service-x64.exe" + ).forEach(executable -> assertThat(es.home.resolve(executable), file(File, owner))); + } + + Stream.of( + "elasticsearch.yml", + "jvm.options", + "log4j2.properties" + ).forEach(config -> assertThat(es.config.resolve(config), file(File, owner, owner, p660))); + + Stream.of( + "NOTICE.txt", + "LICENSE.txt", + "README.textile" + ).forEach(doc -> assertThat(es.home.resolve(doc), file(File, owner, owner, p644))); + } + + private static void verifyDefaultInstallation(Installation es, Distribution distribution, String owner) { + + Stream.of( + "bin/elasticsearch-certgen", + "bin/elasticsearch-certutil", + "bin/elasticsearch-croneval", + "bin/elasticsearch-migrate", + "bin/elasticsearch-saml-metadata", + "bin/elasticsearch-setup-passwords", + "bin/elasticsearch-sql-cli", + "bin/elasticsearch-syskeygen", + "bin/elasticsearch-users", + "bin/x-pack-env", + "bin/x-pack-security-env", + "bin/x-pack-watcher-env" + ).forEach(executable -> { + + assertThat(es.home.resolve(executable), file(File, owner, owner, p755)); + + if (distribution.packaging == Distribution.Packaging.ZIP) { + assertThat(es.home.resolve(executable + ".bat"), file(File, owner)); + } + }); + + // at this time we only install the current version of archive distributions, but if that changes we'll need to pass + // the version through here + assertThat(es.home.resolve("bin/elasticsearch-sql-cli-" + getCurrentVersion() + ".jar"), file(File, owner, owner, p755)); + + Stream.of( + "users", + "users_roles", + "roles.yml", + "role_mapping.yml", + "log4j2.properties" + ).forEach(config -> assertThat(es.config.resolve(config), file(File, owner, owner, p660))); + } + +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Cleanup.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Cleanup.java new file mode 100644 index 00000000000..9e9150c9c18 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Cleanup.java @@ -0,0 +1,121 @@ +/* + * 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.packaging.util; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.elasticsearch.packaging.util.FileUtils.getTempDir; +import static org.elasticsearch.packaging.util.FileUtils.lsGlob; +import static org.elasticsearch.packaging.util.Platforms.isAptGet; +import static org.elasticsearch.packaging.util.Platforms.isDPKG; +import static org.elasticsearch.packaging.util.Platforms.isRPM; +import static org.elasticsearch.packaging.util.Platforms.isSystemd; +import static org.elasticsearch.packaging.util.Platforms.isYUM; + +public class Cleanup { + + private static final List ELASTICSEARCH_FILES_LINUX = Arrays.asList( + "/usr/share/elasticsearch", + "/etc/elasticsearch", + "/var/lib/elasticsearch", + "/var/log/elasticsearch", + "/etc/default/elasticsearch", + "/etc/sysconfig/elasticsearch", + "/var/run/elasticsearch", + "/usr/share/doc/elasticsearch", + "/usr/lib/systemd/system/elasticsearch.conf", + "/usr/lib/tmpfiles.d/elasticsearch.conf", + "/usr/lib/sysctl.d/elasticsearch.conf" + ); + + // todo + private static final List ELASTICSEARCH_FILES_WINDOWS = Collections.emptyList(); + + public static void cleanEverything() { + final Shell sh = new Shell(); + + // kill elasticsearch processes + if (Platforms.WINDOWS) { + + // the view of processes returned by Get-Process doesn't expose command line arguments, so we use WMI here + sh.runIgnoreExitCode("powershell.exe", "-Command", + "Get-WmiObject Win32_Process | " + + "Where-Object { $_.CommandLine -Match 'org.elasticsearch.bootstrap.Elasticsearch' } | " + + "ForEach-Object { $_.Terminate() }"); + + } else { + + sh.runIgnoreExitCode("pkill", "-u", "elasticsearch"); + sh.runIgnoreExitCode("bash", "-c", + "ps aux | grep -i 'org.elasticsearch.bootstrap.Elasticsearch' | awk {'print $2'} | xargs kill -9"); + + } + + if (Platforms.LINUX) { + purgePackagesLinux(); + } + + // remove elasticsearch users + if (Platforms.LINUX) { + sh.runIgnoreExitCode("userdel", "elasticsearch"); + sh.runIgnoreExitCode("groupdel", "elasticsearch"); + } + + // delete files that may still exist + lsGlob(getTempDir(), "elasticsearch*").forEach(FileUtils::rm); + final List filesToDelete = Platforms.WINDOWS + ? ELASTICSEARCH_FILES_WINDOWS + : ELASTICSEARCH_FILES_LINUX; + filesToDelete.stream() + .map(Paths::get) + .filter(Files::exists) + .forEach(FileUtils::rm); + + // disable elasticsearch service + // todo add this for windows when adding tests for service intallation + if (Platforms.LINUX && isSystemd()) { + sh.run("systemctl", "unmask", "systemd-sysctl.service"); + } + } + + private static void purgePackagesLinux() { + final Shell sh = new Shell(); + + if (isRPM()) { + sh.runIgnoreExitCode("rpm", "--quiet", "-e", "elasticsearch", "elasticsearch-oss"); + } + + if (isYUM()) { + sh.runIgnoreExitCode("yum", "remove", "-y", "elasticsearch", "elasticsearch-oss"); + } + + if (isDPKG()) { + sh.runIgnoreExitCode("dpkg", "--purge", "elasticsearch", "elasticsearch-oss"); + } + + if (isAptGet()) { + sh.runIgnoreExitCode("apt-get", "--quiet", "--yes", "purge", "elasticsearch", "elasticsearch-oss"); + } + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Distribution.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Distribution.java new file mode 100644 index 00000000000..4f0c8751ca4 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Distribution.java @@ -0,0 +1,76 @@ +/* + * 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.packaging.util; + +public enum Distribution { + + OSS_TAR(Packaging.TAR, Flavor.OSS), + OSS_ZIP(Packaging.ZIP, Flavor.OSS), + OSS_DEB(Packaging.DEB, Flavor.OSS), + OSS_RPM(Packaging.RPM, Flavor.OSS), + + DEFAULT_TAR(Packaging.TAR, Flavor.DEFAULT), + DEFAULT_ZIP(Packaging.ZIP, Flavor.DEFAULT), + DEFAULT_DEB(Packaging.DEB, Flavor.DEFAULT), + DEFAULT_RPM(Packaging.RPM, Flavor.DEFAULT); + + public final Packaging packaging; + public final Flavor flavor; + + Distribution(Packaging packaging, Flavor flavor) { + this.packaging = packaging; + this.flavor = flavor; + } + + public String filename(String version) { + return flavor.name + "-" + version + packaging.extension; + } + + public enum Packaging { + + TAR(".tar.gz", Platforms.LINUX), + ZIP(".zip", true), + DEB(".deb", Platforms.isDPKG()), + RPM(".rpm", Platforms.isRPM()); + + /** The extension of this distribution's file */ + public final String extension; + + /** Whether the distribution is intended for use on the platform the current JVM is running on */ + public final boolean compatible; + + Packaging(String extension, boolean compatible) { + this.extension = extension; + this.compatible = compatible; + } + } + + public enum Flavor { + + OSS("elasticsearch-oss"), + DEFAULT("elasticsearch"); + + public final String name; + + Flavor(String name) { + this.name = name; + } + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileMatcher.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileMatcher.java new file mode 100644 index 00000000000..9fdf6d60081 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileMatcher.java @@ -0,0 +1,137 @@ +/* + * 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.packaging.util; + +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Objects; +import java.util.Set; + +import static org.elasticsearch.packaging.util.FileUtils.getBasicFileAttributes; +import static org.elasticsearch.packaging.util.FileUtils.getFileOwner; +import static org.elasticsearch.packaging.util.FileUtils.getPosixFileAttributes; + +import static java.nio.file.attribute.PosixFilePermissions.fromString; + +/** + * Asserts that a file at a path matches its status as Directory/File, and its owner. If on a posix system, also matches the permission + * set is what we expect. + * + * This class saves information about its failed matches in instance variables and so instances should not be reused + */ +public class FileMatcher extends TypeSafeMatcher { + + public enum Fileness { File, Directory } + + public static final Set p755 = fromString("rwxr-xr-x"); + public static final Set p660 = fromString("rw-rw----"); + public static final Set p644 = fromString("rw-r--r--"); + + private final Fileness fileness; + private final String owner; + private final String group; + private final Set posixPermissions; + + private String mismatch; + + public FileMatcher(Fileness fileness, String owner, String group, Set posixPermissions) { + this.fileness = Objects.requireNonNull(fileness); + this.owner = Objects.requireNonNull(owner); + this.group = group; + this.posixPermissions = posixPermissions; + } + + @Override + protected boolean matchesSafely(Path path) { + if (Files.exists(path) == false) { + mismatch = "Does not exist"; + return false; + } + + if (Platforms.WINDOWS) { + final BasicFileAttributes attributes = getBasicFileAttributes(path); + final String attributeViewOwner = getFileOwner(path); + + if (fileness.equals(Fileness.Directory) != attributes.isDirectory()) { + mismatch = "Is " + (attributes.isDirectory() ? "a directory" : "a file"); + return false; + } + + if (attributeViewOwner.contains(owner) == false) { + mismatch = "Owned by " + attributeViewOwner; + return false; + } + } else { + final PosixFileAttributes attributes = getPosixFileAttributes(path); + + if (fileness.equals(Fileness.Directory) != attributes.isDirectory()) { + mismatch = "Is " + (attributes.isDirectory() ? "a directory" : "a file"); + return false; + } + + if (owner.equals(attributes.owner().getName()) == false) { + mismatch = "Owned by " + attributes.owner().getName(); + return false; + } + + if (group != null && group.equals(attributes.group().getName()) == false) { + mismatch = "Owned by group " + attributes.group().getName(); + return false; + } + + if (posixPermissions != null && posixPermissions.equals(attributes.permissions()) == false) { + mismatch = "Has permissions " + attributes.permissions(); + return false; + } + } + + return true; + } + + @Override + public void describeMismatchSafely(Path path, Description description) { + description.appendText("path ").appendValue(path); + if (mismatch != null) { + description.appendText(mismatch); + } + } + + @Override + public void describeTo(Description description) { + description.appendValue("file/directory: ").appendValue(fileness) + .appendText(" with owner ").appendValue(owner) + .appendText(" with group ").appendValue(group) + .appendText(" with posix permissions ").appendValueList("[", ",", "]", posixPermissions); + } + + public static FileMatcher file(Fileness fileness, String owner) { + return file(fileness, owner, null, null); + } + + public static FileMatcher file(Fileness fileness, String owner, String group, Set permissions) { + return new FileMatcher(fileness, owner, group, permissions); + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileUtils.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileUtils.java new file mode 100644 index 00000000000..ad826675244 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/FileUtils.java @@ -0,0 +1,134 @@ +/* + * 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.packaging.util; + +import org.elasticsearch.core.internal.io.IOUtils; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileOwnerAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsNot.not; +import static org.hamcrest.text.IsEmptyString.isEmptyOrNullString; + +/** + * Wrappers and convenience methods for common filesystem operations + */ +public class FileUtils { + + public static List lsGlob(Path directory, String glob) { + List paths = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, glob)) { + + for (Path path : stream) { + paths.add(path); + } + return paths; + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void rm(Path... paths) { + try { + IOUtils.rm(paths); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static Path mv(Path source, Path target) { + try { + return Files.move(source, target); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static String slurp(Path file) { + try { + return String.join("\n", Files.readAllLines(file)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Gets the owner of a file in a way that should be supported by all filesystems that have a concept of file owner + */ + public static String getFileOwner(Path path) { + try { + FileOwnerAttributeView view = Files.getFileAttributeView(path, FileOwnerAttributeView.class); + return view.getOwner().getName(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Gets attributes that are supported by all filesystems + */ + public static BasicFileAttributes getBasicFileAttributes(Path path) { + try { + return Files.readAttributes(path, BasicFileAttributes.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Gets attributes that are supported by posix filesystems + */ + public static PosixFileAttributes getPosixFileAttributes(Path path) { + try { + return Files.readAttributes(path, PosixFileAttributes.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + // vagrant creates /tmp for us in windows so we use that to avoid long paths + public static Path getTempDir() { + return Paths.get("/tmp"); + } + + public static Path getDefaultArchiveInstallPath() { + return getTempDir().resolve("elasticsearch"); + } + + public static String getCurrentVersion() { + return slurp(getPackagingArchivesDir().resolve("version")); + } + + public static Path getPackagingArchivesDir() { + String fromEnv = System.getenv("PACKAGING_ARCHIVES"); + assertThat(fromEnv, not(isEmptyOrNullString())); + return Paths.get(fromEnv); + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Installation.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Installation.java new file mode 100644 index 00000000000..d231762d062 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Installation.java @@ -0,0 +1,58 @@ +/* + * 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.packaging.util; + +import java.nio.file.Path; + +/** + * Represents an installation of Elasticsearch + */ +public class Installation { + + public final Path home; + public final Path config; + public final Path data; + public final Path logs; + public final Path plugins; + public final Path modules; + public final Path scripts; + + public Installation(Path home, Path config, Path data, Path logs, Path plugins, Path modules, Path scripts) { + this.home = home; + this.config = config; + this.data = data; + this.logs = logs; + this.plugins = plugins; + this.modules = modules; + this.scripts = scripts; + } + + public Installation(Path home) { + this( + home, + home.resolve("config"), + home.resolve("data"), + home.resolve("logs"), + home.resolve("plugins"), + home.resolve("modules"), + home.resolve("scripts") + ); + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Platforms.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Platforms.java new file mode 100644 index 00000000000..230af8efc2d --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Platforms.java @@ -0,0 +1,68 @@ +/* + * 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.packaging.util; + +public class Platforms { + public static final String OS_NAME = System.getProperty("os.name"); + public static final boolean LINUX = OS_NAME.startsWith("Linux"); + public static final boolean WINDOWS = OS_NAME.startsWith("Windows"); + + public static boolean isDPKG() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "dpkg").isSuccess(); + } + + public static boolean isAptGet() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "apt-get").isSuccess(); + } + + public static boolean isRPM() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "rpm").isSuccess(); + } + + public static boolean isYUM() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "yum").isSuccess(); + } + + public static boolean isSystemd() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "systemctl").isSuccess(); + } + + public static boolean isSysVInit() { + if (WINDOWS) { + return false; + } + return new Shell().runIgnoreExitCode("which", "service").isSuccess(); + } +} diff --git a/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Shell.java b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Shell.java new file mode 100644 index 00000000000..3adc0b62e04 --- /dev/null +++ b/qa/vagrant/src/main/java/org/elasticsearch/packaging/util/Shell.java @@ -0,0 +1,193 @@ +/* + * 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.packaging.util; + +import org.elasticsearch.common.SuppressForbidden; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static java.util.Collections.emptyMap; + +/** + * Wrapper to run shell commands and collect their outputs in a less verbose way + */ +public class Shell { + + final Map env; + final Path workingDirectory; + + public Shell() { + this(emptyMap(), null); + } + + public Shell(Map env) { + this(env, null); + } + + public Shell(Path workingDirectory) { + this(emptyMap(), workingDirectory); + } + + public Shell(Map env, Path workingDirectory) { + this.env = new HashMap<>(env); + this.workingDirectory = workingDirectory; + } + + public Result run(String... command) { + Result result = runIgnoreExitCode(command); + if (result.isSuccess() == false) { + throw new RuntimeException("Command was not successful: [" + String.join(" ", command) + "] result: " + result.toString()); + } + return result; + } + + public Result runIgnoreExitCode(String... command) { + ProcessBuilder builder = new ProcessBuilder(); + builder.command(command); + + if (workingDirectory != null) { + setWorkingDirectory(builder, workingDirectory); + } + + if (env != null && env.isEmpty() == false) { + for (Map.Entry entry : env.entrySet()) { + builder.environment().put(entry.getKey(), entry.getValue()); + } + } + + try { + + Process process = builder.start(); + + StringBuilder stdout = new StringBuilder(); + StringBuilder stderr = new StringBuilder(); + + Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), stdout)); + Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), stderr)); + + stdoutThread.start(); + stderrThread.start(); + + stdoutThread.join(); + stderrThread.join(); + + int exitCode = process.waitFor(); + + return new Result(exitCode, stdout.toString(), stderr.toString()); + + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + @SuppressForbidden(reason = "ProcessBuilder expects java.io.File") + private static void setWorkingDirectory(ProcessBuilder builder, Path path) { + builder.directory(path.toFile()); + } + + public String toString() { + return new StringBuilder() + .append("<") + .append(this.getClass().getName()) + .append(" ") + .append("env = [") + .append(env) + .append("]") + .append("workingDirectory = [") + .append(workingDirectory) + .append("]") + .append(">") + .toString(); + } + + public static class Result { + public final int exitCode; + public final String stdout; + public final String stderr; + + public Result(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + + public boolean isSuccess() { + return exitCode == 0; + } + + public String toString() { + return new StringBuilder() + .append("<") + .append(this.getClass().getName()) + .append(" ") + .append("exitCode = [") + .append(exitCode) + .append("]") + .append(" ") + .append("stdout = [") + .append(stdout) + .append("]") + .append(" ") + .append("stderr = [") + .append(stderr) + .append("]") + .append(">") + .toString(); + } + } + + private static class StreamCollector implements Runnable { + private final InputStream input; + private final Appendable appendable; + + StreamCollector(InputStream input, Appendable appendable) { + this.input = Objects.requireNonNull(input); + this.appendable = Objects.requireNonNull(appendable); + } + + public void run() { + try { + + BufferedReader reader = new BufferedReader(reader(input)); + String line; + + while ((line = reader.readLine()) != null) { + appendable.append(line); + appendable.append("\n"); + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @SuppressForbidden(reason = "the system's default character set is a best guess of what subprocesses will use") + private static InputStreamReader reader(InputStream inputStream) { + return new InputStreamReader(inputStream); + } + } +}