// -*- mode: java; c-basic-offset: 2; -*- // Copyright 2009-2011 Google, All Rights reserved // Copyright 2011-2012 MIT, All rights reserved // Released under the Apache License, Version 2.0 // http://www.apache.org/licenses/LICENSE-2.0 package com.google.appinventor.buildserver; import com.google.appinventor.common.version.GitBuildId; import com.google.common.collect.ImmutableMap; import com.google.common.io.ByteStreams; import com.google.common.io.Files; import com.sun.grizzly.http.SelectorThread; import com.sun.jersey.api.container.grizzly.GrizzlyServerFactory; import org.codehaus.jettison.json.JSONException; import org.codehaus.jettison.json.JSONObject; import org.kohsuke.args4j.CmdLineException; import org.kohsuke.args4j.CmdLineParser; import org.kohsuke.args4j.Option; import org.kohsuke.args4j.spi.StringArrayOptionHandler; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintStream; import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.OperatingSystemMXBean; import java.lang.management.RuntimeMXBean; import java.lang.Math; import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.URL; import java.text.DateFormat; import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; /** * Top level class for exposing the building of App Inventor APK files as a RESTful web service. * * Note that these BuildServer objects are created per request * * @author markf@google.com (Mark Friedman) */ // The Java class will be hosted at the URI path "/buildserver" @Path("/buildserver") public class BuildServer { private ProjectBuilder projectBuilder = new ProjectBuilder(); static class CommandLineOptions { @Option(name = "--shutdownToken", usage = "Token needed to shutdown the server remotely.") String shutdownToken = null; @Option(name = "--childProcessRamMb", usage = "Maximum ram that can be used by a child processes, in MB.") int childProcessRamMb = 2048; @Option(name = "--maxSimultaneousBuilds", usage = "Maximum number of builds that can run in parallel. O means unlimited.") int maxSimultaneousBuilds = 0; // The default is unlimited. @Option(name = "--port", usage = "The port number to bind to on the local machine.") int port = 9990; @Option(name = "--requiredHosts", usage = "If specified, a list of hosts which are permitted to use this BuildServer, other the server is open to all.", handler = StringArrayOptionHandler.class) String[] requiredHosts = null; @Option(name = "--debug", usage = "Turn on debugging, which enables the non-async calls of the buildserver.") boolean debug = false; @Option(name = "--dexCacheDir", usage = "the directory to cache the pre-dexed libraries") String dexCacheDir = null; } private static final CommandLineOptions commandLineOptions = new CommandLineOptions(); // Logging support private static final Logger LOG = Logger.getLogger(BuildServer.class.getName()); private static final MediaType APK_MEDIA_TYPE = new MediaType("application", "vnd.android.package-archive", ImmutableMap.of("charset", "utf-8")); private static final MediaType ZIP_MEDIA_TYPE = new MediaType("application", "zip", ImmutableMap.of("charset", "utf-8")); private static final AtomicInteger buildCount = new AtomicInteger(0); // The number of build requests for this server run private static final AtomicInteger asyncBuildRequests = new AtomicInteger(0); // The number of rejected build requests for this server run private static final AtomicInteger rejectedAsyncBuildRequests = new AtomicInteger(0); //The number of successful build requests for this server run private static final AtomicInteger successfulBuildRequests = new AtomicInteger(0); //The number of failed build requests for this server run private static final AtomicInteger failedBuildRequests = new AtomicInteger(0); //The number of failed build requests for this server run private static int maximumActiveBuildTasks = 0; // The build executor used to limit the number of simultaneous builds. // NOTE(lizlooney) - the buildExecutor must be created after the command line options are // processed in main(). If it is created here, the number of simultaneous builds will always be // the default value, even if the --maxSimultaneousBuilds option is on the command line. private static NonQueuingExecutor buildExecutor; // The input zip file. It will be deleted in cleanUp. private File inputZip; // The built APK file for this build request, if any. private File outputApk; // The temp directory that we're building in. private File outputDir; // The android.keystore file generated by this build request, if necessary. private File outputKeystore; // The zip file where we put all the build results for this request. private File outputZip; // non-zero means we are shutting down, if currentTimeMillis is > then this, then we are // completely shutdown, otherwise we are just providing NOT OK for health checks but // otherwise still accepting jobs. This avoids having people get an error if the load // balancer sends a job our way because it hasn't decided we are down. private static volatile long shuttingTime = 0; private static String shutdownToken = null; private enum ShutdownState { UP, SHUTTING, DOWN }; @GET @Path("health") @Produces(MediaType.TEXT_PLAIN) public Response health() throws IOException { ShutdownState shut = getShutdownState(); if (shut == ShutdownState.UP) { LOG.info("Healthcheck: UP"); return Response.ok("ok", MediaType.TEXT_PLAIN_TYPE).build(); } else if (shut == ShutdownState.DOWN) { LOG.info("Healthcheck: DOWN"); return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("Build Server is shutdown").build(); } else { LOG.info("Healthcheck: SHUTTING"); return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("Build Server is shutting down").build(); } } @GET @Path("vars") @Produces(MediaType.TEXT_HTML) public Response var() throws IOException { Map<String, String> variables = new LinkedHashMap<String, String>(); // Runtime RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean(); DateFormat dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.FULL); variables.put("state", getShutdownState() + ""); if (shuttingTime != 0) { variables.put("shutdown-time", dateTimeFormat.format(new Date(shuttingTime))); } variables.put("start-time", dateTimeFormat.format(new Date(runtimeBean.getStartTime()))); variables.put("uptime-in-ms", runtimeBean.getUptime() + ""); variables.put("vm-name", runtimeBean.getVmName()); variables.put("vm-vender", runtimeBean.getVmVendor()); variables.put("vm-version", runtimeBean.getVmVersion()); //BuildServer Version and Id variables.put("buildserver-version", GitBuildId.getVersion() + ""); variables.put("buildserver-git-fingerprint", GitBuildId.getFingerprint() + ""); // OS OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); variables.put("os-arch", osBean.getArch()); variables.put("os-name", osBean.getName()); variables.put("os-version", osBean.getVersion()); variables.put("num-processors", osBean.getAvailableProcessors() + ""); variables.put("load-average-past-1-min", osBean.getSystemLoadAverage() + ""); // Memory Runtime runtime = Runtime.getRuntime(); MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); variables.put("total-memory", runtime.totalMemory() + ""); variables.put("free-memory", runtime.freeMemory() + ""); variables.put("max-memory", runtime.maxMemory() + ""); variables.put("used-heap", memoryBean.getHeapMemoryUsage().getUsed() + ""); variables.put("used-non-heap", memoryBean.getNonHeapMemoryUsage().getUsed() + ""); // Build requests variables.put("count-async-build-requests", asyncBuildRequests.get() + ""); variables.put("rejected-async-build-requests", rejectedAsyncBuildRequests.get() + ""); variables.put("successful-async-build-requests", successfulBuildRequests.get() + ""); variables.put("failed-async-build-requests", failedBuildRequests.get() + ""); // Build tasks int max = buildExecutor.getMaxActiveTasks(); if (max == 0) { variables.put("maximum-simultaneous-build-tasks-allowed", "unlimited"); } else { variables.put("maximum-simultaneous-build-tasks-allowed", max + ""); } variables.put("completed-build-tasks", buildExecutor.getCompletedTaskCount() + ""); maximumActiveBuildTasks = Math.max(maximumActiveBuildTasks, buildExecutor.getActiveTaskCount()); variables.put("maximum-simultaneous-build-tasks-occurred", maximumActiveBuildTasks + ""); variables.put("active-build-tasks", buildExecutor.getActiveTaskCount() + ""); StringBuilder html = new StringBuilder(); html.append("<html><body><tt>"); for (Map.Entry<String, String> variable : variables.entrySet()) { html.append("<b>").append(variable.getKey()).append("</b> ") .append(variable.getValue()).append("<br>"); } html.append("</tt></body></html>"); return Response.ok(html.toString(), MediaType.TEXT_HTML_TYPE).build(); } /** * Indicate that the server is shutting down. * * @param token -- secret token used like a password to authenticate the shutdown command * @param delay -- the delay in seconds before jobs are no longer accepted */ @GET @Path("shutdown") @Produces(MediaType.TEXT_PLAIN) public Response shutdown(@QueryParam("token") String token, @QueryParam("delay") String delay) throws IOException { if (commandLineOptions.shutdownToken == null || token == null) { return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("No Shutdown Token").build(); } else if (!token.equals(commandLineOptions.shutdownToken)) { return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("Invalid Shutdown Token").build(); } else { long shutdownTime = System.currentTimeMillis(); if (delay != null) { try { shutdownTime += Integer.parseInt(delay) *1000; } catch (NumberFormatException e) { // XXX Ignore } } shuttingTime = shutdownTime; DateFormat dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.FULL); return Response.ok("ok: Will shutdown at " + dateTimeFormat.format(new Date(shuttingTime)), MediaType.TEXT_PLAIN_TYPE).build(); } } /** * Build an APK file from the input zip file. The zip file needs to be a variant of the same * App Inventor source zip that's generated by the Download Source command. The differences are * that it might not contain a .yail file (in which case we will generate the YAIL code) and it * might contain an android.keystore file at the top level (in which case we will use it to sign * the APK. If there is no android.keystore file in the zip we will generate one. * * @param userName The user name to be used in making the CN entry in the generated keystore * @param zipFile * @return the APK file */ @POST @Path("build-from-zip") @Produces("application/vnd.android.package-archive;charset=utf-8") public Response buildFromZipFile(@QueryParam("uname") String userName, File zipFile) throws IOException { // Set the inputZip field so we can delete the input zip file later in cleanUp. inputZip = zipFile; inputZip.deleteOnExit(); // In case build server is killed before cleanUp executes. if(!commandLineOptions.debug) return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE) .entity("Entry point unavailable unless debugging.").build(); try { build(userName, zipFile); String attachedFilename = outputApk.getName(); FileInputStream outputApkDeleteOnClose = new DeleteFileOnCloseFileInputStream(outputApk); // Set the outputApk field to null so that it won't be deleted in cleanUp(). outputApk = null; return Response.ok(outputApkDeleteOnClose) .header("Content-Disposition", "attachment; filename=\"" + attachedFilename + "\"") .build(); } finally { cleanUp(); } } /** * Build an APK file from the input zip file. The zip file needs to be a variant of the same * App Inventor source zip that's generated by the Download Source command. The differences are * that it might not contain a .yail file (in which case we will generate the YAIL code) and it * might contain an android.keystore file at the top level (in which case we will use it to sign * the APK. If there is no android.keystore file in the zip we will generate one and return it * in along with the APK file. * * We'll respond to the requester with a zip file containing the build.out and build.err files as * well as the APK file if the build succeeded and the android.keystore file if it was not * provided in the input zip * * @param userName The user name to be used in making the CN entry in the generated keystore * @param inputZipFile The zip file representing the App Inventor source code. * @return an "OK" {@link Response}. */ @POST @Path("build-all-from-zip") @Produces("application/zip;charset=utf-8") public Response buildAllFromZipFile(@QueryParam("uname") String userName, File inputZipFile) throws IOException, JSONException { // Set the inputZip field so we can delete the input zip file later in cleanUp. inputZip = inputZipFile; inputZip.deleteOnExit(); // In case build server is killed before cleanUp executes. if(!commandLineOptions.debug) return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE) .entity("Entry point unavailable unless debugging.").build(); try { buildAndCreateZip(userName, inputZipFile); String attachedFilename = outputZip.getName(); FileInputStream outputZipDeleteOnClose = new DeleteFileOnCloseFileInputStream(outputZip); // Set the outputZip field to null so that it won't be deleted in cleanUp(). outputZip = null; return Response.ok(outputZipDeleteOnClose) .header("Content-Disposition", "attachment; filename=\"" + attachedFilename + "\"") .build(); } finally { cleanUp(); } } /** * Asynchronously build an APK file from the input zip file and then send it to the callbackUrl. * The input zip file needs to be a variant of the same App Inventor source zip that's generated * by the Download Source command. The differences are that it might not contain a .yail file (in * which case we will generate the YAIL code) and it might contain an android.keystore file at the * top level (in which case we will use it to sign the APK). * * We'll use the callbackUrlStr to post back a zip file containing the build.out and build.err * files as well as the APK file if the build succeeded and the android.keystore file if it was * not provided in the input zip * * Before building the app, we'll check that the gitBuildVersion parameter (if present) equals * GitBuildId.getVersion(). If the values are different, we won't even try to build * the app. This may seem too strict, but we need to make sure that when we build apps, we use * the same version of the code that loads the .blk and .scm files, the same version of * runtime.scm, and the same version of the App Inventor component classes. * * The status code returned here will be seen by the server in YoungAndroidProjectService.build * as connection.getResponseCode(). * * @param userName The user name to be used in making the CN entry in the generated keystore. * @param gitBuildVersion The value of GitBuildId.getVersion() sent from * YoungAndroidProjectService.build. * @param callbackUrlStr An url to send the build results back to. * @param inputZipFile The zip file representing the App Inventor source code. * @return a status response, typically OK (200) or SERVICE_UNAVAILABLE (503). */ @POST @Path("build-all-from-zip-async") @Produces(MediaType.TEXT_PLAIN) public Response buildAllFromZipFileAsync( @QueryParam("uname") final String userName, @QueryParam("callback") final String callbackUrlStr, @QueryParam("gitBuildVersion") final String gitBuildVersion, final File inputZipFile) throws IOException { // Set the inputZip field so we can delete the input zip file later in // cleanUp. inputZip = inputZipFile; inputZip.deleteOnExit(); // In case build server is killed before cleanUp executes. String requesting_host = (new URL(callbackUrlStr)).getHost(); //for the request for update part, the file should be empty if (inputZip.length() == 0L) { cleanUp(); } else { if (getShutdownState() == ShutdownState.DOWN) { LOG.info("request received while shutdown completely"); return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("Temporary build error, try again.").build(); } if (commandLineOptions.requiredHosts != null) { boolean oktoproceed = false; for (String host : commandLineOptions.requiredHosts) { if (host.equals(requesting_host)) { oktoproceed = true; break;} } if (oktoproceed) { LOG.info("requesting host (" + requesting_host + ") is in the allowed host list request will be honored."); } else { // Return an error LOG.info("requesting host (" + requesting_host + ") is NOT in the allowed host list request will be rejected."); return Response.status(Response.Status.FORBIDDEN).type(MediaType.TEXT_PLAIN_TYPE).entity("You are not permitted to use this build server.").build(); } } else { LOG.info("requiredHosts is not set, no restriction on callback url."); } asyncBuildRequests.incrementAndGet(); if (gitBuildVersion != null && !gitBuildVersion.isEmpty()) { if (!gitBuildVersion.equals(GitBuildId.getVersion())) { // This build server is not compatible with the App Inventor instance. Log this as severe // so the owner of the build server will know about it. String errorMessage = "Build server version " + GitBuildId.getVersion() + " is not compatible with App Inventor version " + gitBuildVersion + "."; LOG.severe(errorMessage); // This request was rejected because the gitBuildVersion parameter did not equal the // expected value. rejectedAsyncBuildRequests.incrementAndGet(); cleanUp(); // Here, we use CONFLICT (response code 409), which means (according to rfc2616, section // 10) "The request could not be completed due to a conflict with the current state of the // resource." return Response.status(Response.Status.CONFLICT).type(MediaType.TEXT_PLAIN_TYPE).entity(errorMessage).build(); } } Runnable buildTask = new Runnable() { @Override public void run() { int count = buildCount.incrementAndGet(); try { LOG.info("START NEW BUILD " + count); checkMemory(); buildAndCreateZip(userName, inputZipFile); // Send zip back to the callbackUrl LOG.info("CallbackURL: " + callbackUrlStr); URL callbackUrl = new URL(callbackUrlStr); HttpURLConnection connection = (HttpURLConnection) callbackUrl.openConnection(); connection.setDoOutput(true); connection.setRequestMethod("POST"); // Make sure we aren't misinterpreted as // form-url-encoded connection.addRequestProperty("Content-Type","application/zip; charset=utf-8"); connection.setConnectTimeout(60000); connection.setReadTimeout(60000); BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(connection.getOutputStream()); try { BufferedInputStream bufferedInputStream = new BufferedInputStream( new FileInputStream(outputZip)); try { ByteStreams.copy(bufferedInputStream,bufferedOutputStream); checkMemory(); bufferedOutputStream.flush(); } finally { bufferedInputStream.close(); } } finally { bufferedOutputStream.close(); } if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {LOG.severe("Bad Response Code!: "+ connection.getResponseCode()); // TODO(user) Maybe do some retries } } catch (Exception e) { // TODO(user): Maybe send a failure callback LOG.severe("Exception: " + e.getMessage()+ " and the length is of inputZip is "+ inputZip.length()); } finally { cleanUp(); checkMemory(); LOG.info("BUILD " + count + " FINISHED"); } } }; try { buildExecutor.execute(buildTask); } catch (RejectedExecutionException e) { // This request was rejected because all threads in the build // executor are busy. rejectedAsyncBuildRequests.incrementAndGet(); cleanUp(); // Here, we use SERVICE_UNAVAILABLE (response code 503), which // means (according to rfc2616, section 10) "The server is // currently unable to handle the request due to a temporary // overloading or maintenance of the server. The implication // is that this is a temporary condition which will be // alleviated after some delay." return Response.status(Response.Status.SERVICE_UNAVAILABLE).type(MediaType.TEXT_PLAIN_TYPE).entity("The build server is currently at maximum capacity.").build(); } } return Response.ok().type(MediaType.TEXT_PLAIN_TYPE) .entity("" + projectBuilder.getProgress()).build(); } private void buildAndCreateZip(String userName, File inputZipFile) throws IOException, JSONException { Result buildResult = build(userName, inputZipFile); boolean buildSucceeded = buildResult.succeeded(); outputZip = File.createTempFile(inputZipFile.getName(), ".zip"); outputZip.deleteOnExit(); // In case build server is killed before cleanUp executes. ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(outputZip))); if (buildSucceeded) { if (outputKeystore != null) { zipOutputStream.putNextEntry(new ZipEntry(outputKeystore.getName())); Files.copy(outputKeystore, zipOutputStream); } zipOutputStream.putNextEntry(new ZipEntry(outputApk.getName())); Files.copy(outputApk, zipOutputStream); successfulBuildRequests.getAndIncrement(); } else { LOG.severe("Build " + buildCount.get() + " Failed: " + buildResult.getResult() + " " + buildResult.getError()); failedBuildRequests.getAndIncrement(); } zipOutputStream.putNextEntry(new ZipEntry("build.out")); String buildOutputJson = genBuildOutput(buildResult); PrintStream zipPrintStream = new PrintStream(zipOutputStream); zipPrintStream.print(buildOutputJson); zipPrintStream.flush(); zipOutputStream.flush(); zipOutputStream.close(); } private String genBuildOutput(Result buildResult) throws JSONException { JSONObject buildOutputJsonObj = new JSONObject(); buildOutputJsonObj.put("result", buildResult.getResult()); buildOutputJsonObj.put("error", buildResult.getError()); buildOutputJsonObj.put("output", buildResult.getOutput()); if (buildResult.getFormName() != null) { buildOutputJsonObj.put("formName", buildResult.getFormName()); } return buildOutputJsonObj.toString(); } private Result build(String userName, File zipFile) throws IOException { outputDir = Files.createTempDir(); // We call outputDir.deleteOnExit() here, in case build server is killed before cleanUp // executes. However, it is likely that the directory won't be empty and therefore, won't // actually be deleted. That's only if the build server is killed (via ctrl+c) while a build // is happening, so we should be careful about that. outputDir.deleteOnExit(); Result buildResult = projectBuilder.build(userName, new ZipFile(zipFile), outputDir, false, commandLineOptions.childProcessRamMb, commandLineOptions.dexCacheDir); String buildOutput = buildResult.getOutput(); LOG.info("Build output: " + buildOutput); String buildError = buildResult.getError(); LOG.info("Build error output: " + buildError); outputApk = projectBuilder.getOutputApk(); if (outputApk != null) { outputApk.deleteOnExit(); // In case build server is killed before cleanUp executes. } outputKeystore = projectBuilder.getOutputKeystore(); if (outputKeystore != null) { outputKeystore.deleteOnExit(); // In case build server is killed before cleanUp executes. } checkMemory(); return buildResult; } private void cleanUp() { if (inputZip != null) { inputZip.delete(); } if (outputKeystore != null) { outputKeystore.delete(); } if (outputApk != null) { outputApk.delete(); } if (outputZip != null) { outputZip.delete(); } if (outputDir != null) { outputDir.delete(); } } private static void checkMemory() { MemoryMXBean mBean = ManagementFactory.getMemoryMXBean(); mBean.gc(); LOG.info("Build " + buildCount + " current used memory: " + mBean.getHeapMemoryUsage().getUsed() + " bytes"); } public static void main(String[] args) throws IOException { // TODO(markf): Eventually we'll figure out how to appropriately start and stop the server when // it's run in a production environment. For now, just kill the process CmdLineParser cmdLineParser = new CmdLineParser(commandLineOptions); try { cmdLineParser.parseArgument(args); } catch (CmdLineException e) { LOG.severe(e.getMessage()); cmdLineParser.printUsage(System.err); System.exit(1); } // Now that the command line options have been processed, we can create the buildExecutor. buildExecutor = new NonQueuingExecutor(commandLineOptions.maxSimultaneousBuilds); int port = commandLineOptions.port; SelectorThread threadSelector = GrizzlyServerFactory.create("http://localhost:" + port + "/"); String hostAddress = InetAddress.getLocalHost().getHostAddress(); LOG.info("App Inventor Build Server - Version: " + GitBuildId.getVersion()); LOG.info("App Inventor Build Server - Git Fingerprint: " + GitBuildId.getFingerprint()); LOG.info("Running at: http://" + hostAddress + ":" + port + "/buildserver"); if (commandLineOptions.maxSimultaneousBuilds == 0) { LOG.info("Maximum simultanous builds = unlimited!"); } else { LOG.info("Maximum simultanous builds = " + commandLineOptions.maxSimultaneousBuilds); } LOG.info("Visit: http://" + hostAddress + ":" + port + "/buildserver/health for server health"); LOG.info("Visit: http://" + hostAddress + ":" + port + "/buildserver/vars for server values"); LOG.info("Server running"); } private static class DeleteFileOnCloseFileInputStream extends FileInputStream { private final File file; DeleteFileOnCloseFileInputStream(File file) throws IOException { super(file); this.file = file; } @Override public void close() throws IOException { super.close(); file.delete(); } } private ShutdownState getShutdownState() { if (shuttingTime == 0) { return ShutdownState.UP; } else if (System.currentTimeMillis() > shuttingTime) { return ShutdownState.DOWN; } else { return ShutdownState.SHUTTING; } } }