DeployConfRunner.java

/**
 * Copyright (c) 2013-2017 Polago AB
 * All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package org.polago.deployconf;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.jline.reader.EndOfFileException;
import org.jline.reader.UserInterruptException;
import org.polago.deployconf.group.ConfigGroupManager;
import org.polago.deployconf.group.FileSystemConfigGroupManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.core.joran.spi.JoranException;
import ch.qos.logback.core.util.StatusPrinter;

/**
 * Main Runner class.
 */
public class DeployConfRunner {

    private static Logger logger = LoggerFactory.getLogger(DeployConfRunner.class);

    /**
     * Available RunModes.
     */
    enum RunMode {
        // Never prompt the user
        NON_INTERACTIVE,
        // Prompt the user for non-configured tasks
        INTERACTIVE,
        // Prompt the user for all tasks
        FORCE_INTERACTIVE
    };

    /**
     * The Environment Variable used to set the local repository for storing config files. This may be overridden by
     * command line options.
     */
    private static final String ENV_DEPLOYCONF_REPO = "DEPLOYCONF_REPO";

    /**
     * The default local repository relative the user's HOME directory for storing config files. This may be overridden
     * by command line options.
     */
    private static final String DEFAULT_DEPLOYCONF_REPO = "/.deployconf_repo";

    /**
     * The default deployment template path to use.
     */
    private static final String DEFAULT_TEMPLATE_PATH = "META-INF/deployment-template.xml";

    /**
     * The deployment config file suffix to use when creating deploymentConfig paths.
     */
    protected static final String DEPLOYMENT_CONFIG_SUFFIX = "deployment-config.xml";

    /**
     * The RunMode to use.
     */
    private final RunMode runMode;

    /**
     * The Zip Path to the deployment template.
     */
    private String deploymentTemplatePath = DEFAULT_TEMPLATE_PATH;

    /**
     * The explicit deployment config file to use. This is normally null.
     */
    private Path deploymentConfigFile = null;

    /**
     * The local repository for storing deployment config files. Null means current directory.
     */
    private String repositoryDirectory = null;

    /**
     * The Configuration Group Manager to use.
     */
    private ConfigGroupManager groupManager;

    /**
     * Public Constructor.
     *
     * @param runMode how the program should interact with the user
     */
    public DeployConfRunner(RunMode runMode) {
        this.runMode = runMode;
    }

