com.cinchapi.concourse.server.ManagedConcourseServer Maven / Gradle / Ivy
Show all versions of concourse-ete-test-core Show documentation
/*
* Copyright (c) 2013-2017 Cinchapi Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.cinchapi.concourse.server;
import java.io.File;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.ServerSocket;
import java.net.SocketException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.text.MessageFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.management.MBeanServerConnection;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;
import jline.TerminalFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
import com.cinchapi.ccl.grammar.Symbol;
import com.cinchapi.common.base.ArrayBuilder;
import com.cinchapi.common.base.CheckedExceptions;
import com.cinchapi.common.process.Processes;
import com.cinchapi.common.process.Processes.ProcessResult;
import com.cinchapi.common.reflect.Reflection;
import com.cinchapi.concourse.Calculator;
import com.cinchapi.concourse.Concourse;
import com.cinchapi.concourse.DuplicateEntryException;
import com.cinchapi.concourse.Link;
import com.cinchapi.concourse.Timestamp;
import com.cinchapi.concourse.config.ConcourseClientPreferences;
import com.cinchapi.concourse.config.ConcourseServerPreferences;
import com.cinchapi.concourse.lang.Criteria;
import com.cinchapi.concourse.thrift.Diff;
import com.cinchapi.concourse.thrift.Operator;
import com.cinchapi.concourse.time.Time;
import com.cinchapi.concourse.util.ConcourseServerDownloader;
import com.cinchapi.concourse.util.FileOps;
import com.cinchapi.concourse.util.Strings;
import com.google.common.base.Stopwatch;
import com.google.common.base.Throwables;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
/**
* A {@link ManagedConcourseServer} is an external server process that can be
* programmatically controlled within another application. This class is useful
* for applications that want to "embed" a Concourse Server for the duration of
* the application's life cycle and then forget about its existence afterwards.
*
* @author jnelson
*/
public class ManagedConcourseServer {
/**
* Return an {@link ManagedConcourseServer} that controls an instance
* located in the {@code installDirectory}.
*
* @param installDirectory
* @return the ManagedConcourseServer
*/
public static ManagedConcourseServer manageExistingServer(
String installDirectory) {
return new ManagedConcourseServer(installDirectory);
}
/**
* Create an {@link ManagedConcourseServer} from the {@code installer}.
*
* @param installer
* @return the ManagedConcourseServer
*/
public static ManagedConcourseServer manageNewServer(File installer) {
return manageNewServer(installer,
DEFAULT_INSTALL_HOME + File.separator + Time.now());
}
/**
* Create an {@link ManagedConcourseServer} from the {@code installer} in
* {@code directory}.
*
* @param installer
* @param directory
* @return the ManagedConcourseServer
*/
public static ManagedConcourseServer manageNewServer(File installer,
String directory) {
return new ManagedConcourseServer(
install(installer.getAbsolutePath(), directory));
}
/**
* Create an {@link ManagedConcourseServer} at {@code version}.
*
* @param version
* @return the ManagedConcourseServer
*/
public static ManagedConcourseServer manageNewServer(String version) {
return manageNewServer(version,
DEFAULT_INSTALL_HOME + File.separator + Time.now());
}
/**
* Create an {@link ManagedConcourseServer} at {@code version} in
* {@code directory}.
*
* @param version
* @param directory
* @return the ManagedConcourseServer
*/
public static ManagedConcourseServer manageNewServer(String version,
String directory) {
return manageNewServer(
new File(ConcourseServerDownloader.download(version)),
directory);
}
/**
* Tweak some of the preferences to make this more palatable for testing
* (i.e. reduce the possibility of port conflicts, etc).
*
* @param installDirectory
*/
private static void configure(String installDirectory) {
ConcourseServerPreferences prefs = ConcourseServerPreferences
.open(installDirectory + File.separator + CONF + File.separator
+ "concourse.prefs");
String data = installDirectory + File.separator + "data";
prefs.setBufferDirectory(data + File.separator + "buffer");
prefs.setDatabaseDirectory(data + File.separator + "database");
prefs.setClientPort(getOpenPort());
prefs.setJmxPort(getOpenPort());
prefs.setLogLevel(Level.DEBUG);
prefs.setShutdownPort(getOpenPort());
}
/**
* Collect and return all the {@code jar} files that are located in the
* directory at {@code path}. If {@code path} is not a directory, but is
* instead, itself, a jar file, then return a list that contains in.
*
* @param path
* @return the list of jar file URL paths
*/
private static URL[] gatherJars(String path) {
List jars = Lists.newArrayList();
gatherJars(path, jars);
return jars.toArray(new URL[] {});
}
/**
* Collect all the {@code jar} files that are located in the directory at
* {@code path} and place them into the list of {@code jars}. If
* {@code path} is not a directory, but is instead, itself a jar file, then
* place it in the list.
*
* @param path
* @param jars
*/
private static void gatherJars(String path, List jars) {
try {
if(Files.isDirectory(Paths.get(path))) {
for (Path p : Files.newDirectoryStream(Paths.get(path))) {
gatherJars(p.toString(), jars);
}
}
else if(path.endsWith(".jar")) {
jars.add(new URL("file://" + path.toString()));
}
}
catch (IOException e) {
throw Throwables.propagate(e);
}
}
/**
* Get an open port.
*
* @return the port
*/
private static int getOpenPort() {
int min = 49512;
int max = 65535;
int port = min + RAND.nextInt(max - min);
return isPortAvailable(port) ? port : getOpenPort();
}
/**
* Install a Concourse Server in {@code directory} using {@code installer}.
*
* @param installer
* @param directory
* @return the server install directory
*/
private static String install(String installer, String directory) {
try {
Files.createDirectories(Paths.get(directory));
Path binary = Paths
.get(directory + File.separator + TARGET_BINARY_NAME);
Files.deleteIfExists(binary);
Files.copy(Paths.get(installer), binary);
ProcessBuilder builder = new ProcessBuilder(Lists.newArrayList("sh",
binary.toString(), "--", "skip-integration"));
builder.directory(new File(directory));
builder.redirectErrorStream();
Process process = builder.start();
// The concourse-server installer prompts for an admin password in
// order to make optional system wide In order to get around this
// prompt, we have to "kill" the process, otherwise the server
// install will hang.
Stopwatch watch = Stopwatch.createStarted();
while (watch.elapsed(TimeUnit.SECONDS) < 1) {
continue;
}
watch.stop();
process.destroy();
TerminalFactory.get().restore();
String application = directory + File.separator
+ "concourse-server"; // the install directory for the
// concourse-server application
process = Runtime.getRuntime().exec("ls " + application);
List output = Processes.getStdOut(process);
if(!output.isEmpty()) {
// delete the dev prefs because those would take precedence over
// what is configured in this class
Files.deleteIfExists(
Paths.get(application, "conf/concourse.prefs.dev"));
configure(application);
log.info("Successfully installed server in {}", application);
return application;
}
else {
throw new RuntimeException(MessageFormat.format(
"Unsuccesful attempt to " + "install server at {0} "
+ "using binary from {1}",
directory, installer));
}
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Return {@code true} if the {@code port} is available on the local
* machine.
*
* @param port
* @return {@code true} if the port is available
*/
private static boolean isPortAvailable(int port) {
try {
new ServerSocket(port).close();
return true;
}
catch (SocketException e) {
return false;
}
catch (IOException e) {
throw Throwables.propagate(e);
}
}
private static final String BIN = "bin";
// ---relative paths
private static final String CONF = "conf";
/**
* The default location where the the test server is installed if a
* particular location is not specified.
*/
private static final String DEFAULT_INSTALL_HOME = System
.getProperty("user.home") + File.separator + ".concourse-testing";
// ---logger
private static final Logger log = LoggerFactory
.getLogger(ManagedConcourseServer.class);
// ---random
private static final Random RAND = new Random();
/**
* The filename of the binary installer from which the test server will be
* created.
*/
private static final String TARGET_BINARY_NAME = "concourse-server.bin";
/**
* A flag that determines how the concourse_client.prefs file should be
* handled when this server is {@link #destroy() destroyed}. Generally,
* nothing is done to the prefs file unless
* {@link #syncDefaultClientConnectionInfo()} was called by the client.
*/
private ClientPrefsCleanupAction clientPrefsCleanupAction = ClientPrefsCleanupAction.NONE;
/**
* The server application install directory;
*/
private final String installDirectory;
/**
* A connection to the remote MBean server running in the managed
* concourse-server process.
*/
private MBeanServerConnection mBeanServerConnection = null;
/**
* The handler for the server's preferences.
*/
private final ConcourseServerPreferences prefs;
/**
* Construct a new instance.
*
* @param installDirectory
*/
private ManagedConcourseServer(String installDirectory) {
this.installDirectory = installDirectory;
this.prefs = ConcourseServerPreferences.open(installDirectory
+ File.separator + CONF + File.separator + "concourse.prefs");
prefs.setLogLevel(Level.DEBUG);
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run() {
destroy();
}
}));
}
/**
* Return a connection handler to the server using the default "admin"
* credentials.
*
* @return the connection handler
*/
public Concourse connect() {
return connect("admin", "admin");
}
/**
* Return a connection handler to the server using the specified
* {@code username} and {@code password}.
*
* @param username
* @param password
* @return the connection handler
*/
public Concourse connect(String username, String password) {
return new Client(username, password);
}
/**
* Stop the server, if it is running, and permanently delete the application
* files and associated data.
*/
public void destroy() {
if(Files.exists(Paths.get(installDirectory))) { // check if server has
// been manually
// destroyed
if(isRunning()) {
stop();
}
try {
Path prefs = Paths.get("concourse_client.prefs")
.toAbsolutePath();
if(clientPrefsCleanupAction == ClientPrefsCleanupAction.RESTORE_BACKUP) {
Path backup = Paths.get("concourse_client.prefs.bak")
.toAbsolutePath();
Files.move(backup, prefs,
StandardCopyOption.REPLACE_EXISTING);
log.info("Restored original client prefs from {} to {}",
backup, prefs);
}
else if(clientPrefsCleanupAction == ClientPrefsCleanupAction.DELETE) {
Files.delete(prefs);
log.info("Deleted client prefs from {}", prefs);
}
deleteDirectory(
Paths.get(installDirectory).getParent().toString());
log.info("Deleted server install directory at {}",
installDirectory);
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
}
/**
* Execute the specified {@code cli} with the provided {@code args}.
*
* This is the equivalent of calling {@code concourse }
* on the command line
*
*
* @param cli the name of the CLI to execute
* @param args the args to pass to the cli
* @return the standard output from executing the cli
*/
public List executeCli(String cli, String... args) {
try {
ArrayBuilder args0 = ArrayBuilder.builder();
args0.add("./concourse");
args0.add(cli);
for (String arg : args) {
args0.add(arg.split("\\s"));
}
Process process = new ProcessBuilder(args0.build())
.directory(
new File(installDirectory + File.separator + BIN))
.start();
ProcessResult result = Processes.waitFor(process);
if(result.exitCode() == 0) {
return result.out();
}
else {
log.warn("An error occurred executing '{}': {}", cli,
result.err());
return result.err();
}
}
catch (IOException e) {
throw Throwables.propagate(e);
}
}
/**
* Return the client port for this server.
*
* @return the client port
*/
public int getClientPort() {
return prefs.getClientPort();
}
/**
* Get a collection of stats about the heap memory usage for the managed
* concourse-server process.
*
* @return the heap memory usage
*/
public MemoryUsage getHeapMemoryStats() {
try {
MemoryMXBean memory = ManagementFactory.newPlatformMXBeanProxy(
getMBeanServerConnection(),
ManagementFactory.MEMORY_MXBEAN_NAME, MemoryMXBean.class);
return memory.getHeapMemoryUsage();
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Return the {@link #installDirectory} for this server.
*
* @return the install directory
*/
public String getInstallDirectory() {
return installDirectory;
}
/**
* Return the connection to the MBean sever of the managed concourse-server
* process.
*
* @return the mbean server connection
*/
public MBeanServerConnection getMBeanServerConnection() {
if(mBeanServerConnection == null) {
try {
JMXServiceURL url = new JMXServiceURL(
"service:jmx:rmi:///jndi/rmi://localhost:"
+ prefs.getJmxPort() + "/jmxrmi");
JMXConnector connector = JMXConnectorFactory.connect(url);
mBeanServerConnection = connector.getMBeanServerConnection();
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
return mBeanServerConnection;
}
/**
* Get a collection of stats about the non heap memory usage for the managed
* concourse-server process.
*
* @return the non-heap memory usage
*/
public MemoryUsage getNonHeapMemoryStats() {
try {
MemoryMXBean memory = ManagementFactory.newPlatformMXBeanProxy(
getMBeanServerConnection(),
ManagementFactory.MEMORY_MXBEAN_NAME, MemoryMXBean.class);
return memory.getNonHeapMemoryUsage();
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Install the plugin(s) contained in the {@code bundle} on this
* {@link ManagedConcourseServer}.
*
* @param bundle the path to the plugin bundle
* @return {@code true} if the plugin(s) from the bundle is/are installed
*/
public boolean installPlugin(Path bundle) {
log.info("Attempting to install plugins from {}", bundle);
List out = executeCli("plugin", "install", bundle.toString(),
"--username admin", "--password admin");
for (String line : out) {
if(line.contains("Successfully installed")) {
return true;
}
}
throw new RuntimeException(Strings
.format("Unable to install plugin '{}': {}", bundle, out));
}
/**
* Return {@code true} if the server is currently running.
*
* @return {@code true} if the server is running
*/
public boolean isRunning() {
return Iterables.get(execute("concourse", "status"), 0)
.contains("is running");
}
/**
* Print the content of the log file with {@code name} to the console.
*
* @param name the name of the log file (i.e. console)
*/
public void printLog(String name) {
// NOTE: This method does not currently print contents of archived log
// files. This is intentional because we assume that any interesting log
// information that needs to be printed will be in the most recent file.
String logdir = Paths.get(installDirectory, "log").toString();
String file = Paths.get(logdir, name + ".log").toString();
String content = FileOps.read(file);
System.err.println(file);
for (int i = 0; i < file.length(); ++i) {
System.err.print('-');
}
System.err.println();
System.err.println(content);
}
/**
* Print the content of the log files for each of the log {@code levels} to
* the console.
*
* @param levels the log levels to print
*/
public void printLogs(LogLevel... levels) {
for (LogLevel level : levels) {
String name = level.name().toLowerCase();
printLog(name);
}
}
/**
* Start the server.
*/
public void start() {
try {
for (String line : execute("start")) {
log.info(line);
}
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Stop the server.
*/
public void stop() {
try {
for (String line : execute("stop")) {
log.info(line);
}
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Copy the connection information for this managed server to a
* {@code concourse_client.prefs} file located in the root of the working
* directory so that source code relying on the default connection behaviour
* will properly connect to this server.
*
* A test case that uses an indirect connection to Concourse (i.e. the test
* case doesn't directly use the provided {@code client} variable provided
* by the framework, but uses classes from the application source code that
* has its own mechanism for connecting to Concourse) SHOULD call this
* method so that the application code will connect to the this server for
* the purpose of the unit test.
*
*
* Any connection information that is synchronized will be cleaned up after
* the test. If a prefs file already existed in the root of the working
* directory, that file is backed up and restored so that the application
* can run normally outside of the test cases.
*
*/
public void syncDefaultClientConnectionInfo() {
try {
Path prefs = Paths.get("concourse_client.prefs").toAbsolutePath();
if(Files.exists(prefs)) {
Path backup = Paths.get("concourse_client.prefs.bak")
.toAbsolutePath();
Files.move(prefs, backup);
clientPrefsCleanupAction = ClientPrefsCleanupAction.RESTORE_BACKUP;
log.info("Took backup for client prefs file located at {}. "
+ "The backup is stored in {}", prefs, backup);
}
else {
clientPrefsCleanupAction = ClientPrefsCleanupAction.DELETE;
}
log.info(
"Synchronizing the managed server's connection "
+ "information to the client prefs file at {}",
prefs);
ConcourseClientPreferences ccp = ConcourseClientPreferences
.open(FileOps.touch(prefs.toString()));
ccp.setPort(getClientPort());
ccp.setUsername("admin");
ccp.setPassword("admin".toCharArray());
}
catch (IOException e) {
throw Throwables.propagate(e);
}
}
/**
* Recursively delete a directory and all of its contents.
*
* @param directory
*/
private void deleteDirectory(String directory) {
try {
File dir = new File(directory);
for (File file : dir.listFiles()) {
if(file.isDirectory()) {
deleteDirectory(file.getAbsolutePath());
}
else {
file.delete();
}
}
dir.delete();
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Execute a command line interface script and return the output.
*
* @param cli
* @param args
* @return the script output
*/
private List execute(String cli, String... args) {
try {
String command = "bash " + cli;
for (String arg : args) {
command += " " + arg;
}
Process process = Runtime.getRuntime().exec(command, null,
new File(installDirectory + File.separator + BIN));
process.waitFor();
if(process.exitValue() == 0) {
return Processes.getStdOut(process);
}
else {
log.warn("An error occurred executing '{}': {}", command,
Processes.getStdErr(process));
return Collections.emptyList();
}
}
catch (Exception e) {
throw Throwables.propagate(e);
}
}
/**
* Enum for log levels that can be passed to the
* {@link #printLogs(LogLevel...)} method
*
* @author Jeff Nelson
*/
public enum LogLevel {
CONSOLE, DEBUG, ERROR, INFO, WARN
}
/**
* A class that extends {@link Concourse} with additional methods. This
* abstraction allows casting while keeping the {@link Client} class
* private.
*
* @author Jeff Nelson
*/
public abstract class ReflectiveClient extends Concourse {
/**
* Reflectively call a client method. This is useful for cases where
* this API depends on an older version of the Concourse API and a test
* needs to use a method added in a later version. This procedure will
* work because the underlying client delegates to a client that uses
* the classpath of the managed server.
*
* @param methodName
* @param args
* @return the result
*/
public abstract T call(String methodName, Object... args);
}
/**
* The valid options for the {@link #clientPrefsCleanupAction} variable.
*
* @author Jeff Nelson
*/
enum ClientPrefsCleanupAction {
DELETE, NONE, RESTORE_BACKUP
}
/**
* A {@link Concourse} client wrapper that delegates to the jars located in
* the server's lib directory so that it uses the same version of the code.
*
* @author jnelson
*/
private final class Client extends ReflectiveClient {
private Class> clazz;
private Object delegate;
private ClassLoader loader;
/**
* The top level package under which all Concourse classes exist in the
* remote server.
*/
private String packageBase = "com.cinchapi.concourse.";
/**
* Construct a new instance.
*
* @param username
* @param password
* @throws Exception
*/
public Client(String username, String password) {
this(username, password, 5);
}
/**
* Construct a new instance.
*
* @param username
* @param password
* @param retries
*/
private Client(String username, String password, int retries) {
while (retries > 0) {
--retries;
try {
this.loader = new URLClassLoader(
gatherJars(getInstallDirectory()), null);
try {
clazz = loader.loadClass(packageBase + "Concourse");
}
catch (ClassNotFoundException e) {
// Prior to version 0.5.0, Concourse classes were
// located in the "org.cinchapi.concourse" package, so
// we attempt to use that if the default does not work.
packageBase = "org.cinchapi.concourse.";
clazz = loader.loadClass(packageBase + "Concourse");
}
this.delegate = clazz.getMethod("connect", String.class,
int.class, String.class, String.class).invoke(null,
"localhost", getClientPort(), username,
password);
}
catch (InvocationTargetException e) {
Throwable target = e.getTargetException();
if(target.getMessage().contains(
"Could not connect to the Concourse Server")) {
// There is a race condition where the CLI reports the
// server has started (because the process has
// registered a PID) but the thrift server hasn't been
// opened to accept connections yet. This logic tries to
// get around that by retrying the connection a handful
// of times before failing.
try {
Thread.sleep(500);
continue;
}
catch (InterruptedException t) {/* ignore */}
}
else {
throw CheckedExceptions.throwAsRuntimeException(e);
}
}
catch (Exception e) {
throw CheckedExceptions.throwAsRuntimeException(e);
}
}
}
@Override
public void abort() {
invoke("abort").with();
}
@Override
public long add(String key, T value) {
return invoke("add", String.class, Object.class).with(key, value);
}
@Override
public Map add(String key, T value,
Collection records) {
return invoke("add", String.class, Object.class, Collection.class)
.with(key, value, records);
}
@Override
public boolean add(String key, T value, long record) {
return invoke("add", String.class, Object.class, long.class)
.with(key, value, record);
}
@Override
public Map audit(long record) {
return invoke("audit", long.class).with(record);
}
@Override
public Map audit(long record, Timestamp start) {
return invoke("audit", long.class, Timestamp.class).with(record,
start);
}
@Override
public Map audit(long record, Timestamp start,
Timestamp end) {
return invoke("audit", long.class, Timestamp.class, Timestamp.class)
.with(start, end);
}
@Override
public Map audit(String key, long record) {
return invoke("audit", String.class, long.class).with(key, record);
}
@Override
public Map audit(String key, long record,
Timestamp start) {
return invoke("audit", String.class, long.class, Timestamp.class)
.with(key, record, start);
}
@Override
public Map audit(String key, long record,
Timestamp start, Timestamp end) {
return invoke("audit", String.class, long.class, Timestamp.class,
Timestamp.class).with(key, record, start, end);
}
@Override
public Map>> browse(
Collection keys) {
return invoke("browse", Collection.class, Object.class).with(keys);
}
@Override
public Map>> browse(
Collection keys, Timestamp timestamp) {
return invoke("browse", Collection.class, Timestamp.class)
.with(keys, timestamp);
}
@Override
public Map