Add daemon support for adaptors
When used with procrun or jsvc, an adaptor would be able to run as a
Windows service or Unix daemon where it could obtain priviledged ports
before dropping priviledges to a different user.
diff --git a/THIRDPARTYLICENSE.txt b/THIRDPARTYLICENSE.txt
index ec97bc9..34dccfa 100644
--- a/THIRDPARTYLICENSE.txt
+++ b/THIRDPARTYLICENSE.txt
@@ -16,6 +16,11 @@
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+lib/commons-daemon-1.0.15.jar
+Apache2
+
+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
lib/commons-lang-2.1.jar
Apache2
diff --git a/build.xml b/build.xml
index ddf60c5..7452685 100644
--- a/build.xml
+++ b/build.xml
@@ -40,6 +40,7 @@
<include name="jsr305.jar"/>
<include name="gson-2.0.jar"/>
<include name="velocity-1.5.jar"/>
+ <include name="commons-daemon-1.0.15.jar"/>
</fileset>
</path>
diff --git a/lib/commons-daemon-1.0.15.jar b/lib/commons-daemon-1.0.15.jar
new file mode 100644
index 0000000..2b6b9c6
--- /dev/null
+++ b/lib/commons-daemon-1.0.15.jar
Binary files differ
diff --git a/src/com/google/enterprise/adaptor/Application.java b/src/com/google/enterprise/adaptor/Application.java
index cb922a8..b980aec 100644
--- a/src/com/google/enterprise/adaptor/Application.java
+++ b/src/com/google/enterprise/adaptor/Application.java
@@ -60,18 +60,46 @@
*/
public void start() throws IOException, InterruptedException {
synchronized (this) {
- if (primaryServer != null) {
- throw new IllegalStateException("Already started");
- }
+ daemonInit();
+
+ // The shutdown hook is purposefully not part of the deamon methods,
+ // because it should only be done when running from the command line.
shutdownHook = new Thread(new ShutdownHook(), "gsacomm-shutdown");
Runtime.getRuntime().addShutdownHook(shutdownHook);
-
- primaryServer = createHttpServer();
- dashboardServer = createDashboardHttpServer();
- primaryServer.start();
- dashboardServer.start();
}
+
+ daemonStart();
+ }
+
+ /**
+ * Reserves resources for later use. This may be run with different
+ * permissions (like as root), to reserve ports or other things that need
+ * elevated privileges.
+ */
+ synchronized void daemonInit() throws IOException {
+ if (primaryServer != null) {
+ throw new IllegalStateException("Already started");
+ }
+ primaryServer = createHttpServer(config);
+ dashboardServer = createDashboardHttpServer(config);
+ // Because once stopped the server can't be reused, we can't reuse its
+ // bind()ed socket if we stop it. So although ideally we would start/stop in
+ // daemonStart/daemonStop, we instead must do it in
+ // daemonInit/daemonDestroy.
+ primaryServer.start();
+ dashboardServer.start();
+ }
+
+ /**
+ * Really start. This must be called after a successful {@link #daemonInit}.
+ */
+ void daemonStart() throws IOException, InterruptedException {
gsa.start(primaryServer, dashboardServer);
+
+ if (config.isAdaptorPushDocIdsOnStartup()) {
+ log.info("Pushing once at program start");
+ gsa.checkAndScheduleImmediatePushOfDocIds();
+ }
}
/**
@@ -79,9 +107,8 @@
* shutdown.
*/
public synchronized void stop(long time, TimeUnit unit) {
- if (primaryServer == null) {
- throw new IllegalStateException("Already stopped");
- }
+ daemonStop(time, unit);
+ daemonDestroy(time, unit);
if (shutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
@@ -90,28 +117,44 @@
}
shutdownHook = null;
}
+ }
+ /**
+ * Stop all the services we provide. This is the opposite of {@link
+ * #daemonStart}.
+ */
+ synchronized void daemonStop(long time, TimeUnit unit) {
+ if (primaryServer == null) {
+ throw new IllegalStateException("Already stopped");
+ }
gsa.stop(time, unit);
- SleepHandler sleepHandler = new SleepHandler(100 /* millis */);
- // Workaround Java Bug 7105369.
- primaryServer.createContext(SLEEP_PATH, sleepHandler);
- issueSleepGetRequest(config.getServerPort());
+ }
- primaryServer.stop((int) unit.toSeconds(time));
+ /**
+ * Release reserved resources. This is the opposite of {@link
+ * #daemonInit}.
+ */
+ synchronized void daemonDestroy(long time, TimeUnit unit) {
+ httpServerShutdown(primaryServer, time, unit);
log.finer("Completed primary server stop");
- ((ExecutorService) primaryServer.getExecutor()).shutdownNow();
primaryServer = null;
- // Workaround Java Bug 7105369.
- dashboardServer.createContext(SLEEP_PATH, sleepHandler);
- issueSleepGetRequest(config.getServerDashboardPort());
-
- dashboardServer.stop((int) unit.toSeconds(time));
- ((ExecutorService) dashboardServer.getExecutor()).shutdownNow();
+ httpServerShutdown(dashboardServer, time, unit);
log.finer("Completed dashboard stop");
dashboardServer = null;
}
+ private static void httpServerShutdown(HttpServer server, long time,
+ TimeUnit unit) {
+ // Workaround Java Bug 7105369.
+ SleepHandler sleepHandler = new SleepHandler(100 /* millis */);
+ server.createContext(SLEEP_PATH, sleepHandler);
+ issueSleepGetRequest(server.getAddress(), server instanceof HttpsServer);
+
+ server.stop((int) unit.toSeconds(time));
+ ((ExecutorService) server.getExecutor()).shutdownNow();
+ }
+
/**
* Issues a GET request to a SleepHandler. This is used to workaround Java
* Bug 7105369.
@@ -127,11 +170,12 @@
* before calling stop(). In the event everything goes as planned, the request
* completes after stop() has been called and allows stop() to exit quickly.
*/
- private void issueSleepGetRequest(int port) {
+ private static void issueSleepGetRequest(InetSocketAddress address,
+ boolean isHttps) {
URL url;
try {
- url = new URL(config.isServerSecure() ? "https" : "http",
- config.getServerHostname(), port, SLEEP_PATH);
+ url = new URL(isHttps ? "https" : "http",
+ address.getAddress().getHostAddress(), address.getPort(), SLEEP_PATH);
} catch (MalformedURLException ex) {
log.log(Level.WARNING,
"Unexpected error. Shutting down will be slow.", ex);
@@ -165,7 +209,7 @@
}).start();
}
- private HttpServer createHttpServer() throws IOException {
+ private static HttpServer createHttpServer(Config config) throws IOException {
HttpServer server;
if (!config.isServerSecure()) {
server = HttpServer.create();
@@ -206,7 +250,8 @@
return server;
}
- private HttpServer createDashboardHttpServer() throws IOException {
+ private static HttpServer createDashboardHttpServer(Config config)
+ throws IOException {
boolean secure = config.isServerSecure();
HttpServer server;
if (!secure) {
@@ -247,6 +292,11 @@
return gsa;
}
+ /** Returns the {@link Config} used by this instance. */
+ public Config getConfig() {
+ return config;
+ }
+
/**
* Main for adaptors to utilize when wanting to act as an application. This
* method primarily parses arguments and creates an application instance
@@ -255,14 +305,7 @@
* @return the application instance in use
*/
public static Application main(Adaptor adaptor, String[] args) {
- Config config = new Config();
- adaptor.initConfig(config);
- config.autoConfig(args);
-
- if (config.useAdaptorAutoUnzip()) {
- adaptor = new AutoUnzipAdaptor(adaptor);
- }
- Application app = new Application(adaptor, config);
+ Application app = daemonMain(adaptor, args);
// Setup providing content.
try {
@@ -274,14 +317,24 @@
throw new RuntimeException("could not start serving", e);
}
- if (config.isAdaptorPushDocIdsOnStartup()) {
- log.info("Pushing once at program start");
- app.getGsaCommunicationHandler().checkAndScheduleImmediatePushOfDocIds();
- }
-
return app;
}
+ /**
+ * Performs basic bootstrapping like normal {@link #main}, but does not start
+ * the application.
+ */
+ static Application daemonMain(Adaptor adaptor, String[] args) {
+ Config config = new Config();
+ adaptor.initConfig(config);
+ config.autoConfig(args);
+
+ if (config.useAdaptorAutoUnzip()) {
+ adaptor = new AutoUnzipAdaptor(adaptor);
+ }
+ return new Application(adaptor, config);
+ }
+
private class ShutdownHook implements Runnable {
@Override
public void run() {
diff --git a/src/com/google/enterprise/adaptor/Daemon.java b/src/com/google/enterprise/adaptor/Daemon.java
new file mode 100644
index 0000000..6aa9406
--- /dev/null
+++ b/src/com/google/enterprise/adaptor/Daemon.java
@@ -0,0 +1,107 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// 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.google.enterprise.adaptor;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.commons.daemon.DaemonContext;
+
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Allows running an adaptor as a daemon when used in conjunction with procrun
+ * or jsvc.
+ *
+ * <p>Example execution with jsvc:
+ * <pre>jsvc -pidfile adaptor.pid -cp someadaptor-withlib.jar \
+ * com.google.enterprise.adaptor.Daemon package.SomeAdaptor</pre>
+ */
+public class Daemon implements org.apache.commons.daemon.Daemon {
+ private Application app;
+ private DaemonContext context;
+
+ @Override
+ public synchronized void init(DaemonContext context) throws Exception {
+ if (this.context != null) {
+ throw new IllegalStateException("Already initialized");
+ }
+ this.context = context;
+ String[] args = context.getArguments();
+ if (args.length < 1) {
+ throw new IllegalArgumentException(
+ "Missing argument: adaptor class name");
+ }
+ Adaptor adaptor
+ = Class.forName(args[0]).asSubclass(Adaptor.class).newInstance();
+ args = Arrays.copyOfRange(args, 1, args.length);
+
+ app = Application.daemonMain(adaptor, args);
+ app.daemonInit();
+ }
+
+ @Override
+ public synchronized void destroy() {
+ if (app != null) {
+ app.daemonDestroy(5, TimeUnit.SECONDS);
+ }
+ context = null;
+ app = null;
+ }
+
+ @Override
+ public void start() throws Exception {
+ final Application savedApp;
+ final DaemonContext savedContext;
+ final CountDownLatch latch = new CountDownLatch(1);
+ // Save values so that there aren't any races with stop/destroy.
+ synchronized (this) {
+ savedApp = this.app;
+ savedContext = this.context;
+ }
+ // Run in a new thread so that stop() can be called before we complete
+ // starting (since starting can take a long time if the Adaptor keeps
+ // throwing an exception). However, we still try to wait for start to
+ // complete normally to ease testing and improve the user experience in the
+ // common case of starting being quick.
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ savedApp.daemonStart();
+ } catch (InterruptedException ex) {
+ // We must be shutting down.
+ Thread.currentThread().interrupt();
+ } catch (Exception ex) {
+ savedContext.getController().fail(ex);
+ } finally {
+ latch.countDown();
+ }
+ }
+ }).start();
+ latch.await(5, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public synchronized void stop() throws Exception {
+ app.daemonStop(5, TimeUnit.SECONDS);
+ }
+
+ @VisibleForTesting
+ Application getApplication() {
+ return app;
+ }
+}
diff --git a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
index 7ef85c4..09749b8 100644
--- a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
+++ b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
@@ -539,6 +539,11 @@
}
}
+ /** The adaptor instance being used. */
+ public Adaptor getAdaptor() {
+ return adaptor;
+ }
+
HttpContext addFilters(HttpContext context) {
context.getFilters().add(waiter.filter());
context.getFilters().addAll(commonFilters);
diff --git a/test/com/google/enterprise/adaptor/DaemonTest.java b/test/com/google/enterprise/adaptor/DaemonTest.java
new file mode 100644
index 0000000..bed6475
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/DaemonTest.java
@@ -0,0 +1,149 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// 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.google.enterprise.adaptor;
+
+import static org.junit.Assert.*;
+
+import org.apache.commons.daemon.DaemonContext;
+import org.apache.commons.daemon.DaemonController;
+import org.junit.*;
+import org.junit.rules.ExpectedException;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.Charset;
+
+/** Tests for {@link Daemon}. */
+public class DaemonTest {
+ private String[] arguments = new String[] {
+ SingleDocAdaptor.class.getName(), "-Dgsa.hostname=localhost",
+ "-Dserver.port=0", "-Dserver.dashboardPort=0"};
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ @Test
+ public void testNoArguments() throws Exception {
+ DaemonContext context = new Context(new String[0], null);
+ Daemon daemon = new Daemon();
+ thrown.expect(IllegalArgumentException.class);
+ daemon.init(context);
+ }
+
+ @Test
+ public void testDoubleInit() throws Exception {
+ DaemonContext context = new Context(arguments, null);
+ Daemon daemon = new Daemon();
+ daemon.init(context);
+ try {
+ thrown.expect(IllegalStateException.class);
+ daemon.init(context);
+ } finally {
+ daemon.destroy();
+ }
+ }
+
+ @Test
+ public void testAnyTimeDestroy() {
+ Daemon daemon = new Daemon();
+ // Destroy without init should still work.
+ daemon.destroy();
+ }
+
+ @Test
+ public void testBasicListen() throws Exception {
+ DaemonContext context = new Context(arguments, null);
+ Daemon daemon = new Daemon();
+ SingleDocAdaptor adaptor = null;
+ daemon.init(context);
+ try {
+ daemon.start();
+ try {
+ Adaptor tmpAdaptor = daemon.getApplication()
+ .getGsaCommunicationHandler().getAdaptor();
+ adaptor = (SingleDocAdaptor) tmpAdaptor;
+ assertTrue(adaptor.inited);
+ int port = daemon.getApplication().getConfig().getServerPort();
+ URL url = new URL("http", "localhost", port, "/doc/");
+ URLConnection conn = url.openConnection();
+ InputStream is = conn.getInputStream();
+ try {
+ String out
+ = IOHelper.readInputStreamToString(is, Charset.forName("UTF-8"));
+ assertEquals("success", out);
+ } finally {
+ is.close();
+ }
+ } finally {
+ daemon.stop();
+ }
+ } finally {
+ daemon.destroy();
+ }
+ assertFalse(adaptor.inited);
+ }
+
+ private static class Context implements DaemonContext {
+ private final String[] args;
+ private final DaemonController controller;
+
+ public Context(String[] args, DaemonController controller) {
+ this.args = args;
+ this.controller = controller;
+ }
+
+ @Override
+ public String[] getArguments() {
+ return args;
+ }
+
+ @Override
+ public DaemonController getController() {
+ return controller;
+ }
+ }
+
+ /**
+ * Adaptor with a single document. Marked public so that it can be loaded by
+ * Daemon.
+ */
+ public static class SingleDocAdaptor extends AbstractAdaptor {
+ private volatile boolean inited;
+
+ @Override
+ public void init(AdaptorContext context) {
+ inited = true;
+ }
+
+ @Override
+ public void destroy() {
+ inited = false;
+ }
+
+ @Override
+ public void getDocIds(DocIdPusher pusher) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void getDocContent(Request req, Response resp) throws IOException {
+ if ("".equals(req.getDocId().getUniqueId())) {
+ resp.getOutputStream().write("success".getBytes("UTF-8"));
+ } else {
+ resp.respondNotFound();
+ }
+ }
+ }
+}