    /**
     * Main entry point.
     *
     * @param args the runtime program arguments
     */
    public static void main(String[] args) {
        Options options = new Options();

        Option help = new Option("h", "help", false, "Display usage information");
        options.addOption(help);

        Option version = new Option("v", "version", false, "Display version information and exit");
        options.addOption(version);

        Option interactive = new Option("i", "interactive", false, "Run in interactive mode");
        options.addOption(interactive);

        Option forceInteractive =
            new Option("I", "force-interactive", false, "Run in interactive mode and configure all tasks");
        options.addOption(forceInteractive);

        Option quiet = new Option("q", "quiet", false, "Suppress most messages");
        options.addOption(quiet);

        Option debug = new Option("d", "debug", false, "Print Debug Information");
        options.addOption(debug);

        boolean debugEnabled = false;

        Option repoDir = new Option("r", "repo", true, "Repository directory to use for storing deployment configs");
        options.addOption(repoDir);

        Option configFile =
            new Option("f", "deployment-config-file", true, "File to use for storing the deployment config");
        options.addOption(configFile);

        Option templatePath =
            new Option("t", "deployment-template-path", true, "Path to use for locating the deployment template in the "
                + "<INPUT> file. Default is '" + DEFAULT_TEMPLATE_PATH + "'");
        options.addOption(templatePath);

        CommandLineParser parser = new DefaultParser();

        try {
            CommandLine cmd = parser.parse(options, args);
            ProjectProperties projectProperties = getProjectProperties();

            if (cmd.hasOption(version.getOpt())) {
                System.out.print(projectProperties.getName());
                System.out.print(" version ");
                System.out.println(projectProperties.getVersion());
                System.out.println(projectProperties.getCopyrightMessage());
                System.exit(0);
            }

            if (cmd.hasOption(help.getOpt())) {
                HelpFormatter formatter = new HelpFormatter();
                formatter.printHelp(projectProperties.getName() + " [OPTION]... <INPUT> <OUTPUT>",
                    projectProperties.getHelpHeader(), options, "");
                System.exit(0);
            }

            if (cmd.hasOption(debug.getOpt())) {
                logger.info("Activating Debug Logging");
                debugEnabled = true;
                setLogConfig("logback-debug.xml");
            } else if (cmd.hasOption(quiet.getOpt())) {
                setLogConfig("logback-quiet.xml");
            }

            RunMode mode = RunMode.NON_INTERACTIVE;
            if (cmd.hasOption(forceInteractive.getOpt())) {
                mode = RunMode.FORCE_INTERACTIVE;
            } else if (cmd.hasOption(interactive.getOpt())) {
                mode = RunMode.INTERACTIVE;
            }

            DeployConfRunner instance = new DeployConfRunner(mode);

            String envRepoDir = instance.getRepositoryDirectoryFromEnvironment();

            if (cmd.hasOption(repoDir.getOpt())) {
                String rd = cmd.getOptionValue(repoDir.getOpt());
                logger.debug("Using repository directory: {}", rd);
                instance.setRepositoryDirectory(rd);
            } else if (envRepoDir != null) {
                logger.debug("Using repository directory from environment {}: {}", ENV_DEPLOYCONF_REPO, envRepoDir);
                instance.setRepositoryDirectory(envRepoDir);
            } else {
                String rd = getDefaultRepository();
                instance.setRepositoryDirectory(rd);
                logger.debug("Using default repository directory: {}", rd);
            }
            Path repo = FileSystems.getDefault().getPath(instance.getRepositoryDirectory());
            if (!Files.exists(repo)) {
                Files.createDirectories(repo);
            } else if (!Files.isDirectory(repo)) {
                logger.error("Specified repository is not a directory: {}", repo);
                System.exit(1);
            }

            instance.setGroupManager(new FileSystemConfigGroupManager(Paths.get(instance.getRepositoryDirectory())));

            if (cmd.hasOption(configFile.getOpt())) {
                String f = cmd.getOptionValue(configFile.getOpt());
                logger.debug("Using explicit deployment file: {}", f);
                instance.setDeploymentConfigPath(FileSystems.getDefault().getPath(f));
            }

            if (cmd.hasOption(templatePath.getOpt())) {
                String path = cmd.getOptionValue(templatePath.getOpt());
                logger.debug("Using deployment template path: {}", path);
                instance.setDeploymentTemplatePath(path);
            }

            List<String> argList = cmd.getArgList();
            if (argList.size() != 2) {
                System.out.println("usage: " + projectProperties.getName() + " <INPUT> <OUTPUT>");
                System.exit(1);
            }
            System.exit(instance.run(argList.get(0), argList.get(1)));
        } catch (ParseException e) {
            logger.error("Command Line Parse Error: " + e.getMessage(), e);
            System.exit(1);
        } catch (UserInterruptException e) {
            logger.info("Intertupted by user");
            System.exit(1);
        } catch (EndOfFileException e) {
            logger.info("EOF detected");
            System.exit(1);
        } catch (Exception e) {
            String msg = "Internal Error: " + e.toString();
            if (!debugEnabled) {
                msg += "\n(use the -d option to print stacktraces)";
            }
            logger.error(msg, e);
            System.exit(2);
        }
    }

    /**
     * Gets the default repository directory based on the users home directory.
     *
     * @return the default repository for the user
     */
    private static String getDefaultRepository() {
        return System.getProperty("user.home") + DEFAULT_DEPLOYCONF_REPO;
    }

