Replace most shell script logic with Java (#85758)

Elasticsearch provides several command line tools, as well as the main script to start elasticsearch. While most of the logic is abstracted away for cli tools, the main elasticsearch script has hundreds of lines of platform specific shell code. That code is difficult to maintain because it uses many special shell features which then must also exist in other platforms (ie windows batch files). Additionally, the logic in these scripts are not easy to test, we must be on the actual platform and test with a full installation of Elasticsearch, which is relatively slow (compared to most in process tests).

This commit replaces logic of the main server script, as well as the windows service management script, with Java. The new entrypoints use the CliToolLauncher. The server cli figures out all the jvm options and such necessary, then launches the real server process. If run in the foreground, the launcher will stay alive for the lifetime of Elasticsearch; the streams are effectively inherited so all output from Elasticsearch still goes to the console. If daemonizing, the launcher waits around until Elasticsearch is "ready" (this means the Node startup completed), then detaches and exits.

Co-authored-by: William Brafford <william.brafford@elastic.co>
This commit is contained in:
Ryan Ernst 2022-05-19 08:29:08 -07:00 committed by GitHub
parent 096d7fe67d
commit b9c504b892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 3114 additions and 1254 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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%

View file

@ -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:^>=^^^>!
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

View file

@ -11,6 +11,7 @@ package org.elasticsearch.launcher;
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 @@ import java.util.Map;
class CliToolLauncher {
private static final String SCRIPT_PREFIX = "elasticsearch-";
private static volatile Command command;
/**
* Runs a CLI tool.
*
@ -54,13 +57,15 @@ class CliToolLauncher {
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 @@ class CliToolLauncher {
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();
}
}

View file

@ -18,18 +18,13 @@ import org.elasticsearch.test.ESTestCase;
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<FileSystem> fileSystems = new ArrayList<>();
@ -54,48 +49,12 @@ public class BootstrapTests extends ESTestCase {
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);
}
}
}

View file

@ -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'
}

View file

@ -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.
*
* <p> 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:
* <ul>
* <li>{@link BootstrapInfo#USER_EXCEPTION_MARKER} - signals a bootstrap error has occurred, and is followed
* by the error message</li>
* <li>{@link BootstrapInfo#SERVER_READY_MARKER} - signals the server is ready so the cli may detach if daemonizing</li>
* </ul>
*/
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();
}
}
}

View file

@ -20,7 +20,6 @@ import java.nio.charset.StandardCharsets;
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 @@ final class JvmOptionsParser {
}
/**
* 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.
*

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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> {
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> T nonInterruptible(Interruptible<T> 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;
});
}
}

View file

@ -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<Path> pidfileOption;
private final OptionSpecBuilder quietOption;
private final OptionSpec<String> 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);
}
}

View file

@ -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();
}
}

View file

@ -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.
*
* <p> 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.
*
* <p> The caller starting a {@link ServerProcess} can then do one of several things:
* <ul>
* <li>Block on the server process exiting, by calling {@link #waitFor()}</li>
* <li>Detach from the server process by calling {@link #detach()}</li>
* <li>Tell the server process to shutdown and wait for it to exit by calling {@link #stop()}</li>
* </ul>
*/
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<String> 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.
*
* <p> This sends a special code, {@link BootstrapInfo#SERVER_SHUTDOWN_MARKER} to the stdin
* of the process, then waits for the process to exit.
*
* <p> 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<String, String> 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<String> 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<String> 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.
*
* <p> 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);
}
}

View file

@ -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());
}
}

View file

@ -0,0 +1 @@
org.elasticsearch.server.cli.ServerCliProvider

View file

@ -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<String> 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<String> 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<String> 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<ServerArgs> 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<String> 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;
}
};
}
}

View file

@ -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<String, String> sysprops = new HashMap<>();
protected final Map<String, String> 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<IOException> processException = new AtomicReference<>();
private final AtomicReference<AssertionError> 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<String> expectedJava = new AtomicReference<>(javaBin.resolve("java").toString());
AtomicReference<String> 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"));
}
}

View file

@ -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")
}

View file

@ -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 <a href="https://commons.apache.org/proper/commons-daemon/procrun.html">Apache Procrun Docs</a>
*/
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.
*
* <p> 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<String> 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<String, String> 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<String, String> 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();
}
}

View file

@ -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());
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}

View file

@ -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<String> 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<String> 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<String, String> 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<String, String> sysprops) {
List<String> 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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,2 @@
org.elasticsearch.windows.service.WindowsServiceCliProvider
org.elasticsearch.windows.service.WindowsServiceDaemonProvider

View file

@ -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<String, String> 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"));
}
}

View file

@ -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<String, String> env, ProcrunCall procrunCall);
}
record ProcrunCall(String exe, String command, String serviceId, Map<String, List<String>> 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<String, List<String>> 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<String> 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<String, String> expectedArgs) throws Exception {
mockProcessValidator = (environment, procrunCall) -> {
for (var expected : expectedArgs.entrySet()) {
List<String> 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());
}
}

