DaemonOperation.java

/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> 
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.syncany.operations.daemon;

import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.syncany.config.Config;
import org.syncany.config.ConfigException;
import org.syncany.config.DaemonConfigHelper;
import org.syncany.config.LocalEventBus;
import org.syncany.config.UserConfig;
import org.syncany.config.to.DaemonConfigTO;
import org.syncany.config.to.FolderTO;
import org.syncany.config.to.PortTO;
import org.syncany.config.to.UserTO;
import org.syncany.crypto.CipherUtil;
import org.syncany.operations.Operation;
import org.syncany.operations.daemon.ControlServer.ControlCommand;
import org.syncany.operations.daemon.DaemonOperationOptions.DaemonAction;
import org.syncany.operations.daemon.DaemonOperationResult.DaemonResultCode;
import org.syncany.operations.daemon.messages.ControlManagementRequest;
import org.syncany.operations.daemon.messages.ControlManagementResponse;
import org.syncany.operations.watch.WatchOperation;
import org.syncany.util.PidFileUtil;

import com.google.common.collect.Ordering;
import com.google.common.eventbus.Subscribe;

/**
 * This operation is the central part of the daemon. It can manage many different
 * {@link WatchOperation}s and exposes a web socket server to control and query the 
 * daemon. It furthermore offers a file-based control server to stop and reload the
 * daemon.
 * 
 * <p>When started via {@link #execute()}, the operation starts the following core
 * components:
 * 
 * <ul>
 *  <li>The {@link WatchServer} starts a {@link WatchOperation} for every 
 *      folder registered in the <code>daemon.xml</code> file. It can be reloaded via
 *      the <code>syd reload</code> command.</li>
 *  <li>The {@link WebServer} starts a websocket and allows clients 
 *      (e.g. GUI, Web) to control the daemon (if authenticated). 
 *      TODO [medium] This is not yet implemented!</li>
 *  <li>The {@link ControlServer} creates and watches the daemon control file
 *      which allows the <code>syd</code> shell/batch script to write reload/shutdown
 *      commands.</li>  
 * </ul>
 * 
 * @author Vincent Wiencek (vwiencek@gmail.com)
 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
 * @author Pim Otte 
 */
public class DaemonOperation extends Operation {	
	private static final Logger logger = Logger.getLogger(DaemonOperation.class.getSimpleName());	
	public static final String PID_FILE = "daemon.pid";

	private DaemonOperationOptions options;
	private File pidFile;
	
	private WebServer webServer;
	private WatchServer watchServer;
	private ControlServer controlServer;
	private LocalEventBus eventBus;
	private DaemonConfigTO daemonConfig;
	private PortTO portTO;	

	public DaemonOperation() {
		this(new DaemonOperationOptions(DaemonAction.RUN));
	}
	
	public DaemonOperation(DaemonOperationOptions options) {
		super(null);	
		
		this.options = options;
		this.pidFile = new File(UserConfig.getUserConfigDir(), PID_FILE);		
	}

	@Override
	public DaemonOperationResult execute() throws Exception {		
		logger.log(Level.INFO, "Starting daemon operation with action " + options.getAction() + " ...");
		
		switch (options.getAction()) {
		case LIST:
			return executeList();

		case ADD:
			return executeAdd();

		case REMOVE:
			return executeRemove();
			
		case RUN:
			return executeRun();

		default:
			throw new Exception("Unknown action: " + options.getAction());
		}
	}

	private DaemonOperationResult executeList() {
		logger.log(Level.INFO, "Listing daemon-managed folders ...");
		
		loadOrCreateConfig();
		return new DaemonOperationResult(DaemonResultCode.OK, daemonConfig.getFolders());
	}
	
	private DaemonOperationResult executeAdd() throws Exception {
		// Check all folders
		for (String watchRoot : options.getWatchRoots()) {
			File watchRootFolder = new File(watchRoot);
			File watchRootAppFolder = new File(watchRootFolder, Config.DIR_APPLICATION);
			
			if (!watchRootFolder.isDirectory() || !watchRootAppFolder.isDirectory()) {
				throw new Exception("Given argument is not an existing folder, or a valid Syncany folder: " + watchRoot);
			}
		}
		
		// Add them
		for (String watchRoot : options.getWatchRoots()) {
			DaemonConfigHelper.addFolder(new File(watchRoot));			
		}
				
		// Determine return code
		loadOrCreateConfig();		
		int watchedMatchingFoldersCount = countWatchedMatchingFolders();		

		if (watchedMatchingFoldersCount == options.getWatchRoots().size()) {
			return new DaemonOperationResult(DaemonResultCode.OK, daemonConfig.getFolders());	
		}
		else if (watchedMatchingFoldersCount > 0) {
			return new DaemonOperationResult(DaemonResultCode.OK_PARTIAL, daemonConfig.getFolders());
		}
		else {
			return new DaemonOperationResult(DaemonResultCode.NOK, daemonConfig.getFolders());
		}
	}
	
	private DaemonOperationResult executeRemove() throws ConfigException {
		// Sort 
		Collections.sort(options.getWatchRoots(), Ordering.natural().reverse());
		
		// Remove all folders
		for (String watchRoot : options.getWatchRoots()) {
			logger.log(Level.INFO, "- Removing folder from daemon config: " + watchRoot + " ...");
			DaemonConfigHelper.removeFolder(watchRoot);
		}
		
		// Check if folders were removed
		loadOrCreateConfig();		
		int watchedMatchingFoldersCount = countWatchedMatchingFolders();		

		if (watchedMatchingFoldersCount == options.getWatchRoots().size()) {
			return new DaemonOperationResult(DaemonResultCode.NOK, daemonConfig.getFolders());	
		}
		else if (watchedMatchingFoldersCount > 0) {
			return new DaemonOperationResult(DaemonResultCode.NOK_PARTIAL, daemonConfig.getFolders());
		}
		else {
			return new DaemonOperationResult(DaemonResultCode.OK, daemonConfig.getFolders());
		}
	}
	