    /**
     * Sets the log configuration to use.
     *
     * @param config the log configuration to use
     */
    private static void setLogConfig(String config) {
        LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();

        try {
            JoranConfigurator configurator = new JoranConfigurator();
            configurator.setContext(loggerContext);
            loggerContext.reset();
            configurator.doConfigure(Thread.currentThread().getContextClassLoader().getResource(config));
        } catch (JoranException e) {
            logger.warn("Error setting log config: " + config, e);
        }
        StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext);
    }

    /**
     * Run this program.
     *
     * @param source the input file
     * @param destination the destination file
     * @return the exit status
     * @throws Exception indicating processing error
     */
    public int run(String source, String destination) throws Exception {
        int result = 0;

        DeploymentConfig template = getDeploymentConfigFromZip(source);
        DeploymentConfig config = null;
        Path repoFile = getDeploymentConfigPath(template.getName());
        boolean repoFileExists = Files.exists(repoFile);
        if (repoFileExists) {
            logger.info("Loading Deployment Configuration from: " + repoFile);
            config = getDeploymentConfigFromPath(repoFile);
        } else {
            logger.info("Creating new Deployment Config: " + repoFile);
            config = new DeploymentConfig();
            config.setGroupManager(groupManager);
        }

        logger.debug("Running in mode: {}", runMode);

        if (config.merge(template) && !(runMode == RunMode.FORCE_INTERACTIVE)) {
            if (!repoFileExists) {
                save(config);
            }
            apply(config, source, destination);
        } else {
            // Needs manual merge
            boolean interactive = runMode == RunMode.INTERACTIVE || runMode == RunMode.FORCE_INTERACTIVE;
            if (interactive
                && config.interactiveMerge(newInteractiveConfigurer(), runMode == RunMode.FORCE_INTERACTIVE)) {
                save(config);
                apply(config, source, destination);
            } else {
                save(config);
                System.err.println("Deployment Configuration is incomplete");
                System.err.println("Rerun in interactive mode " + "by using the '-i' option");
                System.err.println(" or");
                System.err.println(
                    "Edit '" + repoFile + "' and make sure that each " + "deployment property has a valid value");
                result = 2;
            }
        }

        return result;
    }

    /**
     * Gets the deploymentTemplatePath property value.
     *
     * @return the current value of the deploymentTemplatePath property
     */
    public String getDeploymentTemplatePath() {
        return deploymentTemplatePath;
    }

    /**
     * Sets the deploymentTemplatePath property.
     *
     * @param deploymentTemplatePath the new property value
     */
    public void setDeploymentTemplatePath(String deploymentTemplatePath) {
        this.deploymentTemplatePath = deploymentTemplatePath;
    }

    /**
     * Sets the deploymentConfigFile property.
     *
     * @param deploymentConfigPath the new property value
     */
    public void setDeploymentConfigPath(Path deploymentConfigPath) {
        this.deploymentConfigFile = deploymentConfigPath;
    }

    /**
     * Gets the Path to use for storing the DeploymentConfig.
     * <p>
     * Unless an explicit deployment config File is set, the File is created in the configured repository and based on
     * the config name. If no repository is set, the current working directory is used.
     *
     * @param name the DeploymentConfig name to use
     * @return the Path to the DeploymentConfig
     */
    public Path getDeploymentConfigPath(String name) {
        Path result = deploymentConfigFile;
        String dir = getRepositoryDirectory();
        if (dir == null) {
            dir = "";
        }
        if (deploymentConfigFile != null) {
            result = deploymentConfigFile;
        } else {
            FileSystem fs = FileSystems.getDefault();
            if (name != null) {
                result = fs.getPath(dir, name + "-" + DEPLOYMENT_CONFIG_SUFFIX);
            } else {
                result = fs.getPath(dir, DEPLOYMENT_CONFIG_SUFFIX);
            }
        }

        return result;
    }

    /**
     * Gets the repositoryDirectory property value.
     *
     * @return the current value of the repositoryDirectory property
     */
    public String getRepositoryDirectory() {
        return repositoryDirectory;
    }

    /**
     * Sets the repositoryDirectory property.
     *
     * @param repositoryDirectory the new property value
     */
    public void setRepositoryDirectory(String repositoryDirectory) {
        this.repositoryDirectory = repositoryDirectory;
    }

    /**
     * Gets the groupManager property value.
     *
     * @return the current value of the groupManager property
     */
    public ConfigGroupManager getGroupManager() {
        return groupManager;
    }

    /**
     * Sets the groupManager property.
     *
     * @param groupManager the new property value
     */
    public void setGroupManager(ConfigGroupManager groupManager) {
        this.groupManager = groupManager;
    }

    /**
     * Create a InteractiveConfigurer instance.
     *
     * @return a InteractiveConfigurer instance.
     * @throws IOException indicating failure
     */
    protected InteractiveConfigurer newInteractiveConfigurer() throws IOException {
        return new ConsoleInteractiveConfigurer();
    }

    /**
     * Gets the repository directory from the Environment, if possible.
     *
     * @return a repository directory or null indicating not set
     */
    protected String getRepositoryDirectoryFromEnvironment() {
        return System.getenv(ENV_DEPLOYCONF_REPO);
    }

    /**
     * Gets the Project Properties for this program.
     *
     * @return the Project Properteis for this program
     * @throws IOException indicating failure to load properties
     */
    private static ProjectProperties getProjectProperties() throws IOException {
        return ProjectProperties.instance();
    }

    /**
     * Apply the given DeploymentConfig to the source and create the destination.
     *
     * @param config the DeploymentConfig to apply
     * @param source the input file
     * @param destination the destination file
     * @throws Exception indicating processing error
     */
    private void apply(DeploymentConfig config, String source, String destination) throws Exception {

        FileSystem fs = FileSystems.getDefault();
        Path sourceFile = fs.getPath(source);
        Path destFile = fs.getPath(destination);

        InputStream srcStream = null;
        OutputStream destStream = null;

        try {
            srcStream = Files.newInputStream(sourceFile);
            logger.debug("Using input file: {}", sourceFile);
            destStream = Files.newOutputStream(destFile);
            logger.debug("Using output file: {}", destFile);
            config.apply(srcStream, destStream, getDeploymentTemplatePath());
        } finally {
            if (srcStream != null) {
                srcStream.close();
            }

            if (destStream != null) {
                destStream.close();
            }
        }
    }

    /**
     * Save the DeploymentConfig to the configured persistent storage.
     *
     * @param config the DeploymentConfig to save
     * @throws IOException indicating IO error
     */
    private void save(DeploymentConfig config) throws IOException {
        Path file = getDeploymentConfigPath(config.getName());
        logger.info("Saving Deployment Configuration to '" + file + "'");
        FileOutputStream os = new FileOutputStream(file.toFile());
        try {
            config.save(os);
        } finally {
            os.close();
        }
    }

    /**
     * Gets a DeploymentConfig instance from a Zip file.
     *
     * @param source the ZipFile to use
     * @return a DeploymentConfig representation of the ReadableByteChannel
     * @throws Exception indicating error
     */
    private DeploymentConfig getDeploymentConfigFromZip(String source) throws Exception {

        ZipFile zipFile = new ZipFile(source);
        ZipEntry entry = zipFile.getEntry(deploymentTemplatePath);
        if (entry == null) {
            zipFile.close();
            throw new IllegalArgumentException(
                "No deployment template file found in file '" + source + "': " + deploymentTemplatePath);
        }
        InputStream is = zipFile.getInputStream(entry);

        DeploymentReader reader = new DeploymentReader(is, groupManager);
        DeploymentConfig result = reader.parse();
        is.close();
        zipFile.close();

        return result;
    }

    /**
     * Gets a DeploymentConfig instance from a Path.
     *
     * @param path the file to use
     * @return a DeploymentConfig representation of the ReadableByteChannel
     * @throws Exception indicating error
     */
    private DeploymentConfig getDeploymentConfigFromPath(Path path) throws Exception {

        ReadableByteChannel ch = FileChannel.open(path, StandardOpenOption.READ);
        InputStream is = Channels.newInputStream(ch);

        DeploymentReader reader = new DeploymentReader(is, getGroupManager());
        DeploymentConfig result = reader.parse();
        ch.close();

        return result;
    }
}