/* * Copyright 2016 Groupon, Inc * Copyright 2016 The Billing Project, LLC * * The Billing Project licenses this file to you 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 org.killbill.billing.util.migration; import java.io.File; import java.io.FileInputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStreamReader; import java.io.StringReader; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Properties; import org.flywaydb.core.FlywayWithDryRun; import org.flywaydb.core.api.FlywayException; import org.flywaydb.core.internal.dbsupport.SqlStatement; import org.flywaydb.core.internal.info.MigrationInfoDumper; import org.flywaydb.core.internal.util.ClassUtils; import org.flywaydb.core.internal.util.FileCopyUtils; import org.flywaydb.core.internal.util.StringUtils; import org.flywaydb.core.internal.util.VersionPrinter; import org.flywaydb.core.internal.util.logging.Log; import org.flywaydb.core.internal.util.logging.LogFactory; import org.flywaydb.core.internal.util.logging.console.ConsoleLog.Level; import org.flywaydb.core.internal.util.logging.console.ConsoleLogCreator; import org.flywaydb.core.internal.util.scanner.classpath.ClassPathResource; // Copied over from org.flywaydb.commandline.Main (not easily extensible unfortunately) to support dry-run public class Migrator { /** * The property name for the directory containing a list of jars to load on the classpath. */ private static final String PROPERTY_JAR_DIRS = "flyway.jarDirs"; private static Log LOG; /** * Initializes the logging. * * @param level The minimum level to log at. */ private static void initLogging(final Level level) { LogFactory.setLogCreator(new ConsoleLogCreator(level)); LOG = LogFactory.getLog(Migrator.class); } /** * Main method. * * @param args The command-line arguments. */ public static void main(final String[] args) { final Level logLevel = getLogLevel(args); initLogging(logLevel); try { if (isPrintVersionAndExit(args)) { printVersion(); System.exit(0); } final List<String> operations = determineOperations(args); if (operations.isEmpty()) { printUsage(); return; } final Properties properties = new Properties(); initializeDefaults(properties); loadConfiguration(properties, args); overrideConfiguration(properties, args); dumpConfiguration(properties); loadJdbcDrivers(); loadJavaMigrationsFromJarDirs(properties); final List<SqlStatement> sqlStatements = new LinkedList<SqlStatement>(); final FlywayWithDryRun flyway = new FlywayWithDryRun(sqlStatements); filterProperties(properties); flyway.configure(properties); for (final String operation : operations) { executeOperation(flyway, operation, sqlStatements); } } catch (final Exception e) { if (logLevel == Level.DEBUG) { LOG.error("Unexpected error", e); } else { if (e instanceof FlywayException) { LOG.error(e.getMessage()); } else { LOG.error(e.toString()); } } System.exit(1); } } private static boolean isPrintVersionAndExit(final String[] args) { for (final String arg : args) { if ("-v".equals(arg)) { return true; } } return false; } /** * Executes this operation on this Flyway instance. * * @param flyway The Flyway instance. * @param operation The operation to execute. * @param sqlStatements The current list of all pending migrations. */ private static void executeOperation(final FlywayWithDryRun flyway, final String operation, final Iterable<SqlStatement> sqlStatements) { if ("clean".equals(operation)) { flyway.clean(); } else if ("baseline".equals(operation)) { flyway.baseline(); } else if ("migrate".equals(operation)) { flyway.migrate(); } else if ("dryRunMigrate".equals(operation)) { flyway.dryRunMigrate(); final StringBuilder stringBuilder = new StringBuilder("BEGIN;\n"); for (final SqlStatement sqlStatement : sqlStatements) { stringBuilder.append(sqlStatement.getSql()) .append(";\n"); } stringBuilder.append("COMMIT;"); LOG.info("\n" + stringBuilder.toString()); } else if ("validate".equals(operation)) { flyway.validate(); } else if ("info".equals(operation)) { LOG.info("\n" + MigrationInfoDumper.dumpToAsciiTable(flyway.info().all())); } else if ("repair".equals(operation)) { flyway.repair(); } else { LOG.error("Invalid operation: " + operation); printUsage(); System.exit(1); } } /** * Checks the desired log level. * * @param args The command-line arguments. * @return The desired log level. */ private static Level getLogLevel(final String[] args) { for (final String arg : args) { if ("-X".equals(arg)) { return Level.DEBUG; } if ("-q".equals(arg)) { return Level.WARN; } } return Level.INFO; } /** * Initializes the properties with the default configuration for the command-line tool. * * @param properties The properties object to initialize. */ private static void initializeDefaults(final Properties properties) { properties.put("flyway.locations", "filesystem:" + new File(getInstallationDir(), "sql").getAbsolutePath()); properties.put(PROPERTY_JAR_DIRS, new File(getInstallationDir(), "jars").getAbsolutePath()); } /** * Filters there properties to remove the Flyway Commandline-specific ones. * * @param properties The properties to filter. */ private static void filterProperties(final Properties properties) { properties.remove(PROPERTY_JAR_DIRS); properties.remove("flyway.configFile"); properties.remove("flyway.configFileEncoding"); } /** * Prints the version number on the console. * * @throws IOException when the version could not be read. */ private static void printVersion() throws IOException { final String version = new ClassPathResource("org/flywaydb/core/internal/version.txt", VersionPrinter.class.getClassLoader()).loadAsString("UTF-8"); LOG.info("Flyway " + version + " for Kill Bill"); LOG.debug("Java " + System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")"); LOG.debug(System.getProperty("os.name") + " " + System.getProperty("os.version") + " " + System.getProperty("os.arch") + "\n"); } /** * Prints the usage instructions on the console. */ private static void printUsage() { LOG.info("Usage"); LOG.info("====="); LOG.info(""); LOG.info("flyway [options] command"); LOG.info(""); LOG.info("By default, the configuration will be read from conf/flyway.conf."); LOG.info("Options passed from the command-line override the configuration."); LOG.info(""); LOG.info("Commands"); LOG.info("--------"); LOG.info("migrate : Migrates the database"); LOG.info("dryRunMigrate : Migrates the database (dry-run)"); LOG.info("clean : Drops all objects in the configured schemas"); LOG.info("info : Prints the information about applied, current and pending migrations"); LOG.info("validate : Validates the applied migrations against the ones on the classpath"); LOG.info("baseline : Baselines an existing database at the baselineVersion"); LOG.info("repair : Repairs the metadata table"); LOG.info(""); LOG.info("Options (Format: -key=value)"); LOG.info("-------"); LOG.info("driver : Fully qualified classname of the jdbc driver"); LOG.info("url : Jdbc url to use to connect to the database"); LOG.info("user : User to use to connect to the database"); LOG.info("password : Password to use to connect to the database"); LOG.info("schemas : Comma-separated list of the schemas managed by Flyway"); LOG.info("table : Name of Flyway's metadata table"); LOG.info("locations : Classpath locations to scan recursively for migrations"); LOG.info("resolvers : Comma-separated list of custom MigrationResolvers"); LOG.info("skipDefaultResolvers : Skips default resolvers (jdbc, sql and Spring-jdbc)"); LOG.info("sqlMigrationPrefix : File name prefix for sql migrations"); LOG.info("repeatableSqlMigrationPrefix : File name prefix for repeatable sql migrations"); LOG.info("sqlMigrationSeparator : File name separator for sql migrations"); LOG.info("sqlMigrationSuffix : File name suffix for sql migrations"); LOG.info("encoding : Encoding of sql migrations"); LOG.info("placeholderReplacement : Whether placeholders should be replaced"); LOG.info("placeholders : Placeholders to replace in sql migrations"); LOG.info("placeholderPrefix : Prefix of every placeholder"); LOG.info("placeholderSuffix : Suffix of every placeholder"); LOG.info("target : Target version up to which Flyway should use migrations"); LOG.info("outOfOrder : Allows migrations to be run \"out of order\""); LOG.info("callbacks : Comma-separated list of FlywayCallback classes"); LOG.info("skipDefaultCallbacks : Skips default callbacks (sql)"); LOG.info("validateOnMigrate : Validate when running migrate"); LOG.info("ignoreFutureMigrations : Allow future migrations when validating"); LOG.info("cleanOnValidationError : Automatically clean on a validation error"); LOG.info("cleanDisabled : Whether to disable clean"); LOG.info("baselineVersion : Version to tag schema with when executing baseline"); LOG.info("baselineDescription : Description to tag schema with when executing baseline"); LOG.info("baselineOnMigrate : Baseline on migrate against uninitialized non-empty schema"); LOG.info("configFile : Config file to use (default: conf/flyway.properties)"); LOG.info("configFileEncoding : Encoding of the config file (default: UTF-8)"); LOG.info("jarDirs : Dirs for Jdbc drivers & Java migrations (default: jars)"); LOG.info(""); LOG.info("Add -X to print debug output"); LOG.info("Add -q to suppress all output, except for errors and warnings"); LOG.info("Add -v to print the Flyway version and exit"); LOG.info(""); LOG.info("Example"); LOG.info("-------"); LOG.info("flyway -user=myuser -password=s3cr3t -url=jdbc:h2:mem -placeholders.abc=def migrate"); LOG.info(""); LOG.info("More info at https://flywaydb.org/documentation/commandline"); } /** * Loads all the driver jars contained in the drivers folder. (For Jdbc drivers) * * @throws IOException When the jars could not be loaded. */ private static void loadJdbcDrivers() throws IOException { final File driversDir = new File(getInstallationDir(), "drivers"); final File[] files = driversDir.listFiles(new FilenameFilter() { public boolean accept(final File dir, final String name) { return name.endsWith(".jar"); } }); // see javadoc of listFiles(): null if given path is not a real directory if (files == null) { return; } for (final File file : files) { addJarOrDirectoryToClasspath(file.getPath()); } } /** * Loads all the jars contained in the jars folder. (For Java Migrations) * This will also indirectly load custom driver jars. * * @param properties The configured properties. * @throws IOException When the jars could not be loaded. */ private static void loadJavaMigrationsFromJarDirs(final Properties properties) throws IOException { String jarDirs = properties.getProperty(PROPERTY_JAR_DIRS); if (!StringUtils.hasLength(jarDirs)) { return; } jarDirs = jarDirs.replace(File.pathSeparator, ","); final String[] dirs = StringUtils.tokenizeToStringArray(jarDirs, ","); for (final String dirName : dirs) { final File dir = new File(dirName); final File[] files = dir.listFiles(new FilenameFilter() { public boolean accept(final File dir, final String name) { return name.endsWith(".jar"); } }); // see javadoc of listFiles(): null if given path is not a real directory if (files == null) { continue; } for (final File file : files) { addJarOrDirectoryToClasspath(file.getPath()); } } } /** * Adds a jar or a directory with this name to the classpath. * * @param name The name of the jar or directory to add. * @throws IOException when the jar or directory could not be found. */ private static void addJarOrDirectoryToClasspath(final String name) throws IOException { LOG.debug("Adding location to classpath: " + name); try { final URL url = new File(name).toURI().toURL(); final URLClassLoader sysloader = (URLClassLoader) ClassLoader.getSystemClassLoader(); final Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); method.setAccessible(true); method.invoke(sysloader, url); } catch (final Exception e) { throw new FlywayException("Unable to load " + name, e); } } /** * Loads the configuration from the various possible locations. * * @param properties The properties object to load to configuration into. * @param args The command-line arguments passed in. */ private static void loadConfiguration(final Properties properties, final String[] args) { final String encoding = determineConfigurationFileEncoding(args); loadConfigurationFile(properties, getInstallationDir() + "/conf/flyway.conf", encoding, false); loadConfigurationFile(properties, System.getProperty("user.home") + "/flyway.conf", encoding, false); loadConfigurationFile(properties, "flyway.conf", encoding, false); final String configFile = determineConfigurationFileArgument(args); if (configFile != null) { loadConfigurationFile(properties, configFile, encoding, true); } } /** * Loads the configuration from the configuration file. If a configuration file is specified using the -configfile * argument it will be used, otherwise the default config file (conf/flyway.properties) will be loaded. * * @param properties The properties object to load to configuration into. * @param file The configuration file to load. * @param encoding The encoding of the configuration file. * @param failIfMissing Whether to fail if the file is missing. * @return Whether the file was loaded successfully. * @throws FlywayException when the configuration file could not be loaded. */ private static boolean loadConfigurationFile(final Properties properties, final String file, final String encoding, final boolean failIfMissing) throws FlywayException { final File configFile = new File(file); final String errorMessage = "Unable to load config file: " + configFile.getAbsolutePath(); if (!configFile.isFile() || !configFile.canRead()) { if (!failIfMissing) { LOG.debug(errorMessage); return false; } throw new FlywayException(errorMessage); } LOG.debug("Loading config file: " + configFile.getAbsolutePath()); try { final String contents = FileCopyUtils.copyToString(new InputStreamReader(new FileInputStream(configFile), encoding)); properties.load(new StringReader(contents.replace("\\", "\\\\"))); return true; } catch (final IOException e) { throw new FlywayException(errorMessage, e); } } /** * Dumps the configuration to the console when debug output is activated. * * @param properties The configured properties. */ private static void dumpConfiguration(final Properties properties) { LOG.debug("Using configuration:"); for (final Map.Entry<Object, Object> entry : properties.entrySet()) { String value = entry.getValue().toString(); value = "flyway.password".equals(entry.getKey()) ? StringUtils.trimOrPad("", value.length(), '*') : value; LOG.debug(entry.getKey() + " -> " + value); } } /** * Determines the file to use for loading the configuration. * * @param args The command-line arguments passed in. * @return The path of the configuration file on disk. */ private static String determineConfigurationFileArgument(final String[] args) { for (final String arg : args) { if (isPropertyArgument(arg) && "configFile".equals(getArgumentProperty(arg))) { return getArgumentValue(arg); } } return null; } /** * @return The installation directory of the Flyway Command-line tool. */ @SuppressWarnings("ConstantConditions") private static String getInstallationDir() { final String path = ClassUtils.getLocationOnDisk(Migrator.class); return new File(path).getParentFile().getParentFile().getAbsolutePath(); } /** * Determines the encoding to use for loading the configuration. * * @param args The command-line arguments passed in. * @return The encoding. (default: UTF-8) */ private static String determineConfigurationFileEncoding(final String[] args) { for (final String arg : args) { if (isPropertyArgument(arg) && "configFileEncoding".equals(getArgumentProperty(arg))) { return getArgumentValue(arg); } } return "UTF-8"; } /** * Overrides the configuration from the config file with the properties passed in directly from the command-line. * * @param properties The properties to override. * @param args The command-line arguments that were passed in. */ private static void overrideConfiguration(final Properties properties, final String[] args) { for (final String arg : args) { if (isPropertyArgument(arg)) { properties.put("flyway." + getArgumentProperty(arg), getArgumentValue(arg)); } } } /** * Checks whether this command-line argument tries to set a property. * * @param arg The command-line argument to check. * @return {@code true} if it does, {@code false} if not. */ private static boolean isPropertyArgument(final String arg) { return arg.startsWith("-") && arg.contains("="); } /** * Retrieves the property this command-line argument tries to assign. * * @param arg The command-line argument to check, typically in the form -key=value. * @return The property. */ private static String getArgumentProperty(final String arg) { final int index = arg.indexOf("="); return arg.substring(1, index); } /** * Retrieves the value this command-line argument tries to assign. * * @param arg The command-line argument to check, typically in the form -key=value. * @return The value or an empty string if no value is assigned. */ private static String getArgumentValue(final String arg) { final int index = arg.indexOf("="); if ((index < 0) || (index == arg.length())) { return ""; } return arg.substring(index + 1); } /** * Determine the operations Flyway should execute. * * @param args The command-line arguments passed in. * @return The operations. An empty list if none. */ private static List<String> determineOperations(final String[] args) { final List<String> operations = new ArrayList<String>(); for (final String arg : args) { if (!arg.startsWith("-")) { operations.add(arg); } } return operations; } }