	private int countWatchedMatchingFolders() {
		int watchedMatchingFoldersCount = 0;

		for (FolderTO folderTO : daemonConfig.getFolders()) {
			if (options.getWatchRoots().contains(folderTO.getPath())) {
				watchedMatchingFoldersCount++;
			}
		}
		
		return watchedMatchingFoldersCount;
	}
	
	private DaemonOperationResult executeRun() throws Exception {
		if (PidFileUtil.isProcessRunning(pidFile)) {
			throw new ServiceAlreadyStartedException("Syncany daemon already running.");
		}
		
		PidFileUtil.createPidFile(pidFile);
		
		initEventBus();		
		loadOrCreateConfig();
		
		startWebServer();
		startWatchServer();
		
		enterControlLoop(); // This blocks until SHUTDOWN is received!
		
		return new DaemonOperationResult(DaemonResultCode.OK);
	}

	@Subscribe
	public void onControlCommand(ControlCommand controlCommand) {
		switch (controlCommand) {
		case SHUTDOWN:
			logger.log(Level.INFO, "SHUTDOWN requested.");
			stopOperation();
			break;
			
		case RELOAD:
			logger.log(Level.INFO, "RELOAD requested.");
			reloadOperation();
			break;
		}
	}	

	@Subscribe
	public void onControlManagementRequest(ControlManagementRequest controlRequest) {
		onControlCommand(controlRequest.getControlCommand());
		eventBus.post(new ControlManagementResponse(200, controlRequest.getId(), "Command executed."));		
	}		
	
	// General initialization functions. These create the EventBus and control loop.	
	
	private void initEventBus() {
		eventBus = LocalEventBus.getInstance();
		eventBus.register(this);
	}

	private void enterControlLoop() throws IOException, ServiceAlreadyStartedException {
		logger.log(Level.INFO, "Starting daemon control server ...");

		controlServer = new ControlServer();
		controlServer.enterLoop(); // This blocks! 
	}

	// General stopping and reloading functions

	private void stopOperation() {
		stopWebServer();
		stopWatchServer();
	}
	
	private void reloadOperation() {
		loadOrCreateConfig();		
		watchServer.reload(daemonConfig);
	}
	
	// Config related functions. Used on starting and reloading.
	
	private void loadOrCreateConfig() {
		try {
			File daemonConfigFile = new File(UserConfig.getUserConfigDir(), UserConfig.DAEMON_FILE);
			File daemonConfigFileExample = new File(UserConfig.getUserConfigDir(), UserConfig.DAEMON_EXAMPLE_FILE);
			
			if (daemonConfigFile.exists()) {
				logger.log(Level.INFO, "Loading daemon config file from " + daemonConfigFile + " ...");
				daemonConfig = DaemonConfigTO.load(daemonConfigFile);
			}
			else {
				logger.log(Level.INFO, "Daemon config file does not exist.");
				logger.log(Level.INFO, "- Writing example config file to " + daemonConfigFileExample + " ...");				
				DaemonConfigHelper.createAndWriteExampleDaemonConfig(daemonConfigFileExample);								

				logger.log(Level.INFO, "- Creating at  " + daemonConfigFile + " ...");				
				daemonConfig = DaemonConfigHelper.createAndWriteDefaultDaemonConfig(daemonConfigFile);
			}
			
			// Add user and password for access from the CLI
			if (daemonConfig.getPortTO() == null && portTO == null) {
				// Access info has not been created yet, generate new user-password pair
				String accessToken = CipherUtil.createRandomAlphabeticString(20);
				
				UserTO cliUser = new UserTO();
				cliUser.setUsername(UserConfig.USER_CLI);
				cliUser.setPassword(accessToken);
				
				portTO = new PortTO();
				
				portTO.setPort(daemonConfig.getWebServer().getBindPort());
				portTO.setUser(cliUser);
				
				daemonConfig.setPortTO(portTO);
			}
			else if (daemonConfig.getPortTO() == null) {
				// Access info is not included in the daemon config, but exists. Happens when reloading.
				// We reload the information about the port, but keep the access token the same.
				
				portTO.setPort(daemonConfig.getWebServer().getBindPort());
				daemonConfig.setPortTO(portTO);
			}
		}
		catch (Exception e) {
			logger.log(Level.WARNING, "Cannot (re-)load config. Exception thrown.", e);
		}
	}		

	// Web server starting and stopping functions
	
	private void startWebServer() throws Exception {
		if (daemonConfig.getWebServer().isEnabled()) {
			logger.log(Level.INFO, "Starting web server ...");

			webServer = new WebServer(daemonConfig);
			webServer.start();
		}
		else {
			logger.log(Level.INFO, "Not starting web server (disabled in confi)");
		}
	}
	
	private void stopWebServer() {
		if (webServer != null) {
			logger.log(Level.INFO, "Stopping web server ...");
			webServer.stop();
		}
		else {
			logger.log(Level.INFO, "Not stopping web server (not running)");			
		}
	}
	
	// Watch server starting and stopping functions
	
	private void startWatchServer() throws ConfigException {
		logger.log(Level.INFO, "Starting watch server ...");

		watchServer = new WatchServer();
		watchServer.start(daemonConfig);
	}

	private void stopWatchServer() {
		logger.log(Level.INFO, "Stopping watch server ...");
		watchServer.stop();
	}
}