View file

@ -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<String> 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<String> 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\""));
}
}

View file

@ -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'";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -67,7 +67,7 @@ public abstract class Command implements Closeable {
* 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 @@ public abstract class Command implements Closeable {
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) {

View file

@ -959,13 +959,16 @@ public class DockerTests extends PackagingTestCase {
* 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<ProcessInfo> 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 class DockerTests extends PackagingTestCase {
* 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<ProcessInfo> infos = ProcessInfo.getProcessInfo(sh, "tini");
assertThat(infos, hasSize(1));
ProcessInfo info = infos.get(0);
assertThat("Incorrect PID", info.pid(), equalTo(1));

View file

@ -21,7 +21,6 @@ import java.util.List;
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 class EnrollNodeToClusterTests extends PackagingTestCase {
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 class EnrollNodeToClusterTests extends PackagingTestCase {
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",

View file

@ -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 class WindowsServiceTests extends PackagingTestCase {
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 @@ public class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
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 class WindowsServiceTests extends PackagingTestCase {
}
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");

View file

@ -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.
* <p>
@ -27,32 +26,34 @@ public record ProcessInfo(int pid, int uid, int gid, String username, String gro
/**
* Fetches process information about <code>command</code>, using <code>sh</code> to execute commands.
*
* @return a populated <code>ProcessInfo</code> object
* @return a populated list of <code>ProcessInfo</code> objects
*/
public static ProcessInfo getProcessInfo(Shell sh, String command) {
public static List<ProcessInfo> getProcessInfo(Shell sh, String command) {
final List<String> processes = sh.run("pgrep " + command).stdout().lines().collect(Collectors.toList());
assertThat("Expected a single process", processes, hasSize(1));
List<ProcessInfo> infos = new ArrayList<>();
for (String pidStr : processes) {
// Ensure we actually have a number
final int pid = Integer.parseInt(pidStr.trim());
// Ensure we actually have a number
final int pid = Integer.parseInt(processes.get(0).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();
infos.add(new ProcessInfo(pid, uid, gid, username, group));
}
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);
return Collections.unmodifiableList(infos);
}
}

View file

@ -25,10 +25,10 @@ import org.elasticsearch.common.inject.CreationException;
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 @@ final class Bootstrap {
/**
* 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 @@ final class Bootstrap {
if (foreground == false) {
LogConfigurator.removeConsoleAppender();
sysOutCloser.run();
sysErrorCloser.run();
}
} catch (NodeValidationException | RuntimeException e) {
@ -386,16 +384,6 @@ final class Bootstrap {
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(

View file

@ -15,7 +15,7 @@ import java.nio.file.Path;
* 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 {

View file

@ -63,6 +63,27 @@ public final class BootstrapInfo {
*/
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

View file

@ -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);
}

View file

@ -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<Path> 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 @@ class Elasticsearch extends EnvironmentAwareCommand {
});
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 @@ class Elasticsearch extends EnvironmentAwareCommand {
}
}
/**
* 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 @@ class Elasticsearch extends EnvironmentAwareCommand {
}
}
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
void init(final boolean daemonize, final Path pidFile, final boolean quiet, Environment initialEnv, SecureString keystorePassword)
throws NodeValidationException, UserException {
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 {
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();
}
}

View file

@ -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 @@ final class Security {
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

View file

@ -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());
}
}

View file

@ -53,7 +53,7 @@ public abstract class KeyStoreAwareCommand extends EnvironmentAwareCommand {
}
Arrays.fill(passwordVerification, '\u0000');
} else {
passwordArray = terminal.readSecret("Enter password for the elasticsearch keystore : ");
passwordArray = terminal.readSecret(KeyStoreWrapper.PROMPT);
}
return new SecureString(passwordArray);
}

View file

@ -75,6 +75,8 @@ import javax.crypto.spec.SecretKeySpec;
*/
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 class KeyStoreWrapper implements SecureSettings {
Arrays.fill(characters, (char) 0);
}
// TODO: this doesn't need to be a supplier anymore
public static KeyStoreWrapper bootstrap(Path configDir, CheckedSupplier<SecureString, Exception> passwordSupplier) throws Exception {
KeyStoreWrapper keystore = KeyStoreWrapper.load(configDir);

View file

@ -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<String> 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<String> 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<String> 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<String> 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);
}
}
};
}
}

View file

@ -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',

View file

@ -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 abstract class CommandTestCase extends ESTestCase {
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<String> outMatcher, Matcher<String> 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<String> matcher, String... args) throws Exception {
terminal.reset();
int status = executeMain(args);
assertThat(status, equalTo(ExitCodes.USAGE));
assertThat(terminal.getErrorOutput(), matcher);
}
}

View file

@ -100,7 +100,7 @@ public class MockTerminal extends Terminal {
}
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() {