diff --git a/distribution/build.gradle b/distribution/build.gradle index e3ef668fb0922..158e7c70091a7 100644 --- a/distribution/build.gradle +++ b/distribution/build.gradle @@ -230,7 +230,7 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { * Properties to expand when copying packaging files * *****************************************************************************/ configurations { - ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole'].each { + ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsWindowsServiceCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole'].each { create(it) { canBeConsumed = false canBeResolved = true @@ -253,6 +253,7 @@ 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') + libsWindowsServiceCli project(':distribution:tools:windows-service-cli') libsAnsiConsole project(':distribution:tools:ansi-console') libsPluginCli project(':distribution:tools:plugin-cli') libsKeystoreCli project(path: ':distribution:tools:keystore-cli') @@ -278,6 +279,9 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { into('tools/server-cli') { from(configurations.libsServerCli) } + into('tools/windows-service-cli') { + from(configurations.libsWindowsServiceCli) + } into('tools/geoip-cli') { from(configurations.libsGeoIpCli) } @@ -295,7 +299,6 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) { } } - modulesFiles = { platform -> copySpec { eachFile { diff --git a/distribution/packages/src/common/systemd/elasticsearch.service b/distribution/packages/src/common/systemd/elasticsearch.service index 9af491822c81c..4da74970ab42f 100644 --- a/distribution/packages/src/common/systemd/elasticsearch.service +++ b/distribution/packages/src/common/systemd/elasticsearch.service @@ -6,6 +6,10 @@ After=network-online.target [Service] Type=notify +# the elasticsearch process currently sends the notifications back to systemd +# and for some reason exec does not work (even though it is a child). We should change +# this notify access back to main (the default), see https://github.com/elastic/elasticsearch/issues/86475 +NotifyAccess=all RuntimeDirectory=elasticsearch PrivateTmp=true Environment=ES_HOME=/usr/share/elasticsearch diff --git a/distribution/src/bin/elasticsearch b/distribution/src/bin/elasticsearch index a0564d409df7c..4f48aa8453d73 100755 --- a/distribution/src/bin/elasticsearch +++ b/distribution/src/bin/elasticsearch @@ -1,139 +1,5 @@ #!/bin/bash -# CONTROLLING STARTUP: -# -# This script relies on a few environment variables to determine startup -# behavior, those variables are: -# -# ES_PATH_CONF -- Path to config directory -# ES_JAVA_OPTS -- External Java Opts on top of the defaults set -# -# Optionally, exact memory values can be set using the `ES_JAVA_OPTS`. Example -# values are "512m", and "10g". -# -# ES_JAVA_OPTS="-Xms8g -Xmx8g" ./bin/elasticsearch - -source "`dirname "$0"`"/elasticsearch-env - -CHECK_KEYSTORE=true -ATTEMPT_SECURITY_AUTO_CONFIG=true -DAEMONIZE=false -ENROLL_TO_CLUSTER=false -# Store original arg array as we will be shifting through it below -ARG_LIST=("$@") - -while [ $# -gt 0 ]; do - if [[ $1 == "--enrollment-token" ]]; then - if [ $ENROLL_TO_CLUSTER = true ]; then - echo "Multiple --enrollment-token parameters are not allowed" 1>&2 - exit 1 - fi - ENROLL_TO_CLUSTER=true - ATTEMPT_SECURITY_AUTO_CONFIG=false - ENROLLMENT_TOKEN="$2" - shift - elif [[ $1 == "-h" || $1 == "--help" || $1 == "-V" || $1 == "--version" ]]; then - CHECK_KEYSTORE=false - ATTEMPT_SECURITY_AUTO_CONFIG=false - elif [[ $1 == "-d" || $1 == "--daemonize" ]]; then - DAEMONIZE=true - fi - if [[ $# -gt 0 ]]; then - shift - fi -done - -if [ -z "$ES_TMPDIR" ]; then - ES_TMPDIR=`"$JAVA" -cp "$SERVER_CLI_CLASSPATH" org.elasticsearch.server.cli.TempDirectory` -fi - -if [ -z "$LIBFFI_TMPDIR" ]; then - LIBFFI_TMPDIR="$ES_TMPDIR" - export LIBFFI_TMPDIR -fi - -# get keystore password before setting java options to avoid -# conflicting GC configurations for the keystore tools -unset KEYSTORE_PASSWORD -KEYSTORE_PASSWORD= -if [[ $CHECK_KEYSTORE = true ]] \ - && bin/elasticsearch-keystore has-passwd --silent -then - if ! read -s -r -p "Elasticsearch keystore password: " KEYSTORE_PASSWORD ; then - echo "Failed to read keystore password on console" 1>&2 - exit 1 - fi -fi - -if [[ $ENROLL_TO_CLUSTER = true ]]; then - CLI_NAME="auto-configure-node" \ - CLI_LIBS="modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli" \ - bin/elasticsearch-cli "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD" -elif [[ $ATTEMPT_SECURITY_AUTO_CONFIG = true ]]; then - # It is possible that an auto-conf failure prevents the node from starting, but this is only the exceptional case (exit code 1). - # Most likely an auto-conf failure will leave the configuration untouched (exit codes 73, 78 and 80), optionally printing a message - # if the error is uncommon or unexpected, but it should otherwise let the node to start as usual. - # It is passed in all the command line options in order to read the node settings ones (-E), while the other parameters are ignored - # (a small caveat is that it also inspects the -v option in order to provide more information on how auto config went) - if CLI_NAME="auto-configure-node" \ - CLI_LIBS="modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli" \ - bin/elasticsearch-cli "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD"; then - : - else - retval=$? - # these exit codes cover the cases where auto-conf cannot run but the node should NOT be prevented from starting as usual - # eg the node is restarted, is already configured in an incompatible way, or the file system permissions do not allow it - if [[ $retval -ne 80 ]] && [[ $retval -ne 73 ]] && [[ $retval -ne 78 ]]; then - exit $retval - fi - fi -fi - -# The JVM options parser produces the final JVM options to start Elasticsearch. -# It does this by incorporating JVM options in the following way: -# - first, system JVM options are applied (these are hardcoded options in the -# parser) -# - second, JVM options are read from jvm.options and jvm.options.d/*.options -# - third, JVM options from ES_JAVA_OPTS are applied -# - fourth, ergonomic JVM options are applied -ES_JAVA_OPTS=`export ES_TMPDIR; "$JAVA" -cp "$SERVER_CLI_CLASSPATH" -Des.distribution.type="$ES_DISTRIBUTION_TYPE" org.elasticsearch.server.cli.JvmOptionsParser "$ES_PATH_CONF" "$ES_HOME/plugins"` - -# Remove enrollment related parameters before passing the arg list to Elasticsearch -for i in "${!ARG_LIST[@]}"; do - if [[ ${ARG_LIST[i]} = "--enrollment-token" || ${ARG_LIST[i]} = "$ENROLLMENT_TOKEN" ]]; then - unset 'ARG_LIST[i]' - fi -done - -# manual parsing to find out, if process should be detached -if [[ $DAEMONIZE = false ]]; then - exec \ - "$JAVA" \ - $ES_JAVA_OPTS \ - -Des.path.home="$ES_HOME" \ - -Des.path.conf="$ES_PATH_CONF" \ - -Des.distribution.type="$ES_DISTRIBUTION_TYPE" \ - -cp "$ES_CLASSPATH" \ - org.elasticsearch.bootstrap.Elasticsearch \ - "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD" -else - exec \ - "$JAVA" \ - $ES_JAVA_OPTS \ - -Des.path.home="$ES_HOME" \ - -Des.path.conf="$ES_PATH_CONF" \ - -Des.distribution.type="$ES_DISTRIBUTION_TYPE" \ - -cp "$ES_CLASSPATH" \ - org.elasticsearch.bootstrap.Elasticsearch \ - "${ARG_LIST[@]}" \ - <<<"$KEYSTORE_PASSWORD" & - retval=$? - pid=$! - [ $retval -eq 0 ] || exit $retval - if ! ps -p $pid > /dev/null ; then - exit 1 - fi - exit 0 -fi - -exit $? +CLI_NAME=server +CLI_LIBS=lib/tools/server-cli +source "`dirname "$0"`"/elasticsearch-cli diff --git a/distribution/src/bin/elasticsearch-env b/distribution/src/bin/elasticsearch-env index 29713f3fbb196..a89495fc5d144 100644 --- a/distribution/src/bin/elasticsearch-env +++ b/distribution/src/bin/elasticsearch-env @@ -32,10 +32,6 @@ while [ "`basename "$ES_HOME"`" != "bin" ]; do done ES_HOME=`dirname "$ES_HOME"` -# now set the classpath -ES_CLASSPATH="$ES_HOME/lib/*" -SERVER_CLI_CLASSPATH="$ES_CLASSPATH:$ES_HOME/lib/tools/server-cli/*" - # now set the path to java if [ ! -z "$ES_JAVA_HOME" ]; then JAVA="$ES_JAVA_HOME/bin/java" diff --git a/distribution/src/bin/elasticsearch-service.bat b/distribution/src/bin/elasticsearch-service.bat index eab0cd1354605..d57b0dc324f1f 100644 --- a/distribution/src/bin/elasticsearch-service.bat +++ b/distribution/src/bin/elasticsearch-service.bat @@ -3,291 +3,13 @@ setlocal enabledelayedexpansion setlocal enableextensions -set NOJAVA=nojava -if /i "%1" == "install" set NOJAVA= - -call "%~dp0elasticsearch-env.bat" %NOJAVA% || exit /b 1 - -set EXECUTABLE=%ES_HOME%\bin\elasticsearch-service-x64.exe -if "%SERVICE_ID%" == "" set SERVICE_ID=elasticsearch-service-x64 -set ARCH=64-bit - -if EXIST "%EXECUTABLE%" goto okExe -echo elasticsearch-service-x64.exe was not found... -exit /B 1 - -:okExe -set ES_VERSION=@project.version@ - -if "%SERVICE_LOG_DIR%" == "" set SERVICE_LOG_DIR=%ES_HOME%\logs - -if "x%1x" == "xx" goto displayUsage -set SERVICE_CMD=%1 -shift -if "x%1x" == "xx" goto checkServiceCmd -set SERVICE_ID=%1 - -:checkServiceCmd - -if "%LOG_OPTS%" == "" set LOG_OPTS=--LogPath "%SERVICE_LOG_DIR%" --LogPrefix "%SERVICE_ID%" --StdError auto --StdOutput auto - -if /i %SERVICE_CMD% == install goto doInstall -if /i %SERVICE_CMD% == remove goto doRemove -if /i %SERVICE_CMD% == start goto doStart -if /i %SERVICE_CMD% == stop goto doStop -if /i %SERVICE_CMD% == manager goto doManagment -echo Unknown option "%SERVICE_CMD%" -exit /B 1 - -:displayUsage -echo. -echo Usage: elasticsearch-service.bat install^|remove^|start^|stop^|manager [SERVICE_ID] -goto:eof - -:doStart -"%EXECUTABLE%" //ES//%SERVICE_ID% %LOG_OPTS% -if not errorlevel 1 goto started -echo Failed starting '%SERVICE_ID%' service -exit /B 1 -goto:eof -:started -echo The service '%SERVICE_ID%' has been started -goto:eof - -:doStop -"%EXECUTABLE%" //SS//%SERVICE_ID% %LOG_OPTS% -if not errorlevel 1 goto stopped -echo Failed stopping '%SERVICE_ID%' service -exit /B 1 -goto:eof -:stopped -echo The service '%SERVICE_ID%' has been stopped -goto:eof - -:doManagment -set EXECUTABLE_MGR=%ES_HOME%\bin\elasticsearch-service-mgr -"%EXECUTABLE_MGR%" //ES//%SERVICE_ID% -if not errorlevel 1 goto managed -echo Failed starting service manager for '%SERVICE_ID%' -exit /B 1 -goto:eof -:managed -echo Successfully started service manager for '%SERVICE_ID%'. -goto:eof - -:doRemove -rem Remove the service -"%EXECUTABLE%" //DS//%SERVICE_ID% %LOG_OPTS% -if not errorlevel 1 goto removed -echo Failed removing '%SERVICE_ID%' service -exit /B 1 -goto:eof -:removed -echo The service '%SERVICE_ID%' has been removed -goto:eof - -:doInstall -echo Installing service : "%SERVICE_ID%" -echo Using ES_JAVA_HOME (%ARCH%): "%ES_JAVA_HOME%" - -rem Check JVM server dll first -if exist "%ES_JAVA_HOME%\jre\bin\server\jvm.dll" ( - set JVM_DLL=\jre\bin\server\jvm.dll - goto foundJVM -) - -rem Check 'server' JRE (JRE installed on Windows Server) -if exist "%ES_JAVA_HOME%\bin\server\jvm.dll" ( - set JVM_DLL=\bin\server\jvm.dll - goto foundJVM -) else ( - echo ES_JAVA_HOME ("%ES_JAVA_HOME%"^) points to an invalid Java installation (no jvm.dll found in "%ES_JAVA_HOME%\jre\bin\server" or "%ES_JAVA_HOME%\bin\server"^). Exiting... - goto:eof -) - -:foundJVM -if not defined ES_TMPDIR ( - for /f "tokens=* usebackq" %%a in (`CALL %JAVA% -cp "!SERVER_CLI_CLASSPATH!" "org.elasticsearch.server.cli.TempDirectory"`) do set ES_TMPDIR=%%a -) - -rem The JVM options parser produces the final JVM options to start -rem Elasticsearch. It does this by incorporating JVM options in the following -rem way: -rem - first, system JVM options are applied (these are hardcoded options in -rem the parser) -rem - second, JVM options are read from jvm.options and -rem jvm.options.d/*.options -rem - third, JVM options from ES_JAVA_OPTS are applied -rem - fourth, ergonomic JVM options are applied - -@setlocal -for /F "usebackq delims=" %%a in (`CALL %JAVA% -cp "!SERVER_CLI_CLASSPATH!" "org.elasticsearch.server.cli.JvmOptionsParser" "!ES_PATH_CONF!" "!ES_HOME!"/plugins ^|^| echo jvm_options_parser_failed`) do set ES_JAVA_OPTS=%%a -@endlocal & set "MAYBE_JVM_OPTIONS_PARSER_FAILED=%ES_JAVA_OPTS%" & set ES_JAVA_OPTS=%ES_JAVA_OPTS% - -if "%MAYBE_JVM_OPTIONS_PARSER_FAILED%" == "jvm_options_parser_failed" ( - exit /b 1 -) - -rem The output of the JVM options parses is space-delimited. We need to -rem convert to semicolon-delimited and avoid doubled semicolons. -@setlocal -if not "%ES_JAVA_OPTS%" == "" ( - set ES_JAVA_OPTS=!ES_JAVA_OPTS: =;! - set ES_JAVA_OPTS=!ES_JAVA_OPTS:;;=;! -) -@endlocal & set ES_JAVA_OPTS=%ES_JAVA_OPTS% - -if "%ES_JAVA_OPTS:~-1%"==";" set ES_JAVA_OPTS=%ES_JAVA_OPTS:~0,-1% - -echo %ES_JAVA_OPTS% - -@setlocal EnableDelayedExpansion -for %%a in ("%ES_JAVA_OPTS:;=","%") do ( - set var=%%a - set other_opt=true - if "!var:~1,4!" == "-Xms" ( - set XMS=!var:~5,-1! - set other_opt=false - call:convertxm !XMS! JVM_MS - ) - if "!var:~1,16!" == "-XX:MinHeapSize=" ( - set XMS=!var:~17,-1! - set other_opt=false - call:convertxm !XMS! JVM_MS - ) - if "!var:~1,4!" == "-Xmx" ( - set XMX=!var:~5,-1! - set other_opt=false - call:convertxm !XMX! JVM_MX - ) - if "!var:~1,16!" == "-XX:MaxHeapSize=" ( - set XMX=!var:~17,-1! - set other_opt=false - call:convertxm !XMX! JVM_MX - ) - if "!var:~1,4!" == "-Xss" ( - set XSS=!var:~5,-1! - set other_opt=false - call:convertxk !XSS! JVM_SS - ) - if "!var:~1,20!" == "-XX:ThreadStackSize=" ( - set XSS=!var:~21,-1! - set other_opt=false - call:convertxk !XSS! JVM_SS - ) - if "!other_opt!" == "true" set OTHER_JAVA_OPTS=!OTHER_JAVA_OPTS!;!var! -) -@endlocal & set JVM_MS=%JVM_MS% & set JVM_MX=%JVM_MX% & set JVM_SS=%JVM_SS% & set OTHER_JAVA_OPTS=%OTHER_JAVA_OPTS% - -if "%JVM_MS%" == "" ( - echo minimum heap size not set; configure using -Xms via "%ES_PATH_CONF%/jvm.options.d", or ES_JAVA_OPTS - goto:eof -) -if "%JVM_MX%" == "" ( - echo maximum heap size not set; configure using -Xmx via "%ES_PATH_CONF%/jvm.options.d", or ES_JAVA_OPTS - goto:eof -) -if "%JVM_SS%" == "" ( - echo thread stack size not set; configure using -Xss via "%ES_PATH_CONF%/jvm.options.d", or ES_JAVA_OPTS - goto:eof -) -set OTHER_JAVA_OPTS=%OTHER_JAVA_OPTS:"=% -set OTHER_JAVA_OPTS=%OTHER_JAVA_OPTS:~1% - -set ES_PARAMS=-Delasticsearch;-Des.path.home="%ES_HOME%";-Des.path.conf="%ES_PATH_CONF%";-Des.distribution.type="%ES_DISTRIBUTION_TYPE%" - -if "%ES_START_TYPE%" == "" set ES_START_TYPE=manual -if "%ES_STOP_TIMEOUT%" == "" set ES_STOP_TIMEOUT=0 - -if "%SERVICE_DISPLAY_NAME%" == "" set SERVICE_DISPLAY_NAME=Elasticsearch %ES_VERSION% (%SERVICE_ID%) -if "%SERVICE_DESCRIPTION%" == "" set SERVICE_DESCRIPTION=Elasticsearch %ES_VERSION% Windows Service - https://elastic.co - -if not "%SERVICE_USERNAME%" == "" ( - if not "%SERVICE_PASSWORD%" == "" ( - set SERVICE_PARAMS=%SERVICE_PARAMS% --ServiceUser "%SERVICE_USERNAME%" --ServicePassword "%SERVICE_PASSWORD%" - ) -) else ( - set SERVICE_PARAMS=%SERVICE_PARAMS% --ServiceUser LocalSystem -) -"%EXECUTABLE%" //IS//%SERVICE_ID% --Startup %ES_START_TYPE% --StopTimeout %ES_STOP_TIMEOUT% --StartClass org.elasticsearch.bootstrap.Elasticsearch --StartMethod main ++StartParams --quiet --StopClass org.elasticsearch.bootstrap.Elasticsearch --StopMethod close --Classpath "%ES_CLASSPATH%" --JvmMs %JVM_MS% --JvmMx %JVM_MX% --JvmSs %JVM_SS% --JvmOptions %OTHER_JAVA_OPTS% ++JvmOptions %ES_PARAMS% %LOG_OPTS% --PidFile "%SERVICE_ID%.pid" --DisplayName "%SERVICE_DISPLAY_NAME%" --Description "%SERVICE_DESCRIPTION%" --Jvm "%ES_JAVA_HOME%%JVM_DLL%" --StartMode jvm --StopMode jvm --StartPath "%ES_HOME%" %SERVICE_PARAMS% ++Environment HOSTNAME="%%COMPUTERNAME%%" - -if not errorlevel 1 goto installed -echo Failed installing '%SERVICE_ID%' service -exit /B 1 -goto:eof - -:installed -echo The service '%SERVICE_ID%' has been installed. -goto:eof - -:err -echo ES_JAVA_HOME environment variable must be set! -pause -goto:eof - -rem --- -rem Function for converting Xm[s|x] values into MB which Commons Daemon accepts -rem --- -:convertxm -set value=%~1 -rem extract last char (unit) -set unit=%value:~-1% -rem assume the unit is specified -set conv=%value:~0,-1% - -if "%unit%" == "k" goto kilo -if "%unit%" == "K" goto kilo -if "%unit%" == "m" goto mega -if "%unit%" == "M" goto mega -if "%unit%" == "g" goto giga -if "%unit%" == "G" goto giga - -rem no unit found, must be bytes; consider the whole value -set conv=%value% -rem convert to KB -set /a conv=%conv% / 1024 -:kilo -rem convert to MB -set /a conv=%conv% / 1024 -goto mega -:giga -rem convert to MB -set /a conv=%conv% * 1024 -:mega -set "%~2=%conv%" -goto:eof - -:convertxk -set value=%~1 -rem extract last char (unit) -set unit=%value:~-1% -rem assume the unit is specified -set conv=%value:~0,-1% - -if "%unit%" == "k" goto kilo -if "%unit%" == "K" goto kilo -if "%unit%" == "m" goto mega -if "%unit%" == "M" goto mega -if "%unit%" == "g" goto giga -if "%unit%" == "G" goto giga - -rem no unit found, must be bytes; consider the whole value -set conv=%value% -rem convert to KB -set /a conv=%conv% / 1024 -goto kilo -:mega -rem convert to KB -set /a conv=%conv% * 1024 -goto kilo -:giga -rem convert to KB -set /a conv=%conv% * 1024 * 1024 -:kilo -set "%~2=%conv%" -goto:eof +set CLI_NAME=windows-service +set CLI_LIBS=lib/tools/windows-service-cli +call "%~dp0elasticsearch-cli.bat" ^ + %%* ^ + || goto exit endlocal endlocal - +:exit exit /b %ERRORLEVEL% diff --git a/distribution/src/bin/elasticsearch.bat b/distribution/src/bin/elasticsearch.bat index 3461b1dd91f09..18c2a4b26c4ce 100644 --- a/distribution/src/bin/elasticsearch.bat +++ b/distribution/src/bin/elasticsearch.bat @@ -3,158 +3,11 @@ setlocal enabledelayedexpansion setlocal enableextensions -SET params='%*' -SET checkpassword=Y -SET enrolltocluster=N -SET attemptautoconfig=Y +set CLI_NAME=server +set CLI_LIBS=lib/tools/server-cli +call "%~dp0elasticsearch-cli.bat" ^ + %%* ^ + || goto exit -:loop -FOR /F "usebackq tokens=1* delims= " %%A IN (!params!) DO ( - SET previous=!current! - SET current=%%A - SET params='%%B' - SET silent=N - IF "!current!" == "-s" ( - SET silent=Y - ) - IF "!current!" == "--silent" ( - SET silent=Y - ) - - IF "!current!" == "-h" ( - SET checkpassword=N - SET attemptautoconfig=N - ) - IF "!current!" == "--help" ( - SET checkpassword=N - SET attemptautoconfig=N - ) - - IF "!current!" == "-V" ( - SET checkpassword=N - SET attemptautoconfig=N - ) - IF "!current!" == "--version" ( - SET checkpassword=N - SET attemptautoconfig=N - ) - - IF "!current!" == "--enrollment-token" ( - IF "!enrolltocluster!" == "Y" ( - ECHO "Multiple --enrollment-token parameters are not allowed" 1>&2 - goto exitwithone - ) - SET enrolltocluster=Y - SET attemptautoconfig=N - ) - - IF "!previous!" == "--enrollment-token" ( - SET enrollmenttoken="!current!" - ) - - IF "!silent!" == "Y" ( - SET nopauseonerror=Y - ) ELSE ( - SET SHOULD_SKIP=false - IF "!previous!" == "--enrollment-token" SET SHOULD_SKIP=true - IF "!current!" == "--enrollment-token" SET SHOULD_SKIP=true - IF "!SHOULD_SKIP!" == "false" ( - IF "x!newparams!" NEQ "x" ( - SET newparams=!newparams! !current! - ) ELSE ( - SET newparams=!current! - ) - ) - - ) - - IF "x!params!" NEQ "x" ( - GOTO loop - ) -) - -CALL "%~dp0elasticsearch-env.bat" || exit /b 1 -IF ERRORLEVEL 1 ( - IF NOT DEFINED nopauseonerror ( - PAUSE - ) - EXIT /B %ERRORLEVEL% -) - -SET KEYSTORE_PASSWORD= -IF "%checkpassword%"=="Y" ( - CALL "%~dp0elasticsearch-keystore.bat" has-passwd --silent - IF !ERRORLEVEL! EQU 0 ( - SET /P KEYSTORE_PASSWORD=Elasticsearch keystore password: - IF !ERRORLEVEL! NEQ 0 ( - ECHO Failed to read keystore password on standard input - EXIT /B !ERRORLEVEL! - ) - ) -) - -rem windows batch pipe will choke on special characters in strings -SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^^=^^^^! -SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^&=^^^&! -SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^|=^^^|! -SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^<=^^^=^^^>! -SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^\=^^^\! - -IF "%attemptautoconfig%"=="Y" ( - SET CLI_NAME=auto-configure-node - SET CLI_LIBS=modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli - ECHO.!KEYSTORE_PASSWORD!|call "%~dp0elasticsearch-cli.bat" !newparams! - SET SHOULDEXIT=Y - IF !ERRORLEVEL! EQU 0 SET SHOULDEXIT=N - IF !ERRORLEVEL! EQU 73 SET SHOULDEXIT=N - IF !ERRORLEVEL! EQU 78 SET SHOULDEXIT=N - IF !ERRORLEVEL! EQU 80 SET SHOULDEXIT=N - IF "!SHOULDEXIT!"=="Y" ( - exit /b !ERRORLEVEL! - ) -) - -IF "!enrolltocluster!"=="Y" ( - SET CLI_NAME=auto-configure-node - SET CLI_LIBS=modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli - ECHO.!KEYSTORE_PASSWORD!|call "%~dp0elasticsearch-cli.bat" !newparams! --enrollment-token %enrollmenttoken% - IF !ERRORLEVEL! NEQ 0 ( - exit /b !ERRORLEVEL! - ) -) - -if not defined ES_TMPDIR ( - for /f "tokens=* usebackq" %%a in (`CALL %JAVA% -cp "!SERVER_CLI_CLASSPATH!" "org.elasticsearch.server.cli.TempDirectory"`) do set ES_TMPDIR=%%a -) - -rem The JVM options parser produces the final JVM options to start -rem Elasticsearch. It does this by incorporating JVM options in the following -rem way: -rem - first, system JVM options are applied (these are hardcoded options in -rem the parser) -rem - second, JVM options are read from jvm.options and -rem jvm.options.d/*.options -rem - third, JVM options from ES_JAVA_OPTS are applied -rem - fourth, ergonomic JVM options are applied -@setlocal -for /F "usebackq delims=" %%a in (`CALL %JAVA% -cp "!SERVER_CLI_CLASSPATH!" "org.elasticsearch.server.cli.JvmOptionsParser" "!ES_PATH_CONF!" "!ES_HOME!"/plugins ^|^| echo jvm_options_parser_failed`) do set ES_JAVA_OPTS=%%a -@endlocal & set "MAYBE_JVM_OPTIONS_PARSER_FAILED=%ES_JAVA_OPTS%" & set ES_JAVA_OPTS=%ES_JAVA_OPTS% - -if "%MAYBE_JVM_OPTIONS_PARSER_FAILED%" == "jvm_options_parser_failed" ( - exit /b 1 -) - -ECHO.!KEYSTORE_PASSWORD!| %JAVA% %ES_JAVA_OPTS% -Delasticsearch ^ - -Des.path.home="%ES_HOME%" -Des.path.conf="%ES_PATH_CONF%" ^ - -Des.distribution.type="%ES_DISTRIBUTION_TYPE%" ^ - -cp "%ES_CLASSPATH%" "org.elasticsearch.bootstrap.Elasticsearch" !newparams! - -endlocal -endlocal +:exit exit /b %ERRORLEVEL% - -rem this hack is ugly but necessary because we can't exit with /b X from within the argument parsing loop. -rem exit 1 (without /b) would work for powershell but it will terminate the cmd process when run in cmd -:exitwithone - exit /b 1 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 f416cbff9a73c..f187763f13cad 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 @@ -11,6 +11,7 @@ import org.apache.logging.log4j.Level; import org.elasticsearch.cli.CliToolProvider; import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ExitCodes; import org.elasticsearch.cli.ProcessInfo; import org.elasticsearch.cli.Terminal; import org.elasticsearch.common.logging.LogConfigurator; @@ -29,6 +30,8 @@ class CliToolLauncher { private static final String SCRIPT_PREFIX = "elasticsearch-"; + private static volatile Command command; + /** * Runs a CLI tool. * @@ -54,13 +57,15 @@ public static void main(String[] args) throws Exception { String toolname = getToolName(pinfo.sysprops()); String libs = pinfo.sysprops().getOrDefault("cli.libs", ""); - Command command = CliToolProvider.load(toolname, libs).create(); + command = CliToolProvider.load(toolname, libs).create(); Terminal terminal = Terminal.DEFAULT; Runtime.getRuntime().addShutdownHook(createShutdownHook(terminal, command)); int exitCode = command.main(args, terminal, pinfo); terminal.flush(); // make sure nothing is left in buffers - exit(exitCode); + if (exitCode != ExitCodes.OK) { + exit(exitCode); + } } // package private for tests @@ -106,4 +111,17 @@ private static void configureLoggingWithoutConfig(Map sysprops) final Settings settings = Settings.builder().put("logger.level", loggerLevel).build(); LogConfigurator.configureWithoutConfig(settings); } + + /** + * Required method that's called by Apache Commons procrun when + * running as a service on Windows, when the service is stopped. + * + * http://commons.apache.org/proper/commons-daemon/procrun.html + * + * NOTE: If this method is renamed and/or moved, make sure to + * update WindowsServiceInstallCommand! + */ + static void close(String[] args) throws IOException { + command.close(); + } } diff --git a/distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/BootstrapTests.java b/distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/BootstrapTests.java index 9b0ff657b7bcf..d4405db422976 100644 --- a/distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/BootstrapTests.java +++ b/distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/BootstrapTests.java @@ -18,18 +18,13 @@ import org.junit.After; import org.junit.Before; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import static org.hamcrest.Matchers.equalTo; - public class BootstrapTests extends ESTestCase { Environment env; List fileSystems = new ArrayList<>(); @@ -54,48 +49,12 @@ public void testLoadSecureSettings() throws Exception { assertTrue(seed.length() > 0); keyStoreWrapper.save(configPath, password); } - final InputStream in = password.length > 0 - ? new ByteArrayInputStream(new String(password).getBytes(StandardCharsets.UTF_8)) - : System.in; + SecureString keystorePassword = new SecureString(password); assertTrue(Files.exists(configPath.resolve("elasticsearch.keystore"))); - try (SecureSettings secureSettings = BootstrapUtil.loadSecureSettings(env, in)) { + try (SecureSettings secureSettings = BootstrapUtil.loadSecureSettings(env, keystorePassword)) { SecureString seedAfterLoad = KeyStoreWrapper.SEED_SETTING.get(Settings.builder().setSecureSettings(secureSettings).build()); assertEquals(seedAfterLoad.toString(), seed.toString()); assertTrue(Files.exists(configPath.resolve("elasticsearch.keystore"))); } } - - public void testReadCharsFromStdin() throws Exception { - assertPassphraseRead("hello", "hello"); - assertPassphraseRead("hello\n", "hello"); - assertPassphraseRead("hello\r\n", "hello"); - - assertPassphraseRead("hellohello", "hellohello"); - assertPassphraseRead("hellohello\n", "hellohello"); - assertPassphraseRead("hellohello\r\n", "hellohello"); - - assertPassphraseRead("hello\nhi\n", "hello"); - assertPassphraseRead("hello\r\nhi\r\n", "hello"); - } - - public void testNoPassPhraseProvided() throws Exception { - byte[] source = "\r\n".getBytes(StandardCharsets.UTF_8); - try (InputStream stream = new ByteArrayInputStream(source)) { - expectThrows( - RuntimeException.class, - "Keystore passphrase required but none provided.", - () -> BootstrapUtil.readPassphrase(stream) - ); - } - } - - private void assertPassphraseRead(String source, String expected) { - try (InputStream stream = new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))) { - SecureString result = BootstrapUtil.readPassphrase(stream); - assertThat(result, equalTo(expected)); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } diff --git a/distribution/tools/server-cli/build.gradle b/distribution/tools/server-cli/build.gradle index 5018cedfe035f..3ab5e6e86f5ba 100644 --- a/distribution/tools/server-cli/build.gradle +++ b/distribution/tools/server-cli/build.gradle @@ -16,6 +16,10 @@ dependencies { testImplementation project(":test:framework") } +tasks.named("test").configure { + systemProperty "tests.security.manager", "false" +} + tasks.withType(CheckForbiddenApis).configureEach { replaceSignatureFiles 'jdk-signatures' } diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java new file mode 100644 index 0000000000000..d8f0c4471c658 --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ErrorPumpThread.java @@ -0,0 +1,105 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +import org.elasticsearch.bootstrap.BootstrapInfo; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; + +import static org.elasticsearch.bootstrap.BootstrapInfo.SERVER_READY_MARKER; +import static org.elasticsearch.bootstrap.BootstrapInfo.USER_EXCEPTION_MARKER; +import static org.elasticsearch.server.cli.ProcessUtil.nonInterruptibleVoid; + +/** + * A thread which reads stderr of the jvm process and writes it to this process' stderr. + * + *

Two special state markers are watched for. These are ascii control characters which signal + * to the cli process something has changed in the server process. The two possible special messages are: + *

+ */ +class ErrorPumpThread extends Thread { + private final BufferedReader reader; + private final PrintWriter writer; + + // 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 exception message received alongside the user exception marker, if a bootstrap error has occurred + private volatile String userExceptionMsg; + + // an unexpected io failure that occurred while pumping stderr + private volatile IOException ioFailure; + + ErrorPumpThread(PrintWriter errOutput, InputStream errInput) { + super("server-cli[stderr_pump]"); + this.reader = new BufferedReader(new InputStreamReader(errInput, StandardCharsets.UTF_8)); + this.writer = errOutput; + } + + /** + * Waits until the server ready marker has been received. + * + * @return a bootstrap exeption message if a bootstrap error occurred, or null otherwise + * @throws IOException if there was a problem reading from stderr of the process + */ + String waitUntilReady() throws IOException { + nonInterruptibleVoid(readyOrDead::await); + if (ioFailure != null) { + throw ioFailure; + } + if (ready == false) { + return userExceptionMsg; + } + assert userExceptionMsg == null; + return null; + } + + /** + * Waits for the stderr pump thread to exit. + */ + void drain() { + nonInterruptibleVoid(this::join); + } + + @Override + public void run() { + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty() == false && line.charAt(0) == USER_EXCEPTION_MARKER) { + userExceptionMsg = line.substring(1); + readyOrDead.countDown(); + } else if (line.isEmpty() == false && line.charAt(0) == SERVER_READY_MARKER) { + ready = true; + readyOrDead.countDown(); + } else { + writer.println(line); + } + } + } catch (IOException e) { + ioFailure = e; + } finally { + writer.flush(); + readyOrDead.countDown(); + } + } +} 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 a307b366efdb3..20cd51d10177a 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 @@ -20,7 +20,6 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -62,33 +61,6 @@ SortedMap invalidLines() { } - /** - * The main entry point. The exit code is 0 if the JVM options were successfully parsed, otherwise the exit code is 1. If an improperly - * formatted line is discovered, the line is output to standard error. - * - * @param args the args to the program which should consist of two options, - * the path to ES_PATH_CONF, and the path to the plugins directory - */ - public static void main(final String[] args) throws InterruptedException, IOException { - if (args.length != 2) { - throw new IllegalArgumentException( - "Expected two arguments specifying path to ES_PATH_CONF and plugins directory, but was " + Arrays.toString(args) - ); - } - try { - Path configDir = Paths.get(args[0]); - Path pluginsDir = Paths.get(args[1]); - Path tmpDir = Paths.get(System.getenv("ES_TMPDIR")); - String envOptions = System.getenv("ES_JAVA_OPTS"); - var jvmOptions = determineJvmOptions(configDir, pluginsDir, tmpDir, envOptions); - - Launchers.outPrintln(String.join(" ", jvmOptions)); - } catch (UserException e) { - Launchers.errPrintln(e.getMessage()); - Launchers.exit(e.exitCode); - } - } - /** * Determines the jvm options that should be passed to the Elasticsearch Java process. * diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/KeystorePasswordTerminal.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/KeystorePasswordTerminal.java new file mode 100644 index 0000000000000..bf03acaf7a5da --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/KeystorePasswordTerminal.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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.common.settings.SecureString; + +import java.io.Closeable; +import java.io.OutputStream; + +/** + * A terminal that wraps an existing terminal and provides a single secret input, the keystore password. + */ +class KeystorePasswordTerminal extends Terminal implements Closeable { + + private final Terminal delegate; + private final SecureString password; + + KeystorePasswordTerminal(Terminal delegate, SecureString password) { + super(delegate.getReader(), delegate.getWriter(), delegate.getErrorWriter()); + this.delegate = delegate; + this.password = password; + setVerbosity(delegate.getVerbosity()); + } + + @Override + public char[] readSecret(String prompt) { + return password.getChars(); + } + + @Override + public OutputStream getOutputStream() { + return delegate.getOutputStream(); + } + + @Override + public void close() { + password.close(); + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/Launchers.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/Launchers.java deleted file mode 100644 index f8e1299a74718..0000000000000 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/Launchers.java +++ /dev/null @@ -1,58 +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 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 or the Server - * Side Public License, v 1. - */ - -package org.elasticsearch.server.cli; - -import org.elasticsearch.core.SuppressForbidden; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.FileAttribute; - -/** - * Utility methods for launchers. - */ -final class Launchers { - - /** - * Prints a string and terminates the line on standard output. - * - * @param message the message to print - */ - @SuppressForbidden(reason = "System#out") - static void outPrintln(final String message) { - System.out.println(message); - } - - /** - * Prints a string and terminates the line on standard error. - * - * @param message the message to print - */ - @SuppressForbidden(reason = "System#err") - static void errPrintln(final String message) { - System.err.println(message); - } - - /** - * Exit the VM with the specified status. - * - * @param status the status - */ - @SuppressForbidden(reason = "System#exit") - static void exit(final int status) { - System.exit(status); - } - - @SuppressForbidden(reason = "Files#createTempDirectory(String, FileAttribute...)") - static Path createTempDirectory(final String prefix, final FileAttribute... attrs) throws IOException { - return Files.createTempDirectory(prefix, attrs); - } - -} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java new file mode 100644 index 0000000000000..fb5f7bae34844 --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ProcessUtil.java @@ -0,0 +1,43 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +class ProcessUtil { + + private ProcessUtil() { /* no instance*/ } + + interface Interruptible { + T run() throws InterruptedException; + } + + interface InterruptibleVoid { + void run() throws InterruptedException; + } + + /** + * Runs an interruptable method, but throws an assertion if an interrupt is received. + * + * This is useful for threads which expect a no interruption policy + */ + static T nonInterruptible(Interruptible interruptible) { + try { + return interruptible.run(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + + static void nonInterruptibleVoid(InterruptibleVoid interruptible) { + nonInterruptible(() -> { + interruptible.run(); + return null; + }); + } +} 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 new file mode 100644 index 0000000000000..59588d1fcedfc --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCli.java @@ -0,0 +1,192 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; +import joptsimple.OptionSpecBuilder; +import joptsimple.util.PathConverter; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.Build; +import org.elasticsearch.bootstrap.ServerArgs; +import org.elasticsearch.cli.CliToolProvider; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.env.Environment; +import org.elasticsearch.monitor.jvm.JvmInfo; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Locale; + +/** + * The main CLI for running Elasticsearch. + */ +class ServerCli extends EnvironmentAwareCommand { + + private static final Logger logger = LogManager.getLogger(ServerCli.class); + + private final OptionSpecBuilder versionOption; + private final OptionSpecBuilder daemonizeOption; + private final OptionSpec pidfileOption; + private final OptionSpecBuilder quietOption; + private final OptionSpec enrollmentTokenOption; + + private volatile ServerProcess server; + + // visible for testing + ServerCli() { + super("Starts Elasticsearch"); // we configure logging later so we override the base class from configuring logging + versionOption = parser.acceptsAll(Arrays.asList("V", "version"), "Prints Elasticsearch version information and exits"); + daemonizeOption = parser.acceptsAll(Arrays.asList("d", "daemonize"), "Starts Elasticsearch in the background") + .availableUnless(versionOption); + pidfileOption = parser.acceptsAll(Arrays.asList("p", "pidfile"), "Creates a pid file in the specified path on start") + .availableUnless(versionOption) + .withRequiredArg() + .withValuesConvertedBy(new PathConverter()); + quietOption = parser.acceptsAll(Arrays.asList("q", "quiet"), "Turns off standard output/error streams logging in console") + .availableUnless(versionOption) + .availableUnless(daemonizeOption); + enrollmentTokenOption = parser.accepts("enrollment-token", "An existing enrollment token for securely joining a cluster") + .availableUnless(versionOption) + .withRequiredArg(); + } + + @Override + public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception { + if (options.nonOptionArguments().isEmpty() == false) { + throw new UserException(ExitCodes.USAGE, "Positional arguments not allowed, found " + options.nonOptionArguments()); + } + if (options.has(versionOption)) { + printVersion(terminal); + return; + } + + if (options.valuesOf(enrollmentTokenOption).size() > 1) { + throw new UserException(ExitCodes.USAGE, "Multiple --enrollment-token parameters are not allowed"); + } + + // setup security + final SecureString keystorePassword = getKeystorePassword(env.configFile(), terminal); + env = autoConfigureSecurity(terminal, options, processInfo, env, keystorePassword); + + ServerArgs args = createArgs(options, env, keystorePassword); + this.server = startServer(terminal, processInfo, args, env.pluginsFile()); + + if (options.has(daemonizeOption)) { + server.detach(); + return; + } + + // we are running in the foreground, so wait for the server to exit + server.waitFor(); + } + + private void printVersion(Terminal terminal) { + final String versionOutput = String.format( + Locale.ROOT, + "Version: %s, Build: %s/%s/%s, JVM: %s", + Build.CURRENT.qualifiedVersion(), + Build.CURRENT.type().displayName(), + Build.CURRENT.hash(), + Build.CURRENT.date(), + JvmInfo.jvmInfo().version() + ); + terminal.println(versionOutput); + } + + private static SecureString getKeystorePassword(Path configDir, Terminal terminal) throws IOException { + try (KeyStoreWrapper keystore = KeyStoreWrapper.load(configDir)) { + if (keystore != null && keystore.hasPassword()) { + return new SecureString(terminal.readSecret(KeyStoreWrapper.PROMPT)); + } else { + return new SecureString(new char[0]); + } + } + } + + private Environment autoConfigureSecurity( + Terminal terminal, + OptionSet options, + ProcessInfo processInfo, + Environment env, + SecureString keystorePassword + ) throws Exception { + String autoConfigLibs = "modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli"; + Command cmd = loadTool("auto-configure-node", autoConfigLibs); + assert cmd instanceof EnvironmentAwareCommand; + @SuppressWarnings("raw") + var autoConfigNode = (EnvironmentAwareCommand) cmd; + final String[] autoConfigArgs; + if (options.has(enrollmentTokenOption)) { + autoConfigArgs = new String[] { "--enrollment-token", options.valueOf(enrollmentTokenOption) }; + } else { + autoConfigArgs = new String[0]; + } + OptionSet autoConfigOptions = autoConfigNode.parseOptions(autoConfigArgs); + + boolean changed = true; + try (var autoConfigTerminal = new KeystorePasswordTerminal(terminal, keystorePassword.clone())) { + autoConfigNode.execute(autoConfigTerminal, autoConfigOptions, env, processInfo); + } catch (UserException e) { + boolean okCode = switch (e.exitCode) { + // these exit codes cover the cases where auto-conf cannot run but the node should NOT be prevented from starting as usual + // eg the node is restarted, is already configured in an incompatible way, or the file system permissions do not allow it + case ExitCodes.CANT_CREATE, ExitCodes.CONFIG, ExitCodes.NOOP -> true; + default -> false; + }; + if (options.has(enrollmentTokenOption) == false && okCode) { + // we still want to print the error, just don't fail startup + terminal.errorPrintln(e.getMessage()); + changed = false; + } else { + throw e; + } + } + if (changed) { + // reload settings since auto security changed them + env = createEnv(options, processInfo); + } + return env; + + } + + private ServerArgs createArgs(OptionSet options, Environment env, SecureString keystorePassword) { + boolean daemonize = options.has(daemonizeOption); + boolean quiet = options.has(quietOption); + Path pidFile = options.valueOf(pidfileOption); + return new ServerArgs(daemonize, quiet, pidFile, keystorePassword, env.settings(), env.configFile()); + } + + @Override + public void close() { + if (server != null) { + server.stop(); + } + } + + // protected to allow tests to override + protected Command loadTool(String toolname, String libs) { + return CliToolProvider.load(toolname, libs).create(); + } + + // protected to allow tests to override + protected ServerProcess startServer(Terminal terminal, ProcessInfo processInfo, ServerArgs args, Path pluginsDir) throws UserException { + return ServerProcess.start(terminal, processInfo, args, pluginsDir); + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCliProvider.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCliProvider.java new file mode 100644 index 0000000000000..ae9b46a39df51 --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerCliProvider.java @@ -0,0 +1,24 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +import org.elasticsearch.cli.CliToolProvider; +import org.elasticsearch.cli.Command; + +public class ServerCliProvider implements CliToolProvider { + @Override + public String name() { + return "server"; + } + + @Override + public Command create() { + return new ServerCli(); + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java new file mode 100644 index 0000000000000..274131137c8d1 --- /dev/null +++ b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/ServerProcess.java @@ -0,0 +1,266 @@ +/* + * 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 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 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.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; +import org.elasticsearch.core.IOUtils; +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.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileAttribute; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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 #start(Terminal, ProcessInfo, ServerArgs, Path)}. + * 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: + *

    + *
  • Block on the server process exiting, by calling {@link #waitFor()}
  • + *
  • Detach from the server process by calling {@link #detach()}
  • + *
  • Tell the server process to shutdown and wait for it to exit by calling {@link #stop()}
  • + *
+ */ +public class ServerProcess { + + // the actual java process of the server + private final Process jvmProcess; + + // the thread pumping stderr watching for state change messages + 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) { + this.jvmProcess = jvmProcess; + this.errorPump = errorPump; + } + + // this allows mocking the process building by tests + interface OptionsBuilder { + List getJvmOptions(Path configDir, Path pluginsDir, Path tmpDir, String envOptions) throws InterruptedException, + IOException, UserException; + } + + // this allows mocking the process building by tests + interface ProcessStarter { + Process start(ProcessBuilder pb) throws IOException; + } + + /** + * Start a server in a new process. + * + * @param terminal A terminal to connect the standard inputs and outputs to for the new process. + * @param processInfo Info about the current process, for passing through to the subprocess. + * @param args Arguments to the server process. + * @param pluginsDir The directory in which plugins can be found + * @return A running server process that is ready for requests + * @throws UserException If the process failed during bootstrap + */ + public static ServerProcess start(Terminal terminal, ProcessInfo processInfo, ServerArgs args, Path pluginsDir) throws UserException { + return start(terminal, processInfo, args, pluginsDir, JvmOptionsParser::determineJvmOptions, ProcessBuilder::start); + } + + // package private so tests can mock options building and process starting + static ServerProcess start( + Terminal terminal, + ProcessInfo processInfo, + ServerArgs args, + Path pluginsDir, + OptionsBuilder optionsBuilder, + ProcessStarter processStarter + ) throws UserException { + Process jvmProcess = null; + ErrorPumpThread errorPump; + + boolean success = false; + try { + jvmProcess = createProcess(processInfo, args.configDir(), pluginsDir, optionsBuilder, processStarter); + errorPump = new ErrorPumpThread(terminal.getErrorWriter(), jvmProcess.getErrorStream()); + errorPump.start(); + sendArgs(args, jvmProcess.getOutputStream()); + + String errorMsg = errorPump.waitUntilReady(); + if (errorMsg != null) { + // something bad happened, wait for the process to exit then rethrow + int exitCode = jvmProcess.waitFor(); + throw new UserException(exitCode, errorMsg); + } + 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); + } + + /** + * Detaches the server process from the current process, enabling the current process to exit. + * + * @throws IOException If an I/O error occured while reading stderr or closing any of the standard streams + */ + public synchronized void detach() throws IOException { + errorPump.drain(); + IOUtils.close(jvmProcess.getOutputStream(), jvmProcess.getInputStream(), jvmProcess.getErrorStream()); + detached = true; + } + + /** + * Waits for the subprocess to exit. + */ + public void waitFor() { + errorPump.drain(); + int exitCode = nonInterruptible(jvmProcess::waitFor); + if (exitCode != ExitCodes.OK) { + throw new RuntimeException("server process exited with status code " + 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. + */ + public synchronized void stop() { + if (detached) { + return; + } + + sendShutdownMarker(); + waitFor(); + } + + 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 anyways. 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. + } + args.keystorePassword().close(); + } + + private void sendShutdownMarker() { + try { + OutputStream os = jvmProcess.getOutputStream(); + os.write(BootstrapInfo.SERVER_SHUTDOWN_MARKER); + os.flush(); + } catch (IOException e) { + // process is already effectively dead, fall through to wait for it, or should we SIGKILL? + } + } + + private static Process createProcess( + ProcessInfo processInfo, + Path configDir, + Path pluginsDir, + OptionsBuilder optionsBuilder, + ProcessStarter processStarter + ) throws InterruptedException, IOException, UserException { + Map envVars = new HashMap<>(processInfo.envVars()); + Path tempDir = setupTempDir(processInfo, envVars.remove("ES_TMPDIR")); + if (envVars.containsKey("LIBFFI_TMPDIR") == false) { + envVars.put("LIBFFI_TMPDIR", tempDir.toString()); + } + + List jvmOptions = optionsBuilder.getJvmOptions(configDir, pluginsDir, tempDir, envVars.remove("ES_JAVA_OPTS")); + // also pass through distribution type + jvmOptions.add("-Des.distribution.type=" + processInfo.sysprops().get("es.distribution.type")); + + Path esHome = processInfo.workingDir(); + Path javaHome = PathUtils.get(processInfo.sysprops().get("java.home")); + List command = new ArrayList<>(); + boolean isWindows = processInfo.sysprops().get("os.name").startsWith("Windows"); + command.add(javaHome.resolve("bin").resolve("java" + (isWindows ? ".exe" : "")).toString()); + command.addAll(jvmOptions); + command.add("-cp"); + // The '*' isn't allowed by the windows filesystem, so we need to force it into the classpath after converting to a string. + // Thankfully this will all go away when switching to modules, which take the directory instead of a glob. + command.add(esHome.resolve("lib") + (isWindows ? "\\" : "/") + "*"); + command.add("org.elasticsearch.bootstrap.Elasticsearch"); + + var builder = new ProcessBuilder(command); + builder.environment().putAll(envVars); + builder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + + return processStarter.start(builder); + } + + /** + * Returns the java.io.tmpdir Elasticsearch should use, creating it if necessary. + * + *

On non-Windows OS, this will be created as a sub-directory of the default temporary directory. + * Note that this causes the created temporary directory to be a private temporary directory. + */ + private static Path setupTempDir(ProcessInfo processInfo, String tmpDirOverride) throws UserException, IOException { + final Path path; + if (tmpDirOverride != null) { + path = Paths.get(tmpDirOverride); + if (Files.exists(path) == false) { + throw new UserException(ExitCodes.CONFIG, "Temporary directory [" + path + "] does not exist or is not accessible"); + } + if (Files.isDirectory(path) == false) { + throw new UserException(ExitCodes.CONFIG, "Temporary directory [" + path + "] is not a directory"); + } + } else { + if (processInfo.sysprops().get("os.name").startsWith("Windows")) { + /* + * On Windows, we avoid creating a unique temporary directory per invocation lest + * we pollute the temporary directory. On other operating systems, temporary directories + * will be cleaned automatically via various mechanisms (e.g., systemd, or restarts). + */ + path = Paths.get(processInfo.sysprops().get("java.io.tmpdir"), "elasticsearch"); + Files.createDirectories(path); + } else { + path = createTempDirectory("elasticsearch-"); + } + } + return path; + } + + @SuppressForbidden(reason = "Files#createTempDirectory(String, FileAttribute...)") + private static Path createTempDirectory(final String prefix, final FileAttribute... attrs) throws IOException { + return Files.createTempDirectory(prefix, attrs); + } +} diff --git a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/TempDirectory.java b/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/TempDirectory.java deleted file mode 100644 index 18e3223ca0ae1..0000000000000 --- a/distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/TempDirectory.java +++ /dev/null @@ -1,48 +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 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 or the Server - * Side Public License, v 1. - */ - -package org.elasticsearch.server.cli; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; - -/** - * Provides a path for a temporary directory. On non-Windows OS, this will be created as a sub-directory of the default temporary directory. - * Note that this causes the created temporary directory to be a private temporary directory. - */ -final class TempDirectory { - - /** - * The main entry point. The exit code is 0 if we successfully created a temporary directory as a sub-directory of the default - * temporary directory and printed the resulting path to the console. - * - * @param args the args to the program which should be empty - * @throws IOException if an I/O exception occurred while creating the temporary directory - */ - public static void main(final String[] args) throws IOException { - if (args.length != 0) { - throw new IllegalArgumentException("expected zero arguments but was " + Arrays.toString(args)); - } - /* - * On Windows, we avoid creating a unique temporary directory per invocation lest we pollute the temporary directory. On other - * operating systems, temporary directories will be cleaned automatically via various mechanisms (e.g., systemd, or restarts). - */ - final Path path; - if (System.getProperty("os.name").startsWith("Windows")) { - path = Paths.get(System.getProperty("java.io.tmpdir"), "elasticsearch"); - Files.createDirectories(path); - } else { - path = Launchers.createTempDirectory("elasticsearch-"); - } - Launchers.outPrintln(path.toString()); - } - -} diff --git a/distribution/tools/server-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider b/distribution/tools/server-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider new file mode 100644 index 0000000000000..7a07f0225080a --- /dev/null +++ b/distribution/tools/server-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider @@ -0,0 +1 @@ +org.elasticsearch.server.cli.ServerCliProvider 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 new file mode 100644 index 0000000000000..471e32f13f921 --- /dev/null +++ b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerCliTests.java @@ -0,0 +1,350 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.server.cli; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import org.elasticsearch.Build; +import org.elasticsearch.bootstrap.ServerArgs; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.Terminal.Verbosity; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.monitor.jvm.JvmInfo; +import org.hamcrest.Matcher; +import org.junit.Before; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +public class ServerCliTests extends CommandTestCase { + + @Override + protected void assertUsage(Matcher matcher, String... args) throws Exception { + argsValidator = serverArgs -> fail("Should not have tried creating args on usage error"); + super.assertUsage(matcher, args); + } + + private void assertMutuallyExclusiveOptions(String... args) throws Exception { + assertUsage(allOf(containsString("ERROR:"), containsString("are unavailable given other options on the command line")), args); + } + + public void testVersion() throws Exception { + assertMutuallyExclusiveOptions("-V", "-d"); + assertMutuallyExclusiveOptions("-V", "--daemonize"); + assertMutuallyExclusiveOptions("-V", "-p", "/tmp/pid"); + assertMutuallyExclusiveOptions("-V", "--pidfile", "/tmp/pid"); + assertMutuallyExclusiveOptions("-V", "--enrollment-token", "mytoken"); + assertMutuallyExclusiveOptions("--version", "-d"); + assertMutuallyExclusiveOptions("--version", "--daemonize"); + assertMutuallyExclusiveOptions("--version", "-p", "/tmp/pid"); + assertMutuallyExclusiveOptions("--version", "--pidfile", "/tmp/pid"); + assertMutuallyExclusiveOptions("--version", "-q"); + assertMutuallyExclusiveOptions("--version", "--quiet"); + + final String expectedBuildOutput = String.format( + Locale.ROOT, + "Build: %s/%s/%s", + Build.CURRENT.type().displayName(), + Build.CURRENT.hash(), + Build.CURRENT.date() + ); + Matcher versionOutput = allOf( + containsString("Version: " + Build.CURRENT.qualifiedVersion()), + containsString(expectedBuildOutput), + containsString("JVM: " + JvmInfo.jvmInfo().version()) + ); + terminal.reset(); + assertOkWithOutput(versionOutput, emptyString(), "-V"); + terminal.reset(); + assertOkWithOutput(versionOutput, emptyString(), "--version"); + } + + public void testPositionalArgs() throws Exception { + String prefix = "Positional arguments not allowed, found "; + assertUsage(containsString(prefix + "[foo]"), "foo"); + assertUsage(containsString(prefix + "[foo, bar]"), "foo", "bar"); + assertUsage(containsString(prefix + "[foo]"), "-E", "foo=bar", "foo", "-E", "baz=qux"); + } + + public void testPidFile() throws Exception { + Path tmpDir = createTempDir(); + Path pidFileArg = tmpDir.resolve("pid"); + assertUsage(containsString("Option p/pidfile requires an argument"), "-p"); + argsValidator = args -> assertThat(args.pidFile().toString(), equalTo(pidFileArg.toString())); + terminal.reset(); + assertOk("-p", pidFileArg.toString()); + terminal.reset(); + assertOk("--pidfile", pidFileArg.toString()); + } + + 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); + } + + public void testQuiet() throws Exception { + AtomicBoolean expectQuiet = new AtomicBoolean(true); + argsValidator = args -> assertThat(args.quiet(), equalTo(expectQuiet.get())); + assertOk("-q"); + assertOk("--quiet"); + expectQuiet.set(false); + assertOk(); + } + + public void testElasticsearchSettings() throws Exception { + argsValidator = args -> { + Settings settings = args.nodeSettings(); + assertThat(settings.get("foo"), equalTo("bar")); + assertThat(settings.get("baz"), equalTo("qux")); + }; + assertOk("-Efoo=bar", "-E", "baz=qux"); + } + + public void testElasticsearchSettingCanNotBeEmpty() throws Exception { + assertUsage(containsString("setting [foo] must not be empty"), "-E", "foo="); + } + + public void testElasticsearchSettingCanNotBeDuplicated() throws Exception { + assertUsage(containsString("setting [foo] already set, saw [bar] and [baz]"), "-E", "foo=bar", "-E", "foo=baz"); + } + + public void testUnknownOption() throws Exception { + assertUsage(containsString("network.host is not a recognized option"), "--network.host"); + } + + public void testPathHome() throws Exception { + AtomicReference expectedHomeDir = new AtomicReference<>(); + expectedHomeDir.set(esHomeDir.toString()); + argsValidator = args -> { + Settings settings = args.nodeSettings(); + assertThat(settings.get("path.home"), equalTo(expectedHomeDir.get())); + assertThat(settings.keySet(), hasItem("path.logs")); // added by env initialization + }; + assertOk(); + sysprops.remove("es.path.home"); + final String commandLineValue = createTempDir().toString(); + expectedHomeDir.set(commandLineValue); + assertOk("-Epath.home=" + commandLineValue); + } + + public void testMissingEnrollmentToken() throws Exception { + assertUsage(containsString("Option enrollment-token requires an argument"), "--enrollment-token"); + } + + public void testMultipleEnrollmentTokens() throws Exception { + assertUsage( + containsString("Multiple --enrollment-token parameters are not allowed"), + "--enrollment-token", + "some-token", + "--enrollment-token", + "some-other-token" + ); + } + + public void testAutoConfigEnrollment() throws Exception { + autoConfigCallback = (t, options, env, processInfo) -> { + assertThat(options.valueOf("enrollment-token"), equalTo("mydummytoken")); + }; + assertOk("--enrollment-token", "mydummytoken"); + } + + public void testAutoConfigLogging() throws Exception { + autoConfigCallback = (t, options, env, processInfo) -> { + t.println("message from auto config"); + t.errorPrintln("error message"); + t.errorPrintln(Verbosity.VERBOSE, "verbose error"); + }; + assertOkWithOutput( + containsString("message from auto config"), + allOf(containsString("error message"), containsString("verbose error")), + "-v" + ); + } + + public void assertAutoConfigError(int autoConfigExitCode, int expectedMainExitCode, String... args) throws Exception { + terminal.reset(); + autoConfigCallback = (t, options, env, processInfo) -> { throw new UserException(autoConfigExitCode, "message from auto config"); }; + int gotMainExitCode = executeMain(args); + assertThat(gotMainExitCode, equalTo(expectedMainExitCode)); + assertThat(terminal.getErrorOutput(), containsString("message from auto config")); + } + + public void testAutoConfigErrorPropagated() throws Exception { + assertAutoConfigError(ExitCodes.IO_ERROR, ExitCodes.IO_ERROR); + terminal.reset(); + assertAutoConfigError(ExitCodes.CONFIG, ExitCodes.CONFIG, "--enrollment-token", "mytoken"); + terminal.reset(); + assertAutoConfigError(ExitCodes.DATA_ERROR, ExitCodes.DATA_ERROR, "--enrollment-token", "bogus"); + } + + public void testAutoConfigOkErrors() throws Exception { + assertAutoConfigError(ExitCodes.CANT_CREATE, ExitCodes.OK); + assertAutoConfigError(ExitCodes.CONFIG, ExitCodes.OK); + assertAutoConfigError(ExitCodes.NOOP, ExitCodes.OK); + } + + public void assertKeystorePassword(String password) throws Exception { + terminal.reset(); + boolean hasPassword = password != null && password.isEmpty() == false; + if (hasPassword) { + terminal.addSecretInput(password); + } + Path configDir = esHomeDir.resolve("config"); + Files.createDirectories(configDir); + if (hasPassword) { + try (KeyStoreWrapper keystore = KeyStoreWrapper.create()) { + keystore.save(configDir, password.toCharArray(), false); + } + } + String expectedPassword = password == null ? "" : password; + argsValidator = args -> assertThat(args.keystorePassword().toString(), equalTo(expectedPassword)); + autoConfigCallback = (t, options, env, processInfo) -> { + char[] gotPassword = t.readSecret(""); + assertThat(gotPassword, equalTo(expectedPassword.toCharArray())); + }; + assertOkWithOutput(emptyString(), hasPassword ? containsString("Enter password") : emptyString()); + } + + public void testKeystorePassword() throws Exception { + assertKeystorePassword(null); // no keystore exists + assertKeystorePassword(""); + assertKeystorePassword("dummypassword"); + } + + public void testCloseStopsServer() throws Exception { + Command command = newCommand(); + command.main(new String[0], terminal, new ProcessInfo(sysprops, envVars, esHomeDir)); + command.close(); + assertThat(mockServer.stopCalled, is(true)); + } + + interface AutoConfigMethod { + void autoconfig(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws UserException; + } + + Consumer argsValidator; + private final MockServerProcess mockServer = new MockServerProcess(); + + AutoConfigMethod autoConfigCallback; + private final MockAutoConfigCli AUTO_CONFIG_CLI = new MockAutoConfigCli(); + + @Before + public void resetCommand() { + argsValidator = null; + autoConfigCallback = null; + } + + private class MockAutoConfigCli extends EnvironmentAwareCommand { + private final OptionSpec enrollmentTokenOption; + + MockAutoConfigCli() { + super("mock auto config tool"); + enrollmentTokenOption = parser.accepts("enrollment-token").withRequiredArg(); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, ProcessInfo processInfo) throws Exception { + fail("Called wrong execute method, must call the one that takes already parsed env"); + } + + @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); + } + } + } + + private class MockServerProcess extends ServerProcess { + boolean detachCalled = false; + boolean waitForCalled = false; + boolean stopCalled = false; + + MockServerProcess() { + super(null, null); + } + + @Override + public void detach() { + assert detachCalled == false; + detachCalled = true; + } + + @Override + public void waitFor() { + assert waitForCalled == false; + waitForCalled = true; + } + + @Override + public void stop() { + assert stopCalled == false; + stopCalled = true; + } + + void reset() { + detachCalled = false; + waitForCalled = false; + stopCalled = false; + } + } + + @Override + protected Command newCommand() { + return new ServerCli() { + + @Override + protected Command loadTool(String toolname, String libs) { + assertThat(toolname, equalTo("auto-configure-node")); + assertThat(libs, equalTo("modules/x-pack-core,modules/x-pack-security,lib/tools/security-cli")); + return AUTO_CONFIG_CLI; + } + + @Override + protected ServerProcess startServer(Terminal terminal, ProcessInfo processInfo, ServerArgs args, Path pluginsDir) { + if (argsValidator != null) { + argsValidator.accept(args); + } + mockServer.reset(); + return mockServer; + } + }; + } + +} 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 new file mode 100644 index 0000000000000..91640e056ae64 --- /dev/null +++ b/distribution/tools/server-cli/src/test/java/org/elasticsearch/server/cli/ServerProcessTests.java @@ -0,0 +1,428 @@ +/* + * 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 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 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.SecureString; +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.server.cli.ProcessUtil.nonInterruptibleVoid; +import static org.hamcrest.Matchers.contains; +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; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +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; + Settings.Builder nodeSettings; + ServerProcess.OptionsBuilder optionsBuilder; + ProcessValidator processValidator; + MainMethod mainCallback; + MockElasticsearchProcess process; + + interface MainMethod { + void main(ServerArgs args, InputStream stdin, PrintStream stderr, AtomicInteger exitCode) throws IOException; + } + + interface ProcessValidator { + void validate(ProcessBuilder processBuilder) throws IOException; + } + + void runForeground() throws Exception { + var server = startProcess(false, false, null, ""); + server.waitFor(); + } + + @Before + public void resetEnv() { + terminal.reset(); + sysprops.clear(); + sysprops.put("os.name", "Linux"); + sysprops.put("java.home", "javahome"); + envVars.clear(); + esHomeDir = createTempDir(); + nodeSettings = Settings.builder(); + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> new ArrayList<>(); + processValidator = null; + mainCallback = null; + } + + @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); + if (mainCallback != null) { + try (var err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + mainCallback.main(serverArgs, stdin, err, exitCode); + } + } + } 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); + } + 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); + return this; + } + } + + ServerProcess startProcess(boolean daemonize, boolean quiet, Path pidFile, String keystorePassword) throws Exception { + var pinfo = new ProcessInfo(Map.copyOf(sysprops), Map.copyOf(envVars), esHomeDir); + SecureString password = new SecureString(keystorePassword.toCharArray()); + var args = new ServerArgs(daemonize, quiet, pidFile, password, nodeSettings.build(), esHomeDir.resolve("config")); + ServerProcess.ProcessStarter starter = pb -> { + if (processValidator != null) { + processValidator.validate(pb); + } + process = new MockElasticsearchProcess(); + return process; + }; + return ServerProcess.start(terminal, pinfo, args, esHomeDir.resolve("plugins"), optionsBuilder, 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(pb.directory(), nullValue()); // leave default, which is working directory + }; + mainCallback = (args, stdin, stderr, exitCode) -> { + try (PrintStream err = new PrintStream(stderr, true, StandardCharsets.UTF_8)) { + err.println("stderr message"); + } + }; + runForeground(); + assertThat(terminal.getErrorOutput(), containsString("stderr message")); + } + + public void testBootstrapError() throws Exception { + mainCallback = (args, stdin, stderr, exitCode) -> { + stderr.println(BootstrapInfo.USER_EXCEPTION_MARKER + "a bootstrap exception"); + exitCode.set(ExitCodes.CONFIG); + }; + var e = expectThrows(UserException.class, () -> runForeground()); + assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); + assertThat(e.getMessage(), equalTo("a bootstrap exception")); + } + + public void testUserError() throws Exception { + mainCallback = (args, stdin, stderr, exitCode) -> { + stderr.println(BootstrapInfo.USER_EXCEPTION_MARKER + "a user exception"); + exitCode.set(ExitCodes.USAGE); + }; + var e = expectThrows(UserException.class, () -> runForeground()); + assertThat(e.exitCode, equalTo(ExitCodes.USAGE)); + assertThat(e.getMessage(), equalTo("a user exception")); + } + + public void testStartError() throws Exception { + processValidator = pb -> { throw new IOException("something went wrong"); }; + var e = expectThrows(UncheckedIOException.class, () -> runForeground()); + assertThat(e.getCause().getMessage(), equalTo("something went wrong")); + } + + public void testOptionsBuildingInterrupted() throws Exception { + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> { + throw new InterruptedException("interrupted while get jvm options"); + }; + var e = expectThrows(RuntimeException.class, () -> runForeground()); + assertThat(e.getCause().getMessage(), equalTo("interrupted while get jvm options")); + } + + 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 testTempDir() throws Exception { + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> { + assertThat(tmpDir.toString(), Files.exists(tmpDir), is(true)); + assertThat(tmpDir.getFileName().toString(), startsWith("elasticsearch-")); + return new ArrayList<>(); + }; + runForeground(); + } + + public void testTempDirWindows() throws Exception { + Path baseTmpDir = createTempDir(); + sysprops.put("os.name", "Windows 10"); + sysprops.put("java.io.tmpdir", baseTmpDir.toString()); + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> { + assertThat(tmpDir.toString(), Files.exists(tmpDir), is(true)); + assertThat(tmpDir.getFileName().toString(), equalTo("elasticsearch")); + assertThat(tmpDir.getParent().toString(), equalTo(baseTmpDir.toString())); + return new ArrayList<>(); + }; + runForeground(); + } + + public void testTempDirOverride() throws Exception { + Path customTmpDir = createTempDir(); + envVars.put("ES_TMPDIR", customTmpDir.toString()); + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> { + assertThat(tmpDir.toString(), equalTo(customTmpDir.toString())); + return new ArrayList<>(); + }; + processValidator = pb -> assertThat(pb.environment(), not(hasKey("ES_TMPDIR"))); + runForeground(); + } + + public void testTempDirOverrideMissing() throws Exception { + Path baseDir = createTempDir(); + envVars.put("ES_TMPDIR", baseDir.resolve("dne").toString()); + var e = expectThrows(UserException.class, () -> runForeground()); + assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); + assertThat(e.getMessage(), containsString("dne] does not exist")); + } + + public void testTempDirOverrideNotADirectory() throws Exception { + Path tmpFile = createTempFile(); + envVars.put("ES_TMPDIR", tmpFile.toString()); + var e = expectThrows(UserException.class, () -> runForeground()); + assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); + assertThat(e.getMessage(), containsString("is not a directory")); + } + + public void testCustomJvmOptions() throws Exception { + envVars.put("ES_JAVA_OPTS", "-Dmyoption=foo"); + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> { + assertThat(envOptions, equalTo("-Dmyoption=foo")); + return new ArrayList<>(); + }; + processValidator = pb -> assertThat(pb.environment(), not(hasKey("ES_JAVA_OPTS"))); + runForeground(); + } + + public void testCommandLineSysprops() throws Exception { + optionsBuilder = (configDir, pluginsDir, tmpDir, envOptions) -> List.of("-Dfoo1=bar", "-Dfoo2=baz"); + processValidator = pb -> { + assertThat(pb.command(), contains("-Dfoo1=bar")); + assertThat(pb.command(), contains("-Dfoo2=bar")); + }; + } + + public void testCommandLine() throws Exception { + String mainClass = "org.elasticsearch.bootstrap.Elasticsearch"; + String distroSysprop = "-Des.distribution.type=testdistro"; + Path javaBin = Paths.get("javahome").resolve("bin"); + sysprops.put("es.distribution.type", "testdistro"); + AtomicReference expectedJava = new AtomicReference<>(javaBin.resolve("java").toString()); + AtomicReference expectedClasspath = new AtomicReference<>(esHomeDir.resolve("lib") + "/*"); + processValidator = pb -> { + assertThat(pb.command(), hasItems(expectedJava.get(), distroSysprop, "-cp", expectedClasspath.get(), mainClass)); + }; + runForeground(); + + sysprops.put("os.name", "Windows 10"); + sysprops.put("java.io.tmpdir", createTempDir().toString()); + expectedJava.set(javaBin.resolve("java.exe").toString()); + expectedClasspath.set(esHomeDir.resolve("lib") + "\\*"); + runForeground(); + } + + public void testDetach() throws Exception { + mainCallback = (args, stdin, stderr, exitCode) -> { + assertThat(args.daemonize(), equalTo(true)); + stderr.println(BootstrapInfo.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, null, ""); + 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(BootstrapInfo.SERVER_READY_MARKER); + nonInterruptibleVoid(mainReady::await); + stderr.println("final message"); + }; + var server = startProcess(false, false, null, ""); + mainReady.countDown(); + server.stop(); + assertThat(process.main.isDone(), is(true)); // stop should have waited + assertThat(terminal.getErrorOutput(), containsString("final message")); + } + + public void testWaitFor() throws Exception { + CountDownLatch mainReady = new CountDownLatch(1); + mainCallback = (args, stdin, stderr, exitCode) -> { + stderr.println(BootstrapInfo.SERVER_READY_MARKER); + mainReady.countDown(); + assertThat(stdin.read(), equalTo((int) BootstrapInfo.SERVER_SHUTDOWN_MARKER)); + stderr.println("final message"); + }; + var server = startProcess(false, false, null, ""); + new Thread(() -> { + // simulate stop run as shutdown hook in another thread, eg from Ctrl-C + nonInterruptibleVoid(mainReady::await); + server.stop(); + }).start(); + server.waitFor(); + assertThat(process.main.isDone(), is(true)); + assertThat(terminal.getErrorOutput(), containsString("final message")); + } + + public void testProcessDies() throws Exception { + CountDownLatch mainReady = new CountDownLatch(1); + CountDownLatch mainExit = new CountDownLatch(1); + mainCallback = (args, stdin, stderr, exitCode) -> { + stderr.println(BootstrapInfo.SERVER_READY_MARKER); + mainReady.countDown(); + stderr.println("fatal message"); + nonInterruptibleVoid(mainExit::await); + exitCode.set(-9); + }; + var server = startProcess(false, false, null, ""); + nonInterruptibleVoid(mainReady::await); + process.processStderr.close(); // mimic pipe break if cli process dies + mainExit.countDown(); + var e = expectThrows(RuntimeException.class, server::waitFor); + assertThat(e.getMessage(), equalTo("server process exited with status code -9")); + assertThat(terminal.getErrorOutput(), containsString("fatal message")); + } +} diff --git a/distribution/tools/windows-service-cli/build.gradle b/distribution/tools/windows-service-cli/build.gradle new file mode 100644 index 0000000000000..103e5322913b9 --- /dev/null +++ b/distribution/tools/windows-service-cli/build.gradle @@ -0,0 +1,9 @@ +apply plugin: 'elasticsearch.java' + +dependencies { + compileOnly project(":server") + compileOnly project(":libs:elasticsearch-cli") + compileOnly project(":distribution:tools:server-cli") + + testImplementation project(":test:framework") +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/ProcrunCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/ProcrunCommand.java new file mode 100644 index 0000000000000..c10495d3b8af6 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/ProcrunCommand.java @@ -0,0 +1,150 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import joptsimple.OptionSet; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.util.Supplier; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Base command for interacting with Apache procrun executable. + * + * @see Apache Procrun Docs + */ +abstract class ProcrunCommand extends Command { + private static final Logger logger = LogManager.getLogger(ProcrunCommand.class); + + private final String cmd; + + /** + * Constructs CLI subcommand that will internally call procrun. + * @param desc A help description for this subcommand + * @param cmd The procrun command to run + */ + protected ProcrunCommand(String desc, String cmd) { + super(desc); + this.cmd = cmd; + } + + /** + * Returns the name of the exe within the Elasticsearch bin dir to run. + * + *

Procrun comes with two executables, {@code prunsrv.exe} and {@code prunmgr.exe}. These are renamed by + * Elasticsearch to {@code elasticsearch-service-x64.exe} and {@code elasticsearch-service-mgr.exe}, respectively. + */ + protected String getExecutable() { + return "elasticsearch-service-x64.exe"; + } + + @Override + protected void execute(Terminal terminal, OptionSet options, ProcessInfo processInfo) throws Exception { + Path procrun = processInfo.workingDir().resolve("bin").resolve(getExecutable()).toAbsolutePath(); + if (Files.exists(procrun) == false) { + throw new IllegalStateException("Missing procrun exe: " + procrun); + } + String serviceId = getServiceId(options, processInfo.envVars()); + preExecute(terminal, processInfo, serviceId); + + List procrunCmd = new ArrayList<>(); + procrunCmd.add(procrun.toString()); + procrunCmd.add("//%s/%s".formatted(cmd, serviceId)); + if (includeLogArgs()) { + procrunCmd.add(getLogArgs(serviceId, processInfo.workingDir(), processInfo.envVars())); + } + procrunCmd.add(getAdditionalArgs(serviceId, processInfo)); + + ProcessBuilder processBuilder = new ProcessBuilder("cmd.exe", "/C", String.join(" ", procrunCmd).trim()); + logger.debug((Supplier) () -> "Running procrun: " + String.join(" ", processBuilder.command())); + processBuilder.inheritIO(); + Process process = startProcess(processBuilder); + int ret = process.waitFor(); + if (ret != ExitCodes.OK) { + throw new UserException(ret, getFailureMessage(serviceId)); + } else { + terminal.println(getSuccessMessage(serviceId)); + } + } + + /** Determines the service id for the Elasticsearch service that should be used */ + private String getServiceId(OptionSet options, Map env) throws UserException { + List args = options.nonOptionArguments(); + if (args.size() > 1) { + throw new UserException(ExitCodes.USAGE, "too many arguments, expected one service id"); + } + final String serviceId; + if (args.size() > 0) { + serviceId = args.get(0).toString(); + } else { + serviceId = env.getOrDefault("SERVICE_ID", "elasticsearch-service-x64"); + } + return serviceId; + } + + /** Determines the logging arguments that should be passed to the procrun command */ + private String getLogArgs(String serviceId, Path esHome, Map env) { + String logArgs = env.get("LOG_OPTS"); + if (logArgs != null && logArgs.isBlank() == false) { + return logArgs; + } + String logsDir = env.get("SERVICE_LOG_DIR"); + if (logsDir == null || logsDir.isBlank()) { + logsDir = esHome.resolve("logs").toString(); + } + String logArgsFormat = "--LogPath \"%s\" --LogPrefix \"%s\" --StdError auto --StdOutput auto --LogLevel Debug"; + return String.format(Locale.ROOT, logArgsFormat, logsDir, serviceId); + } + + /** + * Gets arguments that should be passed to the procrun command. + * + * @param serviceId The service id of the Elasticsearch service + * @param processInfo The current process info + * @return The additional arguments, space delimited + */ + protected String getAdditionalArgs(String serviceId, ProcessInfo processInfo) { + return ""; + } + + /** Return whether logging args should be added to the procrun command */ + protected boolean includeLogArgs() { + return true; + } + + /** + * A hook to add logging and validation before executing the procrun command. + * @throws UserException if there is a problem with the command invocation + */ + protected void preExecute(Terminal terminal, ProcessInfo pinfo, String serviceId) throws UserException {} + + /** Returns a message that should be output on success of the procrun command */ + protected abstract String getSuccessMessage(String serviceId); + + /** Returns a message that should be output on failure of the procrun command */ + protected abstract String getFailureMessage(String serviceId); + + // package private to allow tests to override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return processBuilder.start(); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCli.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCli.java new file mode 100644 index 0000000000000..6f9a7e6b2169f --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCli.java @@ -0,0 +1,27 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.MultiCommand; + +/** + * A CLI for managing Elasticsearch as a Windows Service. + */ +class WindowsServiceCli extends MultiCommand { + + WindowsServiceCli() { + super("A tool for managing Elasticsearch as a Windows service"); + subcommands.put("install", new WindowsServiceInstallCommand()); + subcommands.put("remove", new WindowsServiceRemoveCommand()); + subcommands.put("start", new WindowsServiceStartCommand()); + subcommands.put("stop", new WindowsServiceStopCommand()); + subcommands.put("manager", new WindowsServiceManagerCommand()); + } + +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCliProvider.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCliProvider.java new file mode 100644 index 0000000000000..1b1f8d08c9805 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceCliProvider.java @@ -0,0 +1,27 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.CliToolProvider; +import org.elasticsearch.cli.Command; + +/** + * Provides a tool for managing an Elasticsearch service on Windows + */ +public class WindowsServiceCliProvider implements CliToolProvider { + @Override + public String name() { + return "windows-service"; + } + + @Override + public Command create() { + return new WindowsServiceCli(); + } +} 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 new file mode 100644 index 0000000000000..ebd2e74fddf43 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemon.java @@ -0,0 +1,48 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import joptsimple.OptionSet; + +import org.elasticsearch.bootstrap.ServerArgs; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.env.Environment; +import org.elasticsearch.server.cli.ServerProcess; + +/** + * Starts an Elasticsearch process, but does not wait for it to exit. + * + * This class is expected to be run via Apache Procrun in a long lived JVM that will call close + * when the server should shutdown. + */ +class WindowsServiceDaemon extends EnvironmentAwareCommand { + + private volatile ServerProcess server; + + WindowsServiceDaemon() { + super("Starts and stops the Elasticsearch server process for a Windows Service"); + } + + @Override + public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception { + var args = new ServerArgs(false, true, null, new SecureString(""), env.settings(), env.configFile()); + this.server = ServerProcess.start(terminal, processInfo, args, env.pluginsFile()); + // start does not return until the server is ready, and we do not wait for the process + } + + @Override + public void close() { + if (server != null) { + server.stop(); + } + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemonProvider.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemonProvider.java new file mode 100644 index 0000000000000..a07883dcffcec --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceDaemonProvider.java @@ -0,0 +1,24 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.CliToolProvider; +import org.elasticsearch.cli.Command; + +public class WindowsServiceDaemonProvider implements CliToolProvider { + @Override + public String name() { + return "windows-service-daemon"; + } + + @Override + public Command create() { + return new WindowsServiceDaemon(); + } +} 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 new file mode 100644 index 0000000000000..4e6e2cddfeb93 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceInstallCommand.java @@ -0,0 +1,151 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.core.SuppressForbidden; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Elasticsearch the Elasticsearch Windows service into the Windows Service Registry. + */ +class WindowsServiceInstallCommand extends ProcrunCommand { + WindowsServiceInstallCommand() { + super("Install Elasticsearch as a Windows Service", "IS"); + } + + @Override + protected String getAdditionalArgs(String serviceId, ProcessInfo pinfo) { + List args = new ArrayList<>(); + addArg(args, "--Startup", pinfo.envVars().getOrDefault("ES_START_TYPE", "manual")); + addArg(args, "--StopTimeout", pinfo.envVars().getOrDefault("ES_STOP_TIMEOUT", "0")); + addArg(args, "--StartClass", "org.elasticsearch.launcher.CliToolLauncher"); + addArg(args, "--StartMethod", "main"); + addArg(args, "--StopClass", "org.elasticsearch.launcher.CliToolLauncher"); + addArg(args, "--StopMethod", "close"); + addArg(args, "--Classpath", pinfo.sysprops().get("java.class.path")); + addArg(args, "--JvmMs", "4m"); + addArg(args, "--JvmMx", "64m"); + addArg(args, "--JvmOptions", getJvmOptions(pinfo.sysprops())); + addArg(args, "--PidFile", "%s.pid".formatted(serviceId)); + addArg( + args, + "--DisplayName", + pinfo.envVars().getOrDefault("SERVICE_DISPLAY_NAME", "Elasticsearch %s (%s)".formatted(Version.CURRENT, serviceId)) + ); + addArg( + args, + "--Description", + pinfo.envVars() + .getOrDefault("SERVICE_DESCRIPTION", "Elasticsearch %s Windows Service - https://elastic.co".formatted(Version.CURRENT)) + ); + addArg(args, "--Jvm", getJvmDll(getJavaHome(pinfo.sysprops())).toString()); + addArg(args, "--StartMode", "jvm"); + addArg(args, "--StopMode", "jvm"); + addArg(args, "--StartPath", 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, "++Environment", "HOSTNAME=%s".formatted(pinfo.envVars().get("COMPUTERNAME"))); + + String serviceUsername = pinfo.envVars().get("SERVICE_USERNAME"); + if (serviceUsername != null) { + String servicePassword = pinfo.envVars().get("SERVICE_PASSWORD"); + assert servicePassword != null; // validated in preExecute + addArg(args, "--ServiceUser", serviceUsername); + addArg(args, "--ServicePassword", servicePassword); + } else { + addArg(args, "--ServiceUser", "LocalSystem"); + } + + String serviceParams = pinfo.envVars().get("SERVICE_PARAMS"); + if (serviceParams != null) { + args.add(serviceParams); + } + + return String.join(" ", args); + } + + private static void addArg(List args, String arg, String value) { + args.add(arg); + if (value.contains(" ")) { + value = "\"%s\"".formatted(value); + } + args.add(value); + } + + @SuppressForbidden(reason = "get java home path to pass through") + private static Path getJavaHome(Map sysprops) { + return Paths.get(sysprops.get("java.home")); + } + + private static Path getJvmDll(Path javaHome) { + Path dll = javaHome.resolve("jre/bin/server/jvm.dll"); + if (Files.exists(dll) == false) { + dll = javaHome.resolve("bin/server/jvm.dll"); + } + return dll; + } + + private static String getJvmOptions(Map sysprops) { + List jvmOptions = new ArrayList<>(); + jvmOptions.add("-XX:+UseSerialGC"); + // passthrough these properties + for (var prop : List.of("es.path.home", "es.path.conf", "es.distribution.type")) { + jvmOptions.add("-D%s=%s".formatted(prop, sysprops.get(prop))); + } + return String.join(";", jvmOptions); + } + + @Override + protected void preExecute(Terminal terminal, ProcessInfo pinfo, String serviceId) throws UserException { + Path javaHome = getJavaHome(pinfo.sysprops()); + terminal.println("Installing service : %s".formatted(serviceId)); + terminal.println("Using ES_JAVA_HOME : %s".formatted(javaHome.toString())); + + Path javaDll = getJvmDll(javaHome); + if (Files.exists(javaDll) == false) { + throw new UserException( + ExitCodes.CONFIG, + "Invalid java installation (no jvm.dll found in %s\\jre\\bin\\server\\ or %s\\bin\\server\"). Exiting...".formatted( + javaHome.toString(), + javaHome.toString() + ) + ); + } + + // validate username and password come together + boolean hasUsername = pinfo.envVars().containsKey("SERVICE_USERNAME"); + if (pinfo.envVars().containsKey("SERVICE_PASSWORD") != hasUsername) { + throw new UserException( + ExitCodes.CONFIG, + "Both service username and password must be set, only got " + (hasUsername ? "SERVICE_USERNAME" : "SERVICE_PASSWORD") + ); + } + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "The service '%s' has been installed".formatted(serviceId); + } + + @Override + protected String getFailureMessage(String serviceId) { + return "Failed installing '%s' service".formatted(serviceId); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceManagerCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceManagerCommand.java new file mode 100644 index 0000000000000..a0c3a4e11e208 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceManagerCommand.java @@ -0,0 +1,38 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +/** + * Runs the procrun GUI manager for the Elasticsearch Windows service. + */ +class WindowsServiceManagerCommand extends ProcrunCommand { + WindowsServiceManagerCommand() { + super("Starts the Elasticsearch Windows Service manager", "ES"); + } + + @Override + protected String getExecutable() { + return "elasticsearch-service-mgr.exe"; + } + + @Override + protected boolean includeLogArgs() { + return false; + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "Successfully started service manager for '%s'".formatted(serviceId); + } + + @Override + protected String getFailureMessage(String serviceId) { + return "Failed starting service manager for '%s'".formatted(serviceId); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommand.java new file mode 100644 index 0000000000000..c9df3639fbf38 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommand.java @@ -0,0 +1,28 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +/** + * Removes the Elasticsearch Windows service, first stopping it if it is running. + */ +class WindowsServiceRemoveCommand extends ProcrunCommand { + WindowsServiceRemoveCommand() { + super("Remove the Elasticsearch Windows Service", "DS"); + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "The service '%s' has been removed".formatted(serviceId); + } + + @Override + protected String getFailureMessage(String serviceId) { + return "Failed removing '%s' service".formatted(serviceId); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStartCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStartCommand.java new file mode 100644 index 0000000000000..8f048fb39045e --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStartCommand.java @@ -0,0 +1,28 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +/** + * Starts the Elasticsearch Windows service. + */ +class WindowsServiceStartCommand extends ProcrunCommand { + WindowsServiceStartCommand() { + super("Starts the Elasticsearch Windows Service", "ES"); + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "The service '%s' has been started".formatted(serviceId); + } + + @Override + protected String getFailureMessage(String serviceId) { + return "Failed starting '%s' service".formatted(serviceId); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStopCommand.java b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStopCommand.java new file mode 100644 index 0000000000000..7b880f501c6ae --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/java/org/elasticsearch/windows/service/WindowsServiceStopCommand.java @@ -0,0 +1,28 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +/** + * Stops the Elasticsearch Windows service. + */ +class WindowsServiceStopCommand extends ProcrunCommand { + WindowsServiceStopCommand() { + super("Stops the Elasticsearch Windows Service", "SS"); + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "The service '%s' has been stopped".formatted(serviceId); + } + + @Override + protected String getFailureMessage(String serviceId) { + return "Failed stopping '%s' service".formatted(serviceId); + } +} diff --git a/distribution/tools/windows-service-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider b/distribution/tools/windows-service-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider new file mode 100644 index 0000000000000..49d8b3d9cb0a6 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/main/resources/META-INF/services/org.elasticsearch.cli.CliToolProvider @@ -0,0 +1,2 @@ +org.elasticsearch.windows.service.WindowsServiceCliProvider +org.elasticsearch.windows.service.WindowsServiceDaemonProvider diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/ProcrunCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/ProcrunCommandTests.java new file mode 100644 index 0000000000000..b683884a37571 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/ProcrunCommandTests.java @@ -0,0 +1,162 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.ProcessInfo; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.junit.Before; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; + +public class ProcrunCommandTests extends WindowsServiceCliTestCase { + + PreExecuteHook preExecuteHook; + boolean includeLogArgs; + String additionalArgs; + String serviceId; + + interface PreExecuteHook { + void preExecute(Terminal terminal, ProcessInfo pinfo, String serviceId) throws UserException; + } + + @Before + public void resetArgs() { + serviceId = "elasticsearch-service-x64"; + preExecuteHook = null; + includeLogArgs = false; + additionalArgs = ""; + } + + class TestProcrunCommand extends ProcrunCommand { + + protected TestProcrunCommand() { + super("test command", "DC"); + } + + @Override + protected void preExecute(Terminal terminal, ProcessInfo pinfo, String serviceId) throws UserException { + if (preExecuteHook != null) { + preExecuteHook.preExecute(terminal, pinfo, serviceId); + } + } + + protected String getAdditionalArgs(String serviceId, ProcessInfo processInfo) { + return additionalArgs; + } + + @Override + protected boolean includeLogArgs() { + return includeLogArgs; + } + + @Override + protected String getSuccessMessage(String serviceId) { + return "success message for " + serviceId; + } + + @Override + protected String getFailureMessage(String serviceId) { + return "failure message for " + serviceId; + } + + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + } + + @Override + protected Command newCommand() { + return new TestProcrunCommand(); + } + + @Override + protected boolean includeLogsArgs() { + return includeLogArgs; + } + + @Override + protected String getCommand() { + return "DC"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "success message for " + serviceId; + } + + @Override + protected String getDefaultFailureMessage() { + return "failure message for " + serviceId; + } + + public void testMissingExe() throws Exception { + Files.delete(serviceExe); + var e = expectThrows(IllegalStateException.class, () -> executeMain("install")); + assertThat(e.getMessage(), containsString("Missing procrun exe")); + } + + public void testServiceId() throws Exception { + assertUsage(containsString("too many arguments"), "servicename", "servicename"); + terminal.reset(); + preExecuteHook = (terminal, pinfo, serviceId) -> assertThat(serviceId, equalTo("my-service-id")); + assertOkWithOutput(containsString("success"), emptyString(), "my-service-id"); + terminal.reset(); + envVars.put("SERVICE_ID", "my-service-id"); + assertOkWithOutput(containsString("success"), emptyString()); + } + + public void testPreExecuteError() throws Exception { + preExecuteHook = (terminal, pinfo, serviceId) -> { throw new UserException(ExitCodes.USAGE, "validation error"); }; + assertUsage(containsString("validation error")); + } + + void assertLogArgs(Map logArgs) throws Exception { + terminal.reset(); + includeLogArgs = true; + assertServiceArgs(logArgs); + } + + public void testDefaultLogArgs() throws Exception { + String logsDir = esHomeDir.resolve("logs").toString(); + assertLogArgs( + Map.of("LogPath", "\"" + logsDir + "\"", "LogPrefix", "\"elasticsearch-service-x64\"", "StdError", "auto", "StdOutput", "auto") + ); + } + + public void testLogOpts() throws Exception { + envVars.put("LOG_OPTS", "--LogPath custom"); + assertLogArgs(Map.of("LogPath", "custom")); + } + + public void testLogDir() throws Exception { + envVars.put("SERVICE_LOG_DIR", "mylogdir"); + assertLogArgs(Map.of("LogPath", "\"mylogdir\"")); + } + + public void testLogPrefix() throws Exception { + serviceId = "myservice"; + envVars.put("SERVICE_ID", "myservice"); + assertLogArgs(Map.of("LogPrefix", "\"myservice\"")); + } + + public void testAdditionalArgs() throws Exception { + additionalArgs = "--Foo bar"; + assertServiceArgs(Map.of("Foo", "bar")); + } +} diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceCliTestCase.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceCliTestCase.java new file mode 100644 index 0000000000000..b727774ea2d1d --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceCliTestCase.java @@ -0,0 +1,208 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.CommandTestCase; +import org.junit.Before; + +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.lang.ProcessBuilder.Redirect.INHERIT; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +public abstract class WindowsServiceCliTestCase extends CommandTestCase { + + Path javaHome; + Path binDir; + Path serviceExe; + Path mgrExe; + int mockProcessExit = 0; + ProcessValidator mockProcessValidator = null; + + interface ProcessValidator { + void validate(Map env, ProcrunCall procrunCall); + } + + record ProcrunCall(String exe, String command, String serviceId, Map> args) {} + + class MockProcess extends Process { + + @Override + public OutputStream getOutputStream() { + throw new AssertionError("should not access output stream"); + } + + @Override + public InputStream getInputStream() { + throw new AssertionError("should not access input stream"); + } + + @Override + public InputStream getErrorStream() { + throw new AssertionError("should not access error stream"); + } + + @Override + public int waitFor() { + return mockProcessExit; + } + + @Override + public int exitValue() { + return mockProcessExit; + } + + @Override + public void destroy() { + throw new AssertionError("should not kill procrun process"); + } + } + + protected Process mockProcess(ProcessBuilder processBuilder) throws IOException { + assertThat(processBuilder.redirectInput(), equalTo(INHERIT)); + assertThat(processBuilder.redirectOutput(), equalTo(INHERIT)); + assertThat(processBuilder.redirectError(), equalTo(INHERIT)); + if (mockProcessValidator != null) { + var fullCommand = processBuilder.command(); + assertThat(fullCommand, hasSize(3)); + assertThat(fullCommand.get(0), equalTo("cmd.exe")); + assertThat(fullCommand.get(1), equalTo("/C")); + ProcrunCall procrunCall = parseProcrunCall(fullCommand.get(2)); + mockProcessValidator.validate(processBuilder.environment(), procrunCall); + } + return new MockProcess(); + } + + // args could have spaces in them, so splitting on string alone is not enough + // instead we look for the next --Foo and reconstitute the argument following it + private static final Pattern commandPattern = Pattern.compile("//([A-Z]{2})/([\\w-]+)"); + + private static ProcrunCall parseProcrunCall(String unparsedArgs) { + String[] splitArgs = unparsedArgs.split(" "); + assertThat(unparsedArgs, splitArgs.length, greaterThanOrEqualTo(2)); + Map> args = new HashMap<>(); + String exe = splitArgs[0]; + Matcher commandMatcher = commandPattern.matcher(splitArgs[1]); + assertThat(splitArgs[1], commandMatcher.matches(), is(true)); + String command = commandMatcher.group(1); + String serviceId = commandMatcher.group(2); + + int i = 2; + while (i < splitArgs.length) { + String arg = splitArgs[i]; + assertThat("procrun args begin with -- or ++", arg, anyOf(startsWith("--"), startsWith("++"))); + ++i; + assertThat("missing value for arg " + arg, i, lessThan(splitArgs.length)); + + List argValue = new ArrayList<>(); + while (i < splitArgs.length && splitArgs[i].startsWith("--") == false && splitArgs[i].startsWith("++") == false) { + argValue.add(splitArgs[i++]); + } + + String key = arg.substring(2); + args.compute(key, (k, value) -> { + if (arg.startsWith("--")) { + assertThat("overwriting existing arg: " + key, value, nullValue()); + } + if (value == null) { + // could be ++ implicitly creating new list, or -- above + value = new ArrayList<>(); + } + value.add(String.join(" ", argValue)); + return value; + }); + } + + return new ProcrunCall(exe, command, serviceId, args); + } + + @Before + public void resetMockProcess() throws Exception { + javaHome = createTempDir(); + Path javaBin = javaHome.resolve("bin"); + sysprops.put("java.home", javaHome.toString()); + binDir = esHomeDir.resolve("bin"); + Files.createDirectories(binDir); + serviceExe = binDir.resolve("elasticsearch-service-x64.exe"); + Files.createFile(serviceExe); + mgrExe = binDir.resolve("elasticsearch-service-mgr.exe"); + Files.createFile(mgrExe); + mockProcessExit = 0; + mockProcessValidator = null; + } + + protected abstract String getCommand(); + + protected abstract String getDefaultSuccessMessage(); + + protected abstract String getDefaultFailureMessage(); + + protected String getExe() { + return serviceExe.toString(); + } + + protected boolean includeLogsArgs() { + return true; + } + + public void testDefaultCommand() throws Exception { + mockProcessValidator = (environment, procrunCall) -> { + assertThat(procrunCall.exe, equalTo(getExe())); + assertThat(procrunCall.command, equalTo(getCommand())); + assertThat(procrunCall.serviceId, equalTo("elasticsearch-service-x64")); + if (includeLogsArgs()) { + assertThat(procrunCall.args, hasKey("LogPath")); + } else { + assertThat(procrunCall.args, not(hasKey("LogPath"))); + } + }; + assertOkWithOutput(containsString(getDefaultSuccessMessage()), emptyString()); + } + + public void testFailure() throws Exception { + mockProcessExit = 5; + assertThat(executeMain(), equalTo(5)); + assertThat(terminal.getErrorOutput(), containsString(getDefaultFailureMessage())); + } + + // for single value args + protected void assertServiceArgs(Map expectedArgs) throws Exception { + mockProcessValidator = (environment, procrunCall) -> { + for (var expected : expectedArgs.entrySet()) { + List value = procrunCall.args.get(expected.getKey()); + assertThat("missing arg " + expected.getKey(), value, notNullValue()); + assertThat(value.toString(), value, hasSize(1)); + assertThat(value.get(0), equalTo(expected.getValue())); + } + }; + assertOkWithOutput(containsString(getDefaultSuccessMessage()), emptyString()); + } +} 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 new file mode 100644 index 0000000000000..ffd0e16fd6f79 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceInstallCommandTests.java @@ -0,0 +1,188 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.ExitCodes; +import org.junit.Before; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.any; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; + +public class WindowsServiceInstallCommandTests extends WindowsServiceCliTestCase { + + Path jvmDll; + + @Before + public void setupJvm() throws Exception { + jvmDll = javaHome.resolve("jre/bin/server/jvm.dll"); + Files.createDirectories(jvmDll.getParent()); + Files.createFile(jvmDll); + sysprops.put("java.class.path", "javaclasspath"); + envVars.put("COMPUTERNAME", "mycomputer"); + } + + @Override + protected Command newCommand() { + return new WindowsServiceInstallCommand() { + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + }; + } + + @Override + protected String getCommand() { + return "IS"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "The service 'elasticsearch-service-x64' has been installed"; + } + + @Override + protected String getDefaultFailureMessage() { + return "Failed installing 'elasticsearch-service-x64' service"; + } + + public void testDllMissing() throws Exception { + Files.delete(jvmDll); + assertThat(executeMain(), equalTo(ExitCodes.CONFIG)); + assertThat(terminal.getErrorOutput(), containsString("Invalid java installation (no jvm.dll")); + } + + public void testAlternateDllLocation() throws Exception { + Files.delete(jvmDll); + Path altJvmDll = javaHome.resolve("bin/server/jvm.dll"); + Files.createDirectories(altJvmDll.getParent()); + Files.createFile(altJvmDll); + assertServiceArgs(Map.of()); + } + + public void testDll() throws Exception { + assertServiceArgs(Map.of("Jvm", jvmDll.toString())); + } + + public void testPreExecuteOutput() throws Exception { + envVars.put("SERVICE_ID", "myservice"); + assertOkWithOutput( + allOf(containsString("Installing service : myservice"), containsString("Using ES_JAVA_HOME : " + javaHome)), + emptyString() + ); + } + + public void testJvmOptions() throws Exception { + sysprops.put("es.distribution.type", "testdistro"); + List expectedOptions = List.of( + "" + "-XX:+UseSerialGC", + "-Des.path.home=" + esHomeDir.toString(), + "-Des.path.conf=" + esHomeDir.resolve("config").toString(), + "-Des.distribution.type=testdistro" + ); + mockProcessValidator = (environment, procrunCall) -> { + List options = procrunCall.args().get("JvmOptions"); + assertThat( + options, + containsInAnyOrder( + "-Dcli.name=windows-service-daemon", + "-Dcli.libs=lib/tools/server-cli,lib/tools/windows-service-cli", + String.join(";", expectedOptions) + ) + ); + }; + assertOkWithOutput(any(String.class), emptyString()); + } + + public void testStartupType() throws Exception { + assertServiceArgs(Map.of("Startup", "manual")); + envVars.put("ES_START_TYPE", "auto"); + assertServiceArgs(Map.of("Startup", "auto")); + } + + public void testStopTimeout() throws Exception { + assertServiceArgs(Map.of("StopTimeout", "0")); + envVars.put("ES_STOP_TIMEOUT", "5"); + assertServiceArgs(Map.of("StopTimeout", "5")); + } + + public void testFixedArgs() throws Exception { + assertServiceArgs( + Map.ofEntries( + entry("StartClass", "org.elasticsearch.launcher.CliToolLauncher"), + entry("StartMethod", "main"), + entry("StartMode", "jvm"), + entry("StopClass", "org.elasticsearch.launcher.CliToolLauncher"), + entry("StopMethod", "close"), + entry("StopMode", "jvm"), + entry("JvmMs", "4m"), + entry("JvmMx", "64m"), + entry("StartPath", esHomeDir.toString()), + entry("Classpath", "javaclasspath") // dummy value for tests + ) + ); + } + + public void testPidFile() throws Exception { + assertServiceArgs(Map.of("PidFile", "elasticsearch-service-x64.pid")); + envVars.put("SERVICE_ID", "myservice"); + assertServiceArgs(Map.of("PidFile", "myservice.pid")); + } + + public void testDisplayName() throws Exception { + assertServiceArgs(Map.of("DisplayName", "\"Elasticsearch %s (elasticsearch-service-x64)\"".formatted(Version.CURRENT))); + envVars.put("SERVICE_DISPLAY_NAME", "my service name"); + assertServiceArgs(Map.of("DisplayName", "\"my service name\"")); + } + + public void testDescription() throws Exception { + String defaultDescription = "\"Elasticsearch %s Windows Service - https://elastic.co\"".formatted(Version.CURRENT); + assertServiceArgs(Map.of("Description", defaultDescription)); + envVars.put("SERVICE_DESCRIPTION", "my description"); + assertServiceArgs(Map.of("Description", "\"my description\"")); + } + + public void testUsernamePassword() throws Exception { + assertServiceArgs(Map.of("ServiceUser", "LocalSystem")); + + terminal.reset(); + envVars.put("SERVICE_USERNAME", "myuser"); + assertThat(executeMain(), equalTo(ExitCodes.CONFIG)); + assertThat(terminal.getErrorOutput(), containsString("Both service username and password must be set")); + + terminal.reset(); + envVars.remove("SERVICE_USERNAME"); + envVars.put("SERVICE_PASSWORD", "mypassword"); + assertThat(executeMain(), equalTo(ExitCodes.CONFIG)); + assertThat(terminal.getErrorOutput(), containsString("Both service username and password must be set")); + + terminal.reset(); + envVars.put("SERVICE_USERNAME", "myuser"); + envVars.put("SERVICE_PASSWORD", "mypassword"); + assertServiceArgs(Map.of("ServiceUser", "myuser", "ServicePassword", "mypassword")); + } + + public void testExtraServiceParams() throws Exception { + envVars.put("SERVICE_PARAMS", "--MyExtraArg \"and value\""); + assertServiceArgs(Map.of("MyExtraArg", "\"and value\"")); + } +} diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceManagerCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceManagerCommandTests.java new file mode 100644 index 0000000000000..cd3aea949f0f6 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceManagerCommandTests.java @@ -0,0 +1,50 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.Command; + +import java.io.IOException; + +public class WindowsServiceManagerCommandTests extends WindowsServiceCliTestCase { + @Override + protected Command newCommand() { + return new WindowsServiceManagerCommand() { + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + }; + } + + @Override + protected String getExe() { + return mgrExe.toString(); + } + + @Override + protected boolean includeLogsArgs() { + return false; + } + + @Override + protected String getCommand() { + return "ES"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "Successfully started service manager for 'elasticsearch-service-x64'"; + } + + @Override + protected String getDefaultFailureMessage() { + return "Failed starting service manager for 'elasticsearch-service-x64'"; + } +} diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommandTests.java new file mode 100644 index 0000000000000..3d2032d75a195 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceRemoveCommandTests.java @@ -0,0 +1,40 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.Command; + +import java.io.IOException; + +public class WindowsServiceRemoveCommandTests extends WindowsServiceCliTestCase { + @Override + protected Command newCommand() { + return new WindowsServiceRemoveCommand() { + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + }; + } + + @Override + protected String getCommand() { + return "DS"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "The service 'elasticsearch-service-x64' has been removed"; + } + + @Override + protected String getDefaultFailureMessage() { + return "Failed removing 'elasticsearch-service-x64' service"; + } +} diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStartCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStartCommandTests.java new file mode 100644 index 0000000000000..7a30540d53ba0 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStartCommandTests.java @@ -0,0 +1,40 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.Command; + +import java.io.IOException; + +public class WindowsServiceStartCommandTests extends WindowsServiceCliTestCase { + @Override + protected Command newCommand() { + return new WindowsServiceStartCommand() { + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + }; + } + + @Override + protected String getCommand() { + return "ES"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "The service 'elasticsearch-service-x64' has been started"; + } + + @Override + protected String getDefaultFailureMessage() { + return "Failed starting 'elasticsearch-service-x64' service"; + } +} diff --git a/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStopCommandTests.java b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStopCommandTests.java new file mode 100644 index 0000000000000..f623c5d2465f3 --- /dev/null +++ b/distribution/tools/windows-service-cli/src/test/java/org/elasticsearch/windows/service/WindowsServiceStopCommandTests.java @@ -0,0 +1,40 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.windows.service; + +import org.elasticsearch.cli.Command; + +import java.io.IOException; + +public class WindowsServiceStopCommandTests extends WindowsServiceCliTestCase { + @Override + protected Command newCommand() { + return new WindowsServiceStopCommand() { + @Override + Process startProcess(ProcessBuilder processBuilder) throws IOException { + return mockProcess(processBuilder); + } + }; + } + + @Override + protected String getCommand() { + return "SS"; + } + + @Override + protected String getDefaultSuccessMessage() { + return "The service 'elasticsearch-service-x64' has been stopped"; + } + + @Override + protected String getDefaultFailureMessage() { + return "Failed stopping 'elasticsearch-service-x64' service"; + } +} diff --git a/libs/cli/src/main/java/org/elasticsearch/cli/Command.java b/libs/cli/src/main/java/org/elasticsearch/cli/Command.java index 93ef03c1ac969..27c34408c5f86 100644 --- a/libs/cli/src/main/java/org/elasticsearch/cli/Command.java +++ b/libs/cli/src/main/java/org/elasticsearch/cli/Command.java @@ -67,7 +67,7 @@ public final int main(String[] args, Terminal terminal, ProcessInfo processInfo) * Executes the command, but all errors are thrown. */ protected void mainWithoutErrorHandling(String[] args, Terminal terminal, ProcessInfo processInfo) throws Exception { - final OptionSet options = parser.parse(args); + final OptionSet options = parseOptions(args); if (options.has(helpOption)) { printHelp(terminal, false); @@ -85,6 +85,15 @@ protected void mainWithoutErrorHandling(String[] args, Terminal terminal, Proces execute(terminal, options, processInfo); } + /** + * Parse command line arguments for this command. + * @param args The string arguments passed to the command + * @return A set of parsed options + */ + public OptionSet parseOptions(String[] args) { + return parser.parse(args); + } + /** Prints a help message for the command to the terminal. */ private void printHelp(Terminal terminal, boolean toStdError) throws IOException { if (toStdError) { diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java b/qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java index 2d6c176fbd4a1..0b4b48f8b87ea 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java @@ -959,13 +959,16 @@ public void test124CanRestartContainerWithStackLoggingConfig() { * Check that the Java process running inside the container has the expected UID, GID and username. */ public void test130JavaHasCorrectOwnership() { - final ProcessInfo info = ProcessInfo.getProcessInfo(sh, "java"); + final List infos = ProcessInfo.getProcessInfo(sh, "java"); + assertThat(infos, hasSize(2)); - assertThat("Incorrect UID", info.uid(), equalTo(1000)); - assertThat("Incorrect username", info.username(), equalTo("elasticsearch")); + for (ProcessInfo info : infos) { + assertThat("Incorrect UID", info.uid(), equalTo(1000)); + assertThat("Incorrect username", info.username(), equalTo("elasticsearch")); - assertThat("Incorrect GID", info.gid(), equalTo(0)); - assertThat("Incorrect group", info.group(), equalTo("root")); + assertThat("Incorrect GID", info.gid(), equalTo(0)); + assertThat("Incorrect group", info.group(), equalTo("root")); + } } /** @@ -973,7 +976,9 @@ public void test130JavaHasCorrectOwnership() { * The PID is particularly important because PID 1 handles signal forwarding and child reaping. */ public void test131InitProcessHasCorrectPID() { - final ProcessInfo info = ProcessInfo.getProcessInfo(sh, "tini"); + final List infos = ProcessInfo.getProcessInfo(sh, "tini"); + assertThat(infos, hasSize(1)); + ProcessInfo info = infos.get(0); assertThat("Incorrect PID", info.pid(), equalTo(1)); diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java b/qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java index 91e5051550e1f..3ca61ccacae17 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java @@ -21,7 +21,6 @@ import static org.elasticsearch.packaging.util.Archives.installArchive; import static org.elasticsearch.packaging.util.Archives.verifyArchiveInstallation; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assume.assumeTrue; @@ -37,16 +36,6 @@ public void test10Install() throws Exception { verifyArchiveInstallation(installation, distribution()); } - public void test20EnrollToClusterWithEmptyTokenValue() throws Exception { - Shell.Result result = Archives.runElasticsearchStartCommand(installation, sh, null, List.of("--enrollment-token"), false); - // something in our tests wrap the error code to 1 on windows - // TODO investigate this and remove this guard - if (distribution.platform != Distribution.Platform.WINDOWS) { - assertThat(result.exitCode(), equalTo(ExitCodes.USAGE)); - } - verifySecurityNotAutoConfigured(installation); - } - public void test30EnrollToClusterWithInvalidToken() throws Exception { Shell.Result result = Archives.runElasticsearchStartCommand( installation, @@ -100,43 +89,6 @@ public void test50EnrollmentFailsForConfiguredNode() throws Exception { Platforms.onWindows(() -> sh.chown(installation.config)); } - public void test60MultipleValuesForEnrollmentToken() throws Exception { - // if invoked with --enrollment-token tokenA tokenB tokenC, only tokenA is read - Shell.Result result = Archives.runElasticsearchStartCommand( - installation, - sh, - null, - List.of("--enrollment-token", generateMockEnrollmentToken(), "some-other-token", "some-other-token", "some-other-token"), - false - ); - // Assert we used the first value which is a proper enrollment token but failed because the node is already configured ( 80 ) - // something in our tests wrap the error code to 1 on windows - // TODO investigate this and remove this guard - if (distribution.platform != Distribution.Platform.WINDOWS) { - assertThat(result.exitCode(), equalTo(ExitCodes.NOOP)); - } - } - - public void test70MultipleParametersForEnrollmentTokenAreNotAllowed() throws Exception { - // if invoked with --enrollment-token tokenA --enrollment-token tokenB --enrollment-token tokenC, we exit - Shell.Result result = Archives.runElasticsearchStartCommand( - installation, - sh, - null, - List.of( - "--enrollment-token", - "some-other-token", - "--enrollment-token", - "some-other-token", - "--enrollment-token", - generateMockEnrollmentToken() - ), - false - ); - assertThat(result.stderr(), containsString("Multiple --enrollment-token parameters are not allowed")); - assertThat(result.exitCode(), equalTo(1)); - } - private String generateMockEnrollmentToken() throws Exception { EnrollmentToken enrollmentToken = new EnrollmentToken( "some-api-key", diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/test/WindowsServiceTests.java b/qa/os/src/test/java/org/elasticsearch/packaging/test/WindowsServiceTests.java index d3e8974e0e8f0..cca5bd702485a 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/test/WindowsServiceTests.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/test/WindowsServiceTests.java @@ -8,25 +8,23 @@ package org.elasticsearch.packaging.test; -import junit.framework.TestCase; - import org.elasticsearch.packaging.util.FileUtils; import org.elasticsearch.packaging.util.Platforms; import org.elasticsearch.packaging.util.ServerUtils; -import org.elasticsearch.packaging.util.Shell; import org.elasticsearch.packaging.util.Shell.Result; import org.junit.After; import org.junit.BeforeClass; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; import static com.carrotsearch.randomizedtesting.RandomizedTest.assumeTrue; import static org.elasticsearch.packaging.util.Archives.installArchive; import static org.elasticsearch.packaging.util.Archives.verifyArchiveInstallation; import static org.elasticsearch.packaging.util.FileUtils.append; +import static org.elasticsearch.packaging.util.FileUtils.copyDirectory; import static org.elasticsearch.packaging.util.FileUtils.mv; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; @@ -48,11 +46,10 @@ public void uninstallService() { sh.runIgnoreExitCode(serviceScript + " remove"); } - private void assertService(String id, String status, String displayName) { + private void assertService(String id, String status) { Result result = sh.run("Get-Service " + id + " | Format-List -Property Name, Status, DisplayName"); assertThat(result.stdout(), containsString("Name : " + id)); assertThat(result.stdout(), containsString("Status : " + status)); - assertThat(result.stdout(), containsString("DisplayName : " + displayName)); } // runs the service command, dumping all log files on failure @@ -68,22 +65,32 @@ private Result assertFailure(String script, int exitCode) { return result; } + @Override + protected void dumpDebug() { + super.dumpDebug(); + dumpServiceLogs(); + } + + private void dumpServiceLogs() { + logger.warn("\n"); + try (var logsDir = Files.list(installation.logs)) { + for (Path logFile : logsDir.toList()) { + String filename = logFile.getFileName().toString(); + if (filename.startsWith("elasticsearch-service-x64")) { + logger.warn(filename + "\n" + FileUtils.slurp(logFile)); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + private void assertExit(Result result, String script, int exitCode) { if (result.exitCode() != exitCode) { logger.error("---- Unexpected exit code (expected " + exitCode + ", got " + result.exitCode() + ") for script: " + script); logger.error(result); logger.error("Dumping log files\n"); - Result logs = sh.run( - "$files = Get-ChildItem \"" - + installation.logs - + "\\elasticsearch.log\"; " - + "Write-Output $files; " - + "foreach ($file in $files) {" - + " Write-Output \"$file\"; " - + " Get-Content \"$file\" " - + "}" - ); - logger.error(logs.stdout()); + dumpDebug(); fail(); } else { logger.info("\nscript: " + script + "\nstdout: " + result.stdout() + "\nstderr: " + result.stderr()); @@ -97,32 +104,15 @@ public void test10InstallArchive() throws Exception { serviceScript = installation.bin("elasticsearch-service.bat").toString(); } - public void test11InstallServiceExeMissing() throws IOException { - Path serviceExe = installation.bin("elasticsearch-service-x64.exe"); - Path tmpServiceExe = serviceExe.getParent().resolve(serviceExe.getFileName() + ".tmp"); - Files.move(serviceExe, tmpServiceExe); - Result result = sh.runIgnoreExitCode(serviceScript + " install"); - assertThat(result.exitCode(), equalTo(1)); - assertThat(result.stdout(), containsString("elasticsearch-service-x64.exe was not found...")); - Files.move(tmpServiceExe, serviceExe); - } - public void test12InstallService() { sh.run(serviceScript + " install"); - assertService(DEFAULT_ID, "Stopped", DEFAULT_DISPLAY_NAME); + assertService(DEFAULT_ID, "Stopped"); sh.run(serviceScript + " remove"); } - public void test14InstallBadJavaHome() throws IOException { - sh.getEnv().put("ES_JAVA_HOME", "doesnotexist"); - Result result = sh.runIgnoreExitCode(serviceScript + " install"); - assertThat(result.exitCode(), equalTo(1)); - assertThat(result.stderr(), containsString("could not find java in ES_JAVA_HOME")); - } - public void test15RemoveNotInstalled() { Result result = assertFailure(serviceScript + " remove", 1); - assertThat(result.stdout(), containsString("Failed removing '" + DEFAULT_ID + "' service")); + assertThat(result.stderr(), containsString("Failed removing '" + DEFAULT_ID + "' service")); } public void test16InstallSpecialCharactersInJdkPath() throws IOException { @@ -133,7 +123,7 @@ public void test16InstallSpecialCharactersInJdkPath() throws IOException { try { mv(installation.bundledJdk, relocatedJdk); Result result = sh.run(serviceScript + " install"); - assertThat(result.stdout(), containsString("The service 'elasticsearch-service-x64' has been installed.")); + assertThat(result.stdout(), containsString("The service 'elasticsearch-service-x64' has been installed")); } finally { sh.runIgnoreExitCode(serviceScript + " remove"); mv(relocatedJdk, installation.bundledJdk); @@ -142,10 +132,9 @@ public void test16InstallSpecialCharactersInJdkPath() throws IOException { public void test20CustomizeServiceId() { String serviceId = "my-es-service"; - String displayName = DEFAULT_DISPLAY_NAME.replace(DEFAULT_ID, serviceId); sh.getEnv().put("SERVICE_ID", serviceId); sh.run(serviceScript + " install"); - assertService(serviceId, "Stopped", displayName); + assertService(serviceId, "Stopped"); sh.run(serviceScript + " remove"); } @@ -153,7 +142,7 @@ public void test21CustomizeServiceDisplayName() { String displayName = "my es service display name"; sh.getEnv().put("SERVICE_DISPLAY_NAME", displayName); sh.run(serviceScript + " install"); - assertService(DEFAULT_ID, "Stopped", displayName); + assertService(DEFAULT_ID, "Stopped"); sh.run(serviceScript + " remove"); } @@ -163,7 +152,7 @@ public void assertStartedAndStop() throws Exception { runElasticsearchTests(); assertCommand(serviceScript + " stop"); - assertService(DEFAULT_ID, "Stopped", DEFAULT_DISPLAY_NAME); + assertService(DEFAULT_ID, "Stopped"); // the process is stopped async, and can become a zombie process, so we poll for the process actually being gone assertCommand( "$p = Get-Service -Name \"elasticsearch-service-x64\" -ErrorAction SilentlyContinue;" @@ -201,8 +190,9 @@ public void test30StartStop() throws Exception { public void test31StartNotInstalled() throws IOException { Result result = sh.runIgnoreExitCode(serviceScript + " start"); - assertThat(result.stdout(), result.exitCode(), equalTo(1)); - assertThat(result.stdout(), containsString("Failed starting '" + DEFAULT_ID + "' service")); + assertThat(result.stderr(), result.exitCode(), equalTo(1)); + dumpServiceLogs(); + assertThat(result.stderr(), containsString("Failed starting '" + DEFAULT_ID + "' service")); } public void test32StopNotStarted() throws IOException { @@ -212,44 +202,20 @@ public void test32StopNotStarted() throws IOException { } public void test33JavaChanged() throws Exception { - final Path relocatedJdk = installation.bundledJdk.getParent().resolve("jdk.relocated"); + final Path alternateJdk = installation.bundledJdk.getParent().resolve("jdk.copy"); try { - mv(installation.bundledJdk, relocatedJdk); - sh.getEnv().put("ES_JAVA_HOME", relocatedJdk.toString()); + copyDirectory(installation.bundledJdk, alternateJdk); + sh.getEnv().put("ES_JAVA_HOME", alternateJdk.toString()); assertCommand(serviceScript + " install"); sh.getEnv().remove("ES_JAVA_HOME"); assertCommand(serviceScript + " start"); assertStartedAndStop(); } finally { - mv(relocatedJdk, installation.bundledJdk); + FileUtils.rm(alternateJdk); } } - public void test60Manager() throws IOException { - Path serviceMgr = installation.bin("elasticsearch-service-mgr.exe"); - Path tmpServiceMgr = serviceMgr.getParent().resolve(serviceMgr.getFileName() + ".tmp"); - Files.move(serviceMgr, tmpServiceMgr); - Path fakeServiceMgr = serviceMgr.getParent().resolve("elasticsearch-service-mgr.bat"); - Files.write(fakeServiceMgr, Arrays.asList("echo \"Fake Service Manager GUI\"")); - Shell sh = new Shell(); - Result result = sh.run(serviceScript + " manager"); - assertThat(result.stdout(), containsString("Fake Service Manager GUI")); - - // check failure too - Files.write(fakeServiceMgr, Arrays.asList("echo \"Fake Service Manager GUI Failure\"", "exit 1")); - result = sh.runIgnoreExitCode(serviceScript + " manager"); - TestCase.assertEquals(1, result.exitCode()); - TestCase.assertTrue(result.stdout(), result.stdout().contains("Fake Service Manager GUI Failure")); - Files.move(tmpServiceMgr, serviceMgr); - } - - public void test70UnknownCommand() { - Result result = sh.runIgnoreExitCode(serviceScript + " bogus"); - assertThat(result.exitCode(), equalTo(1)); - assertThat(result.stdout(), containsString("Unknown option \"bogus\"")); - } - public void test80JavaOptsInEnvVar() throws Exception { sh.getEnv().put("ES_JAVA_OPTS", "-Xmx2g -Xms2g"); sh.run(serviceScript + " install"); diff --git a/qa/os/src/test/java/org/elasticsearch/packaging/util/ProcessInfo.java b/qa/os/src/test/java/org/elasticsearch/packaging/util/ProcessInfo.java index 91fd00d86e2b2..4080a0d7e76e2 100644 --- a/qa/os/src/test/java/org/elasticsearch/packaging/util/ProcessInfo.java +++ b/qa/os/src/test/java/org/elasticsearch/packaging/util/ProcessInfo.java @@ -8,12 +8,11 @@ package org.elasticsearch.packaging.util; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasSize; - /** * Encapsulates the fetching of information about a running process. *

@@ -27,32 +26,34 @@ public record ProcessInfo(int pid, int uid, int gid, String username, String gro /** * Fetches process information about command, using sh to execute commands. * - * @return a populated ProcessInfo object + * @return a populated list of ProcessInfo objects */ - public static ProcessInfo getProcessInfo(Shell sh, String command) { + public static List getProcessInfo(Shell sh, String command) { final List processes = sh.run("pgrep " + command).stdout().lines().collect(Collectors.toList()); - assertThat("Expected a single process", processes, hasSize(1)); - - // Ensure we actually have a number - final int pid = Integer.parseInt(processes.get(0).trim()); + List infos = new ArrayList<>(); + for (String pidStr : processes) { + // Ensure we actually have a number + final int pid = Integer.parseInt(pidStr.trim()); - int uid = -1; - int gid = -1; + int uid = -1; + int gid = -1; - for (String line : sh.run("cat /proc/" + pid + "/status | grep '^[UG]id:'").stdout().split("\\n")) { - final String[] fields = line.split("\\s+"); + for (String line : sh.run("cat /proc/" + pid + "/status | grep '^[UG]id:'").stdout().split("\\n")) { + final String[] fields = line.split("\\s+"); - if (fields[0].equals("Uid:")) { - uid = Integer.parseInt(fields[1]); - } else { - gid = Integer.parseInt(fields[1]); + if (fields[0].equals("Uid:")) { + uid = Integer.parseInt(fields[1]); + } else { + gid = Integer.parseInt(fields[1]); + } } - } - final String username = sh.run("getent passwd " + uid + " | cut -f1 -d:").stdout().trim(); - final String group = sh.run("getent group " + gid + " | cut -f1 -d:").stdout().trim(); + final String username = sh.run("getent passwd " + uid + " | cut -f1 -d:").stdout().trim(); + final String group = sh.run("getent group " + gid + " | cut -f1 -d:").stdout().trim(); - return new ProcessInfo(pid, uid, gid, username, group); + infos.add(new ProcessInfo(pid, uid, gid, username, group)); + } + return Collections.unmodifiableList(infos); } } diff --git a/server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java b/server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java index fe15426ea8ce2..5379e596c28ad 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java @@ -25,10 +25,10 @@ import org.elasticsearch.common.logging.LogConfigurator; import org.elasticsearch.common.network.IfConfig; import org.elasticsearch.common.settings.SecureSettings; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.transport.BoundTransportAddress; import org.elasticsearch.core.IOUtils; -import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.env.Environment; import org.elasticsearch.jdk.JarHell; import org.elasticsearch.monitor.jvm.HotThreads; @@ -287,24 +287,24 @@ static void stop() throws IOException { /** * This method is invoked by {@link Elasticsearch#main(String[])} to startup elasticsearch. */ - static void init(final boolean foreground, final Path pidFile, final boolean quiet, final Environment initialEnv) - throws BootstrapException, NodeValidationException, UserException { + static void init( + final boolean foreground, + final Path pidFile, + final boolean quiet, + final Environment initialEnv, + SecureString keystorePassword + ) throws BootstrapException, NodeValidationException, UserException { // force the class initializer for BootstrapInfo to run before // the security manager is installed BootstrapInfo.init(); INSTANCE = new Bootstrap(); - final SecureSettings keystore = BootstrapUtil.loadSecureSettings(initialEnv); + final SecureSettings keystore = BootstrapUtil.loadSecureSettings(initialEnv, keystorePassword); final Environment environment = createEnvironment(pidFile, keystore, initialEnv.settings(), initialEnv.configFile()); BootstrapInfo.setConsole(getConsole(environment)); - // the LogConfigurator will replace System.out and System.err with redirects to our logfile, so we need to capture - // the stream objects before calling LogConfigurator to be able to close them when appropriate - final Runnable sysOutCloser = getSysOutCloser(); - final Runnable sysErrorCloser = getSysErrorCloser(); - LogConfigurator.setNodeName(Node.NODE_NAME_SETTING.get(environment.settings())); try { LogConfigurator.configure(environment, quiet == false); @@ -355,8 +355,6 @@ static void init(final boolean foreground, final Path pidFile, final boolean qui if (foreground == false) { LogConfigurator.removeConsoleAppender(); - sysOutCloser.run(); - sysErrorCloser.run(); } } catch (NodeValidationException | RuntimeException e) { @@ -386,16 +384,6 @@ private static ConsoleLoader.Console getConsole(Environment environment) { return ConsoleLoader.loadConsole(environment); } - @SuppressForbidden(reason = "System#out") - private static Runnable getSysOutCloser() { - return System.out::close; - } - - @SuppressForbidden(reason = "System#err") - private static Runnable getSysErrorCloser() { - return System.err::close; - } - private static void checkLucene() { if (Version.CURRENT.luceneVersion.equals(org.apache.lucene.util.Version.LATEST) == false) { throw new AssertionError( diff --git a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapException.java b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapException.java index 8c45d8bfdd6c4..92be3791ddff1 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapException.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapException.java @@ -15,7 +15,7 @@ * during bootstrap should explicitly declare the checked exceptions that they can throw, rather * than declaring the top-level checked exception {@link Exception}. This exception exists to wrap * these checked exceptions so that - * {@link Bootstrap#init(boolean, Path, boolean, org.elasticsearch.env.Environment)} + * {@link Bootstrap#init(boolean, Path, boolean, org.elasticsearch.env.Environment, org.elasticsearch.common.settings.SecureString)} * does not have to declare all of these checked exceptions. */ class BootstrapException extends Exception { diff --git a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapInfo.java b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapInfo.java index 0b63211f2851a..9f9e404ba4b90 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapInfo.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapInfo.java @@ -63,6 +63,27 @@ public static ConsoleLoader.Console getConsole() { */ public static final String UNTRUSTED_CODEBASE = "/untrusted"; + /** + * A non-printable character denoting a UserException has occurred. + * + * This is sent over stderr to the controlling CLI process. + */ + public static final char USER_EXCEPTION_MARKER = '\u0015'; + + /** + * A non-printable character denoting the server is ready to process requests. + * + * This is sent over stderr to the controlling CLI process. + */ + public static final char SERVER_READY_MARKER = '\u0018'; + + /** + * A non-printable character denoting the server should shut itself down. + * + * This is sent over stdin from the controlling CLI process. + */ + public static final char SERVER_SHUTDOWN_MARKER = '\u001B'; + // create a view of sysprops map that does not allow modifications // this must be done this way (e.g. versus an actual typed map), because // some test methods still change properties, so whitelisted changes must diff --git a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapUtil.java b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapUtil.java index 4f9f467eed048..7ff0bfeb4c76d 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/BootstrapUtil.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/BootstrapUtil.java @@ -8,17 +8,11 @@ package org.elasticsearch.bootstrap; -import org.elasticsearch.cli.Terminal; import org.elasticsearch.common.settings.KeyStoreWrapper; import org.elasticsearch.common.settings.SecureSettings; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.env.Environment; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; - /** * Utilities for use during bootstrap. This is public so that tests may use these methods. */ @@ -27,32 +21,9 @@ public class BootstrapUtil { // no construction private BootstrapUtil() {} - /** - * Read from an InputStream up to the first carriage return or newline, - * returning no more than maxLength characters. - */ - public static SecureString readPassphrase(InputStream stream) throws IOException { - SecureString passphrase; - - try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { - passphrase = new SecureString(Terminal.readLineToCharArray(reader)); - } - - if (passphrase.length() == 0) { - passphrase.close(); - throw new IllegalStateException("Keystore passphrase required but none provided."); - } - - return passphrase; - } - - public static SecureSettings loadSecureSettings(Environment initialEnv) throws BootstrapException { - return loadSecureSettings(initialEnv, System.in); - } - - public static SecureSettings loadSecureSettings(Environment initialEnv, InputStream stdin) throws BootstrapException { + public static SecureSettings loadSecureSettings(Environment initialEnv, SecureString keystorePassword) throws BootstrapException { try { - return KeyStoreWrapper.bootstrap(initialEnv.configFile(), () -> readPassphrase(stdin)); + return KeyStoreWrapper.bootstrap(initialEnv.configFile(), () -> keystorePassword); } catch (Exception e) { throw new BootstrapException(e); } diff --git a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java index 83c591cf8522c..c62c8744a9e44 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java @@ -8,55 +8,30 @@ package org.elasticsearch.bootstrap; -import joptsimple.OptionSet; -import joptsimple.OptionSpec; -import joptsimple.OptionSpecBuilder; -import joptsimple.util.PathConverter; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.elasticsearch.Build; import org.elasticsearch.cli.ExitCodes; -import org.elasticsearch.cli.ProcessInfo; -import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.UserException; -import org.elasticsearch.common.cli.EnvironmentAwareCommand; +import org.elasticsearch.common.io.stream.InputStreamStreamInput; import org.elasticsearch.common.logging.LogConfigurator; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.env.Environment; -import org.elasticsearch.monitor.jvm.JvmInfo; import org.elasticsearch.node.NodeValidationException; import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; import java.nio.file.Path; import java.security.Permission; import java.security.Security; -import java.util.Arrays; -import java.util.Locale; + +import static org.elasticsearch.bootstrap.BootstrapInfo.USER_EXCEPTION_MARKER; /** * This class starts elasticsearch. */ -class Elasticsearch extends EnvironmentAwareCommand { - - private final OptionSpecBuilder versionOption; - private final OptionSpecBuilder daemonizeOption; - private final OptionSpec pidfileOption; - private final OptionSpecBuilder quietOption; - - // visible for testing - Elasticsearch() { - super("Starts Elasticsearch"); // we configure logging later so we override the base class from configuring logging - versionOption = parser.acceptsAll(Arrays.asList("V", "version"), "Prints Elasticsearch version information and exits"); - daemonizeOption = parser.acceptsAll(Arrays.asList("d", "daemonize"), "Starts Elasticsearch in the background") - .availableUnless(versionOption); - pidfileOption = parser.acceptsAll(Arrays.asList("p", "pidfile"), "Creates a pid file in the specified path on start") - .availableUnless(versionOption) - .withRequiredArg() - .withValuesConvertedBy(new PathConverter()); - quietOption = parser.acceptsAll(Arrays.asList("q", "quiet"), "Turns off standard output/error streams logging in console") - .availableUnless(versionOption) - .availableUnless(daemonizeOption); - } +class Elasticsearch { /** * Main entry point for starting elasticsearch @@ -79,38 +54,88 @@ public void checkPermission(Permission perm) { }); LogConfigurator.registerErrorListener(); + final Elasticsearch elasticsearch = new Elasticsearch(); - final Terminal terminal = Terminal.DEFAULT; - int status; + PrintStream out = getStdout(); + PrintStream err = getStderr(); try { - status = main(args, elasticsearch, terminal); - } catch (Exception e) { - status = 1; // mimic JDK exit code on exception - if (System.getProperty("es.logs.base_path") != null) { - // this is a horrible hack to see if logging has been initialized - // we need to find a better way! - Logger logger = LogManager.getLogger(Elasticsearch.class); - logger.error("fatal exception while booting Elasticsearch", e); + final var in = new InputStreamStreamInput(System.in); + final ServerArgs serverArgs = new ServerArgs(in); + elasticsearch.init( + serverArgs.daemonize(), + serverArgs.pidFile(), + serverArgs.quiet(), + new Environment(serverArgs.nodeSettings(), serverArgs.configDir()), + serverArgs.keystorePassword() + ); + + err.println(BootstrapInfo.SERVER_READY_MARKER); + if (serverArgs.daemonize()) { + out.close(); + err.close(); + } else { + startCliMonitorThread(System.in); } - e.printStackTrace(terminal.getErrorWriter()); + + } catch (NodeValidationException e) { + exitWithUserException(err, ExitCodes.CONFIG, e); + } catch (UserException e) { + exitWithUserException(err, e.exitCode, e); + } catch (Exception e) { + exitWithUnknownException(err, e); } - if (status != ExitCodes.OK) { - printLogsSuggestion(); - terminal.flush(); - exit(status); + } + + private static void exitWithUserException(PrintStream err, int exitCode, Exception e) { + err.print(USER_EXCEPTION_MARKER); + err.println(e.getMessage()); + gracefullyExit(err, exitCode); + } + + private static void exitWithUnknownException(PrintStream err, Exception e) { + if (System.getProperty("es.logs.base_path") != null) { + // this is a horrible hack to see if logging has been initialized + // we need to find a better way! + Logger logger = LogManager.getLogger(Elasticsearch.class); + logger.error("fatal exception while booting Elasticsearch", e); } + e.printStackTrace(err); + gracefullyExit(err, 1); // mimic JDK exit code on exception + } + + private static void gracefullyExit(PrintStream err, int exitCode) { + err.println("EXITING with non-zero status: " + exitCode); + printLogsSuggestion(err); + err.flush(); + exit(exitCode); + } + + @SuppressForbidden(reason = "grab stderr for communication with server-cli") + private static PrintStream getStderr() { + return System.err; + } + + // TODO: remove this, just for debugging + @SuppressForbidden(reason = "grab stdout for communication with server-cli") + private static PrintStream getStdout() { + return System.out; + } + + @SuppressForbidden(reason = "main exit path") + private static void exit(int exitCode) { + System.exit(exitCode); } /** * Prints a message directing the user to look at the logs. A message is only printed if * logging has been configured. */ - static void printLogsSuggestion() { + static void printLogsSuggestion(PrintStream err) { final String basePath = System.getProperty("es.logs.base_path"); // It's possible to fail before logging has been configured, in which case there's no point // suggesting that the user look in the log file. if (basePath != null) { - Terminal.DEFAULT.errorPrintln( + err.println( "ERROR: Elasticsearch did not exit normally - check the logs at " + basePath + System.getProperty("file.separator") @@ -120,6 +145,32 @@ static void printLogsSuggestion() { } } + /** + * Starts a thread that monitors stdin for a shutdown signal. + * + * If the shutdown signal is received, Elasticsearch exits with status code 0. + * If the pipe is broken, Elasticsearch exits with status code 1. + * + * @param stdin Standard input for this process + */ + private static void startCliMonitorThread(InputStream stdin) { + new Thread(() -> { + int msg = -1; + try { + msg = stdin.read(); + } catch (IOException e) { + // ignore, whether we cleanly got end of stream (-1) or an error, we will shut down below + } finally { + if (msg == BootstrapInfo.SERVER_SHUTDOWN_MARKER) { + exit(0); + } else { + // parent process died or there was an error reading from it + exit(1); + } + } + }).start(); + } + private static void overrideDnsCachePolicyProperties() { for (final String property : new String[] { "networkaddress.cache.ttl", "networkaddress.cache.negative.ttl" }) { final String overrideProperty = "es." + property; @@ -135,69 +186,14 @@ private static void overrideDnsCachePolicyProperties() { } } - static int main(final String[] args, final Elasticsearch elasticsearch, final Terminal terminal) throws Exception { - return elasticsearch.main(args, terminal, ProcessInfo.fromSystem()); - } - - @Override - public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws UserException { - if (options.nonOptionArguments().isEmpty() == false) { - throw new UserException(ExitCodes.USAGE, "Positional arguments not allowed, found " + options.nonOptionArguments()); - } - if (options.has(versionOption)) { - final String versionOutput = String.format( - Locale.ROOT, - "Version: %s, Build: %s/%s/%s, JVM: %s", - Build.CURRENT.qualifiedVersion(), - Build.CURRENT.type().displayName(), - Build.CURRENT.hash(), - Build.CURRENT.date(), - JvmInfo.jvmInfo().version() - ); - terminal.println(versionOutput); - return; - } - - final boolean daemonize = options.has(daemonizeOption); - final Path pidFile = pidfileOption.value(options); - final boolean quiet = options.has(quietOption); - - // a misconfigured java.io.tmpdir can cause hard-to-diagnose problems later, so reject it immediately - try { - env.validateTmpFile(); - } catch (IOException e) { - throw new UserException(ExitCodes.CONFIG, e.getMessage()); - } - - try { - init(daemonize, pidFile, quiet, env); - } catch (NodeValidationException e) { - throw new UserException(ExitCodes.CONFIG, e.getMessage()); - } - } - - void init(final boolean daemonize, final Path pidFile, final boolean quiet, Environment initialEnv) throws NodeValidationException, - UserException { + void init(final boolean daemonize, final Path pidFile, final boolean quiet, Environment initialEnv, SecureString keystorePassword) + throws NodeValidationException, UserException { try { - Bootstrap.init(daemonize == false, pidFile, quiet, initialEnv); + Bootstrap.init(daemonize == false, pidFile, quiet, initialEnv, keystorePassword); } catch (BootstrapException | RuntimeException e) { // format exceptions to the console in a special way // to avoid 2MB stacktraces from guice, etc. throw new StartupException(e); } } - - /** - * Required method that's called by Apache Commons procrun when - * running as a service on Windows, when the service is stopped. - * - * http://commons.apache.org/proper/commons-daemon/procrun.html - * - * NOTE: If this method is renamed and/or moved, make sure to - * update elasticsearch-service.bat! - */ - static void close(String[] args) throws IOException { - Bootstrap.stop(); - } - } diff --git a/server/src/main/java/org/elasticsearch/bootstrap/Security.java b/server/src/main/java/org/elasticsearch/bootstrap/Security.java index cff20666f07de..3f1d622e3ed30 100644 --- a/server/src/main/java/org/elasticsearch/bootstrap/Security.java +++ b/server/src/main/java/org/elasticsearch/bootstrap/Security.java @@ -9,7 +9,6 @@ package org.elasticsearch.bootstrap; import org.elasticsearch.ElasticsearchException; -import org.elasticsearch.cli.Command; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.PathUtils; import org.elasticsearch.core.SuppressForbidden; @@ -128,7 +127,7 @@ static void configure(Environment environment, boolean filterBadDefaults) throws final String[] classesThatCanExit = new String[] { // SecureSM matches class names as regular expressions so we escape the $ that arises from the nested class name ElasticsearchUncaughtExceptionHandler.PrivilegedHaltAction.class.getName().replace("$", "\\$"), - Command.class.getName() }; + Elasticsearch.class.getName() }; setSecurityManager(new SecureSM(classesThatCanExit)); // do some basic tests diff --git a/server/src/main/java/org/elasticsearch/bootstrap/ServerArgs.java b/server/src/main/java/org/elasticsearch/bootstrap/ServerArgs.java new file mode 100644 index 0000000000000..8adb791d5db52 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/bootstrap/ServerArgs.java @@ -0,0 +1,74 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.bootstrap; + +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.PathUtils; +import org.elasticsearch.core.SuppressForbidden; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Arguments for running Elasticsearch. + * + * @param daemonize {@code true} if Elasticsearch should run as a daemon process, or {@code false} otherwise + * @param quiet {@code false} if Elasticsearch should print log output to the console, {@code true} otherwise + * @param pidFile a path to a file Elasticsearch should write its process id to, or {@code null} if no pid file should be written + * @param keystorePassword the password for the Elasticsearch keystore + * @param nodeSettings the node settings read from {@code elasticsearch.yml}, the cli and the process environment + * @param configDir the directory where {@code elasticsearch.yml} and other config exists + */ +public record ServerArgs( + boolean daemonize, + boolean quiet, + Path pidFile, + SecureString keystorePassword, + Settings nodeSettings, + Path configDir +) implements Writeable { + + /** + * Alternate constructor to read the args from a binary stream. + */ + public ServerArgs(StreamInput in) throws IOException { + this( + in.readBoolean(), + in.readBoolean(), + readPidFile(in), + in.readSecureString(), + Settings.readSettingsFromStream(in), + resolvePath(in.readString()) + ); + } + + private static Path readPidFile(StreamInput in) throws IOException { + String pidFile = in.readOptionalString(); + return pidFile == null ? null : resolvePath(pidFile); + } + + @SuppressForbidden(reason = "reading local path from stream") + private static Path resolvePath(String path) { + return PathUtils.get(path); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeBoolean(daemonize); + out.writeBoolean(quiet); + out.writeOptionalString(pidFile == null ? null : pidFile.toString()); + out.writeSecureString(keystorePassword); + Settings.writeSettingsToStream(nodeSettings, out); + out.writeString(configDir.toString()); + } +} diff --git a/server/src/main/java/org/elasticsearch/common/cli/KeyStoreAwareCommand.java b/server/src/main/java/org/elasticsearch/common/cli/KeyStoreAwareCommand.java index b8bdbcd4ef8ed..a2212d60f1220 100644 --- a/server/src/main/java/org/elasticsearch/common/cli/KeyStoreAwareCommand.java +++ b/server/src/main/java/org/elasticsearch/common/cli/KeyStoreAwareCommand.java @@ -53,7 +53,7 @@ protected static SecureString readPassword(Terminal terminal, boolean withVerifi } Arrays.fill(passwordVerification, '\u0000'); } else { - passwordArray = terminal.readSecret("Enter password for the elasticsearch keystore : "); + passwordArray = terminal.readSecret(KeyStoreWrapper.PROMPT); } return new SecureString(passwordArray); } diff --git a/server/src/main/java/org/elasticsearch/common/settings/KeyStoreWrapper.java b/server/src/main/java/org/elasticsearch/common/settings/KeyStoreWrapper.java index 56d244b0654ec..5e1398ce6d247 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/KeyStoreWrapper.java +++ b/server/src/main/java/org/elasticsearch/common/settings/KeyStoreWrapper.java @@ -75,6 +75,8 @@ */ public class KeyStoreWrapper implements SecureSettings { + public static final String PROMPT = "Enter password for the elasticsearch keystore : "; + /** An identifier for the type of data that may be stored in a keystore entry. */ private enum EntryType { STRING, @@ -202,6 +204,7 @@ public static void addBootstrapSeed(KeyStoreWrapper wrapper) { Arrays.fill(characters, (char) 0); } + // TODO: this doesn't need to be a supplier anymore public static KeyStoreWrapper bootstrap(Path configDir, CheckedSupplier passwordSupplier) throws Exception { KeyStoreWrapper keystore = KeyStoreWrapper.load(configDir); diff --git a/server/src/test/java/org/elasticsearch/bootstrap/ElasticsearchCliTests.java b/server/src/test/java/org/elasticsearch/bootstrap/ElasticsearchCliTests.java deleted file mode 100644 index 4f9c805a967e9..0000000000000 --- a/server/src/test/java/org/elasticsearch/bootstrap/ElasticsearchCliTests.java +++ /dev/null @@ -1,182 +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 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 or the Server - * Side Public License, v 1. - */ - -package org.elasticsearch.bootstrap; - -import org.elasticsearch.Build; -import org.elasticsearch.cli.Command; -import org.elasticsearch.cli.CommandTestCase; -import org.elasticsearch.cli.ExitCodes; -import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.env.Environment; -import org.elasticsearch.monitor.jvm.JvmInfo; -import org.hamcrest.Matcher; -import org.junit.Before; - -import java.nio.file.Path; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.emptyString; -import static org.hamcrest.Matchers.hasItem; - -public class ElasticsearchCliTests extends CommandTestCase { - private void assertOk(String... args) throws Exception { - assertOkWithOutput(emptyString(), args); - } - - private void assertOkWithOutput(Matcher matcher, String... args) throws Exception { - terminal.reset(); - int status = executeMain(args); - assertThat(status, equalTo(ExitCodes.OK)); - assertThat(terminal.getErrorOutput(), emptyString()); - assertThat(terminal.getOutput(), matcher); - } - - private void assertUsage(Matcher matcher, String... args) throws Exception { - terminal.reset(); - initCallback = FAIL_INIT; - int status = executeMain(args); - assertThat(status, equalTo(ExitCodes.USAGE)); - assertThat(terminal.getErrorOutput(), matcher); - } - - private void assertMutuallyExclusiveOptions(String... args) throws Exception { - assertUsage(allOf(containsString("ERROR:"), containsString("are unavailable given other options on the command line")), args); - } - - public void testVersion() throws Exception { - assertMutuallyExclusiveOptions("-V", "-d"); - assertMutuallyExclusiveOptions("-V", "--daemonize"); - assertMutuallyExclusiveOptions("-V", "-p", "/tmp/pid"); - assertMutuallyExclusiveOptions("-V", "--pidfile", "/tmp/pid"); - assertMutuallyExclusiveOptions("--version", "-d"); - assertMutuallyExclusiveOptions("--version", "--daemonize"); - assertMutuallyExclusiveOptions("--version", "-p", "/tmp/pid"); - assertMutuallyExclusiveOptions("--version", "--pidfile", "/tmp/pid"); - assertMutuallyExclusiveOptions("--version", "-q"); - assertMutuallyExclusiveOptions("--version", "--quiet"); - - final String expectedBuildOutput = String.format( - Locale.ROOT, - "Build: %s/%s/%s", - Build.CURRENT.type().displayName(), - Build.CURRENT.hash(), - Build.CURRENT.date() - ); - Matcher versionOutput = allOf( - containsString("Version: " + Build.CURRENT.qualifiedVersion()), - containsString(expectedBuildOutput), - containsString("JVM: " + JvmInfo.jvmInfo().version()) - ); - assertOkWithOutput(versionOutput, "-V"); - assertOkWithOutput(versionOutput, "--version"); - } - - public void testPositionalArgs() throws Exception { - String prefix = "Positional arguments not allowed, found "; - assertUsage(containsString(prefix + "[foo]"), "foo"); - assertUsage(containsString(prefix + "[foo, bar]"), "foo", "bar"); - assertUsage(containsString(prefix + "[foo]"), "-E", "foo=bar", "foo", "-E", "baz=qux"); - } - - public void testPidFile() throws Exception { - Path tmpDir = createTempDir(); - Path pidFileArg = tmpDir.resolve("pid"); - assertUsage(containsString("Option p/pidfile requires an argument"), "-p"); - initCallback = (daemonize, pidFile, quiet, env) -> { assertThat(pidFile.toString(), equalTo(pidFileArg.toString())); }; - terminal.reset(); - assertOk("-p", pidFileArg.toString()); - terminal.reset(); - assertOk("--pidfile", pidFileArg.toString()); - } - - public void testDaemonize() throws Exception { - AtomicBoolean expectDaemonize = new AtomicBoolean(true); - initCallback = (d, p, q, e) -> assertThat(d, equalTo(expectDaemonize.get())); - assertOk("-d"); - assertOk("--daemonize"); - expectDaemonize.set(false); - assertOk(); - } - - public void testQuiet() throws Exception { - AtomicBoolean expectQuiet = new AtomicBoolean(true); - initCallback = (d, p, q, e) -> assertThat(q, equalTo(expectQuiet.get())); - assertOk("-q"); - assertOk("--quiet"); - expectQuiet.set(false); - assertOk(); - } - - public void testElasticsearchSettings() throws Exception { - initCallback = (d, p, q, e) -> { - Settings settings = e.settings(); - assertThat(settings.get("foo"), equalTo("bar")); - assertThat(settings.get("baz"), equalTo("qux")); - }; - assertOk("-Efoo=bar", "-E", "baz=qux"); - } - - public void testElasticsearchSettingCanNotBeEmpty() throws Exception { - assertUsage(containsString("setting [foo] must not be empty"), "-E", "foo="); - } - - public void testElasticsearchSettingCanNotBeDuplicated() throws Exception { - assertUsage(containsString("setting [foo] already set, saw [bar] and [baz]"), "-E", "foo=bar", "-E", "foo=baz"); - } - - public void testUnknownOption() throws Exception { - assertUsage(containsString("network.host is not a recognized option"), "--network.host"); - } - - public void testPathHome() throws Exception { - AtomicReference expectedHomeDir = new AtomicReference<>(); - expectedHomeDir.set(esHomeDir.toString()); - initCallback = (d, p, q, e) -> { - Settings settings = e.settings(); - assertThat(settings.get("path.home"), equalTo(expectedHomeDir.get())); - assertThat(settings.keySet(), hasItem("path.logs")); // added by env initialization - }; - assertOk(); - sysprops.remove("es.path.home"); - final String commandLineValue = createTempDir().toString(); - expectedHomeDir.set(commandLineValue); - assertOk("-Epath.home=" + commandLineValue); - } - - interface InitMethod { - void init(boolean daemonize, Path pidFile, boolean quiet, Environment initialEnv); - } - - InitMethod initCallback; - final InitMethod FAIL_INIT = (d, p, q, e) -> fail("Did not expect to run init"); - - @Before - public void resetCommand() { - initCallback = null; - } - - @Override - protected Command newCommand() { - return new Elasticsearch() { - - @Override - void init(boolean daemonize, Path pidFile, boolean quiet, Environment initialEnv) { - if (initCallback != null) { - initCallback.init(daemonize, pidFile, quiet, initialEnv); - } - } - - }; - } -} diff --git a/settings.gradle b/settings.gradle index 690cedb88a5c0..770cdad0721de 100644 --- a/settings.gradle +++ b/settings.gradle @@ -55,6 +55,7 @@ List projects = [ 'distribution:tools:java-version-checker', 'distribution:tools:cli-launcher', 'distribution:tools:server-cli', + 'distribution:tools:windows-service-cli', 'distribution:tools:plugin-cli', 'distribution:tools:keystore-cli', 'distribution:tools:geoip-cli', diff --git a/test/framework/src/main/java/org/elasticsearch/cli/CommandTestCase.java b/test/framework/src/main/java/org/elasticsearch/cli/CommandTestCase.java index 930455160b072..99d66c7206f31 100644 --- a/test/framework/src/main/java/org/elasticsearch/cli/CommandTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/cli/CommandTestCase.java @@ -9,12 +9,16 @@ package org.elasticsearch.cli; import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matcher; import org.junit.Before; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.emptyString; + /** * A base test case for cli tools. */ @@ -79,4 +83,22 @@ public String execute(Command command, String... args) throws Exception { command.mainWithoutErrorHandling(args, terminal, new ProcessInfo(sysprops, envVars, esHomeDir)); return terminal.getOutput(); } + + protected void assertOk(String... args) throws Exception { + assertOkWithOutput(emptyString(), emptyString(), args); + } + + protected void assertOkWithOutput(Matcher outMatcher, Matcher errMatcher, String... args) throws Exception { + int status = executeMain(args); + assertThat(status, equalTo(ExitCodes.OK)); + assertThat(terminal.getErrorOutput(), errMatcher); + assertThat(terminal.getOutput(), outMatcher); + } + + protected void assertUsage(Matcher matcher, String... args) throws Exception { + terminal.reset(); + int status = executeMain(args); + assertThat(status, equalTo(ExitCodes.USAGE)); + assertThat(terminal.getErrorOutput(), matcher); + } } diff --git a/test/framework/src/main/java/org/elasticsearch/cli/MockTerminal.java b/test/framework/src/main/java/org/elasticsearch/cli/MockTerminal.java index dfb960c270810..fd1fa661bc151 100644 --- a/test/framework/src/main/java/org/elasticsearch/cli/MockTerminal.java +++ b/test/framework/src/main/java/org/elasticsearch/cli/MockTerminal.java @@ -100,7 +100,7 @@ public OutputStream getOutputStream() { } private static PrintWriter newPrintWriter(OutputStream out) { - return new PrintWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); + return new PrintWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8), true); } public static MockTerminal create() {