/**
* diqube: Distributed Query Base.
*
* Copyright (C) 2015 Bastian Gloeckle
*
* This file is part of diqube.
*
* diqube is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.diqube.itest.control;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.diqube.config.ConfigKey;
import org.diqube.config.ConfigurationManager;
import org.diqube.connection.ConnectionFactory;
import org.diqube.connection.DefaultDiqubeConnectionFactoryTestUtil;
import org.diqube.itest.annotations.NeedsProcessPid;
import org.diqube.itest.util.ProcessPidUtil;
import org.diqube.itest.util.ServiceTestUtil;
import org.diqube.itest.util.Waiter;
import org.diqube.itest.util.Waiter.WaitTimeoutException;
import org.diqube.server.ControlFileManager;
import org.diqube.server.control.ControlFileLoader;
import org.diqube.thrift.base.services.DiqubeThriftServiceInfoManager;
import org.diqube.thrift.base.thrift.RNodeAddress;
import org.diqube.thrift.base.thrift.RNodeDefaultAddress;
import org.diqube.thrift.base.thrift.Ticket;
import org.diqube.util.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.io.ByteStreams;
/**
* Controls a single diqube-server
*
* @author Bastian Gloeckle
*/
public class ServerControl implements LogfileSaver {
private static final Logger logger = LoggerFactory.getLogger(ServerControl.class);
private static final String LOGBACK_DEFAULT_CONFIG_CLASSPATH = "/server/logback.default.xml";
private static final String TICKET_PEM_CLASSPATH = "/server/ticket.pem";
private static final String TICKET_PEM_PASSWORD = "diqube";
public static final String ROOT_PASSWORD = "diqube";
public static final String ROOT_USER = "root";
private File serverJarFile;
private File workDir;
/** The file containing the servers logback.xml configuration. */
private File logbackConfig;
/** File the server logs its log-output to. */
private File serverLog;
/** The server.properties the server uses. Valid after started. */
private File serverPropertiesFile;
private Process serverProcess = null;
/** true if {@link #checkLivelinessThread} reported that the process died. */
private AtomicBoolean serverDied = new AtomicBoolean(false);
private CheckLivelinessThread checkLivelinessThread;
private ServerAddressProvider addressProvider;
private ServerClusterNodesProvider clusterNodesProvider;
/** Address of our server. Only valid while started. */
private ServerAddr ourAddr;
/** Control over this server was manually overridden, this class cannot start/stop this server! */
private boolean manualOverride;
private ProcessOutputReadThread processOutputReadThread;
private ServiceTestUtil serviceTestUtil;
/** key for HMAC the server uses for thrift access */
private String serverMacKey;
private File ticketPem;
public ServerControl(File serverJarFile, File workDir, ServerAddressProvider addressProvider,
ServerClusterNodesProvider clusterNodesProvider, boolean manualOverride) {
this.serverJarFile = serverJarFile;
this.workDir = workDir;
this.addressProvider = addressProvider;
this.clusterNodesProvider = clusterNodesProvider;
this.manualOverride = manualOverride;
serverLog = new File(workDir, "server.log");
logbackConfig = createLogbackConfig(serverLog);
ticketPem = createTicketPem();
}
public void start() {
start(null);
}
public void start(Consumer<Properties> serverPropertiesAdjust) {
if (!manualOverride && isStarted())
throw new RuntimeException("Server is started already.");
ourAddr = addressProvider.reserveAddress();
serverMacKey = "thisisatest";
if (manualOverride) {
logger.info("Using already started server at port {}, not starting a separate one!", ourAddr.getPort());
serverDied.set(false);
serverProcess = null;
checkLivelinessThread = null;
} else {
Properties serverProp = new Properties();
serverProp.setProperty(ConfigKey.OUR_HOST, ourAddr.getHost());
serverProp.setProperty(ConfigKey.PORT, Short.toString(ourAddr.getPort()));
serverProp.setProperty(ConfigKey.DATA_DIR, workDir.getAbsolutePath());
serverProp.setProperty(ConfigKey.CLUSTER_NODES, clusterNodesProvider.getClusterNodeConfigurationString(ourAddr));
serverProp.setProperty(ConfigKey.BIND, ourAddr.getHost()); // bind only to our addr, which is typically 127.0.0.1
serverProp.setProperty(ConfigKey.MESSAGE_INTEGRITY_SECRET, serverMacKey);
serverProp.setProperty(ConfigKey.TICKET_RSA_PRIVATE_KEY_PEM_FILE, ticketPem.getAbsolutePath());
serverProp.setProperty(ConfigKey.TICKET_RSA_PRIVATE_KEY_PASSWORD, TICKET_PEM_PASSWORD);
serverProp.setProperty(ConfigKey.SUPERUSER, ROOT_USER);
serverProp.setProperty(ConfigKey.SUPERUSER_PASSWORD, ROOT_PASSWORD);
if (serverPropertiesAdjust != null)
serverPropertiesAdjust.accept(serverProp);
serverPropertiesFile = new File(workDir, "server.properties");
try (FileOutputStream propWrite = new FileOutputStream(serverPropertiesFile)) {
serverProp.store(propWrite, "");
} catch (IOException e1) {
throw new RuntimeException("Could not write " + serverPropertiesFile.getAbsolutePath());
}
ProcessBuilder processBuilder = new ProcessBuilder("java",
"-D" + ConfigurationManager.CUSTOM_PROPERTIES_SYSTEM_PROPERTY + "=" + serverPropertiesFile.getAbsolutePath(),
"-Dlogback.configurationFile=" + logbackConfig.getAbsolutePath(), "-jar", serverJarFile.getAbsolutePath());
processBuilder.redirectErrorStream(true);
serverDied.set(false);
logger.info("Starting server at {}", ourAddr);
try {
serverProcess = processBuilder.start();
} catch (IOException e) {
throw new RuntimeException("Could not start server.", e);
}
checkLivelinessThread = new CheckLivelinessThread(serverProcess, ourAddr);
checkLivelinessThread.start();
processOutputReadThread = new ProcessOutputReadThread(serverProcess.getInputStream(), ourAddr);
processOutputReadThread.start();
try {
// seems to be a good idea to close the input of the process right away as we'll never send any data to it.
serverProcess.getOutputStream().close();
} catch (IOException e) {
logger.debug("Could not close input pipe of server process", e);
}
waitUntilServerIsRunning(ourAddr);
}
}
public void stop() throws ShutdownForciblyException {
if (serverDied.get()) {
serverDied.set(false);
throw new RuntimeException("The server " + ourAddr + " died unexpectedly before.");
}
if (!isStarted()) {
logger.info("Server {} is stopped already.", ourAddr);
return;
}
if (!manualOverride) {
checkLivelinessThread.interrupt();
processOutputReadThread.interrupt();
try {
checkLivelinessThread.join();
processOutputReadThread.join();
} catch (InterruptedException e1) {
// swallow.
}
}
serverDied.set(false);
if (!manualOverride) {
logger.info("Stopping server {}", ourAddr);
try {
serverProcess.destroy();
boolean stopped = serverProcess.waitFor(10, TimeUnit.SECONDS);
// cleanup ready files
for (File readyFile : workDir.listFiles((dir, file) -> file.endsWith(ControlFileManager.READY_FILE_EXTENSION)))
if (!readyFile.delete())
logger.warn("Could not delete ready file {}", readyFile);
if (!stopped) {
logger.error("The server {} did not stop, killing it forcibly.", ourAddr);
serverProcess.destroyForcibly();
throw new ShutdownForciblyException("The server " + ourAddr + " did not stop, killed it forcibly.");
}
} catch (InterruptedException e) {
throw new RuntimeException("Interrupted while waiting the server " + ourAddr + " to stop.", e);
}
} else
logger.warn("Cannot stop server at {} as it was manually overridden!", ourAddr);
}
/**
* Create a thread dump of the running server and output it to the given file.
*
* <p>
* This depends on
* <ul>
* <li>{@link ProcessPidUtil} to work. Use {@link NeedsProcessPid} annotation on test method.
* <li>"jstack" to be installed on the host machine
* </ul>
*/
public void createThreadDump(OutputStream outstream) {
int serverPid = ProcessPidUtil.getPid(serverProcess);
ProcessBuilder jstackProcessBuilder = new ProcessBuilder("jstack", Integer.toString(serverPid));
try {
Process jstackProcess = jstackProcessBuilder.start();
for (int i = 0; i < 10; i++) {
jstackProcess.waitFor(1, TimeUnit.SECONDS);
ByteStreams.copy(jstackProcess.getInputStream(), outstream);
}
ByteStreams.copy(jstackProcess.getInputStream(), outstream);
if (!jstackProcess.waitFor(1, TimeUnit.SECONDS)) {
jstackProcess.destroyForcibly();
throw new RuntimeException("jstack process did not shut down, killed it forcibly.");
}
ByteStreams.copy(jstackProcess.getInputStream(), outstream);
} catch (InterruptedException e) {
throw new RuntimeException("Interrupted", e);
} catch (IOException e) {
throw new RuntimeException("IOException while interacting with jstack.", e);
}
}
/**
* All the output of the server process.
*/
public String getServerLogOutput() {
return new String(processOutputReadThread.getCurrentBytes(), Charset.forName("UTF-8"));
}
public boolean isStarted() {
return manualOverride || serverProcess != null && serverProcess.isAlive();
}
public ServiceTestUtil getSerivceTestUtil() {
if (serviceTestUtil == null) {
DiqubeThriftServiceInfoManager infoManager = new DiqubeThriftServiceInfoManager();
infoManager.initialize();
ConnectionFactory connectionFactory =
DefaultDiqubeConnectionFactoryTestUtil.createDefaultConnectionFactory(serverMacKey);
serviceTestUtil = new ServiceTestUtil(this, connectionFactory, infoManager);
}
return serviceTestUtil;
}
private File readyFile(File controlFile) {
return new File(controlFile.getParentFile(),
controlFile.getName().substring(0,
controlFile.getName().length() - ControlFileManager.CONTROL_FILE_EXTENSION.length())
+ ControlFileManager.READY_FILE_EXTENSION);
}
public void deploy(File controlFile, File dataFile) throws WaitTimeoutException {
Properties control = new Properties();
try (FileInputStream is = new FileInputStream(controlFile)) {
control.load(new InputStreamReader(is, Charset.forName("UTF-8")));
} catch (IOException e) {
throw new RuntimeException("Could not load control file properties from " + controlFile.getAbsolutePath());
}
control.setProperty(ControlFileLoader.KEY_FILE, dataFile.getAbsolutePath());
// put the adjusted .control file into workDir, as our server is watching that directory!
File realControlFile = new File(workDir, controlFile.getName());
try (FileOutputStream fos = new FileOutputStream(realControlFile)) {
control.store(new OutputStreamWriter(fos, Charset.forName("UTF-8")), "");
} catch (IOException e) {
throw new RuntimeException("Could not store control file to " + realControlFile.getAbsolutePath());
}
// wait for ready file to get present, if started
if (isStarted())
waitUntilDeployed(controlFile);
logger.info("Deployment complete.");
}
public void waitUntilDeployed(File controlFile) {
File realControlFile = new File(workDir, controlFile.getName());
File readyFile = readyFile(realControlFile);
new Waiter().waitUntil("Ready file " + readyFile.getAbsolutePath() + " to be available", 120, 500,
() -> readyFile.exists());
}
public void undeploy(File controlFile) {
File realControlFile = new File(workDir, controlFile.getName());
if (!realControlFile.delete())
throw new RuntimeException("Could not delete control file " + realControlFile.getAbsolutePath());
// wait until ready file is deleted, too, if started
if (isStarted())
waitUntilUndeployed(controlFile);
logger.info("Undeployment complete.");
}
public void waitUntilUndeployed(File controlFile) {
File realControlFile = new File(workDir, controlFile.getName());
File readyFile = readyFile(realControlFile);
new Waiter().waitUntil("Ready file " + readyFile.getAbsolutePath() + " is deleted", 20, 100,
() -> !readyFile.exists());
}
public ServerAddr getAddr() {
return ourAddr;
}
@Override
public void saveLogfiles(File targetDir) {
for (File logFile : Arrays.asList(serverLog, serverPropertiesFile)) {
if (logFile.exists())
try {
Files.copy(logFile.toPath(), targetDir.toPath().resolve(logFile.getName()));
} catch (IOException e) {
logger.warn("Could not copy logfile {} to {}", logFile.getAbsolutePath(),
targetDir.toPath().resolve(logFile.getName()), e);
}
}
}
private void waitUntilServerIsRunning(ServerAddr addr) {
new Waiter().waitUntil("Server at " + addr + " to start up", 10, 100, () -> {
try {
getSerivceTestUtil().keepAliveService(service -> service.ping());
// ping succeeded, server started up!
logger.info("Server at {} has started successfully.", addr);
return true;
} catch (RuntimeException e) {
return false;
}
});
}
public byte[] getServerMacKey() {
return serverMacKey.getBytes(Charset.forName("UTF-8"));
}
/**
* Logs in the root user (the superuser) and returns a valid ticket.
*/
public Ticket loginSuperuser() {
return login(ROOT_USER, ROOT_PASSWORD);
}
public Ticket login(String username, String password) {
CompletableFuture<Ticket> f = new CompletableFuture<>();
getSerivceTestUtil().identityService(identityService -> {
f.complete(identityService.login(username, password));
});
try {
return f.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("Could not login!", e);
}
}
/**
* Create a file containing a logback configuration which logs to the given logfile. Additionally it will log to
* STDOUT, but only the messages of the logs (no loglevel, time etc.).
*/
private File createLogbackConfig(File serverLog) {
InputStream is = this.getClass().getResourceAsStream(LOGBACK_DEFAULT_CONFIG_CLASSPATH);
File logbackOut = new File(workDir, "logback.xml");
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ByteStreams.copy(is, baos);
String logbackConfig = new String(baos.toByteArray(), Charset.forName("UTF-8"));
logbackConfig = logbackConfig.replace("{{logfile}}", serverLog.getAbsolutePath().replace("\\", "\\\\"));
byte[] replacedLogbackConfig = logbackConfig.getBytes("UTF-8");
try (FileOutputStream fos = new FileOutputStream(logbackOut)) {
fos.write(replacedLogbackConfig);
}
} catch (IOException e) {
throw new RuntimeException("Could not create logback config", e);
}
return logbackOut;
}
private File createTicketPem() {
InputStream is = this.getClass().getResourceAsStream(TICKET_PEM_CLASSPATH);
File ticketPemOut = new File(workDir, "ticket.pem");
try (FileOutputStream fos = new FileOutputStream(ticketPemOut)) {
ByteStreams.copy(is, fos);
} catch (IOException e) {
throw new RuntimeException("Could not create ticket.pem", e);
}
return ticketPemOut;
}
/**
* A {@link Thread} that continuously checks if the process of the server is still alive.
*/
private class CheckLivelinessThread extends Thread {
private Process serverProcess;
private ServerAddr ourAddr;
public CheckLivelinessThread(Process serverProcess, ServerAddr ourAddr) {
super("server-liveliness-" + ourAddr.getHost() + "-" + ourAddr.getPort());
this.serverProcess = serverProcess;
this.ourAddr = ourAddr;
}
@Override
public void run() {
Object sync = new Object();
while (true) {
synchronized (sync) {
try {
sync.wait(1000);
} catch (InterruptedException e) {
// quiet exit.
return;
}
}
if (!serverProcess.isAlive()) {
logger.error("Server {} died unexpectedly.", ourAddr);
serverDied.set(true);
return;
}
}
}
}
/**
* Thread that continuously reads from an {@link InputStream} and provides all the read bytes.
*/
private class ProcessOutputReadThread extends Thread {
private InputStream streamToReadFrom;
private ByteArrayOutputStream bytesReadStream = new ByteArrayOutputStream();
public ProcessOutputReadThread(InputStream streamToReadFrom, ServerAddr server) {
super("server-output-read-" + server.getHost() + "-" + server.getPort());
this.streamToReadFrom = streamToReadFrom;
}
@Override
public void run() {
Object sync = new Object();
while (true) {
synchronized (sync) {
try {
sync.wait(10);
} catch (InterruptedException e) {
// quiet exit
return;
}
}
int read;
try {
if (streamToReadFrom.available() > 0) {
byte[] buf = new byte[streamToReadFrom.available()];
if ((read = streamToReadFrom.read(buf)) > 0) {
bytesReadStream.write(buf, 0, read);
}
}
} catch (IOException e) {
logger.error("IOException while reading from server process", e);
throw new RuntimeException("IOException while reading from server process", e);
}
}
}
public byte[] getCurrentBytes() {
return bytesReadStream.toByteArray();
}
}
/**
* Represents the address of a server consisting of a host and a port.
*/
public static class ServerAddr extends Pair<String, Short> {
public ServerAddr(String left, Short right) {
super(left, right);
}
public String getHost() {
return getLeft();
}
public short getPort() {
return getRight();
}
@Override
public String toString() {
return getHost() + ":" + getPort();
}
public RNodeAddress toRNodeAddress() {
RNodeAddress res = new RNodeAddress();
res.setDefaultAddr(new RNodeDefaultAddress());
res.getDefaultAddr().setHost(getHost());
res.getDefaultAddr().setPort(getPort());
return res;
}
}
/**
* Provides a new server address to a starting server. The port has to be unbound currently.
*/
public static interface ServerAddressProvider {
public ServerAddr reserveAddress();
}
/**
* Provides the addresses of other cluster nodes a new server should bind to in form of the configuration string for
* {@link ConfigKey#CLUSTER_NODES}.
*/
public static interface ServerClusterNodesProvider {
/**
* Calculates and returns the string to be used for {@link ConfigKey#CLUSTER_NODES} to be used for the given cluster
* node.
*/
public String getClusterNodeConfigurationString(ServerAddr serverAddr);
}
public static class ShutdownForciblyException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ShutdownForciblyException(String msg) {
super(msg);
}
public ShutdownForciblyException(String msg, Throwable cause) {
super(msg, cause);
}
}
}