package liquibase.ext.percona;
/*
* 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.
*/
import java.io.ByteArrayOutputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import liquibase.database.Database;
import liquibase.exception.UnexpectedLiquibaseException;
import liquibase.logging.LogFactory;
import liquibase.logging.Logger;
import liquibase.sql.Sql;
import liquibase.statement.core.RuntimeStatement;
import liquibase.util.StreamUtil;
/**
* Statement to run {@code pt-online-schema-change} in order
* to alter a database table.
*/
public class PTOnlineSchemaChangeStatement extends RuntimeStatement {
public static final String COMMAND = "pt-online-schema-change";
private static PerconaToolkitVersion perconaToolkitVersion = null;
static Boolean available = null;
private static Logger log = LogFactory.getInstance().getLog();
private String tableName;
private String alterStatement;
public PTOnlineSchemaChangeStatement(String tableName, String alterStatement) {
this.tableName = tableName;
this.alterStatement = alterStatement;
}
/**
* Tokenizes the given options into separate arguments, so that it can be
* fed into the {@link ProcessBuilder}'s commands.
* @param options the options as one single string
* @return the list of arguments
*/
private List<String> tokenize(String options) {
StringTokenizer stringTokenizer = new StringTokenizer(options);
List<String> result = new LinkedList<String>();
while (stringTokenizer.hasMoreTokens()) {
result.add(stringTokenizer.nextToken());
}
return joinQuotedArguments(result);
}
/**
* Very simplistic approach to join together any quoted arguments.
* Only double quotes are supported and the join character is a space.
* @param tokenizedArguments the arguments tokenized by space
* @return the filtered arguments, maybe joined
*/
private List<String> joinQuotedArguments(List<String> tokenizedArguments) {
final String joinCharacters = " ";
List<String> filtered = new LinkedList<String>();
boolean inQuotes = false;
for (int i = 0; i < tokenizedArguments.size(); i++) {
String arg = tokenizedArguments.get(i);
if (!inQuotes) {
if (arg.startsWith("\"")) {
inQuotes = true;
arg = arg.substring(1);
}
if (arg.endsWith("\"")) {
inQuotes = false;
arg = arg.substring(0, arg.length() - 1);
}
filtered.add(arg);
} else {
if (arg.endsWith("\"")) {
inQuotes = false;
arg = arg.substring(0, arg.length() - 1);
}
String last = filtered.get(filtered.size() - 1);
filtered.set(filtered.size() - 1, last + joinCharacters + arg);
}
}
return filtered;
}
/**
* Builds the command line arguments that will be executed.
* @param database the database - needed to get the connection info.
* @return the command line arguments including {@link #COMMAND}
*/
List<String> buildCommand(Database database) {
List<String> commands = new ArrayList<String>();
commands.add(PTOnlineSchemaChangeStatement.COMMAND);
if (!Configuration.getAdditionalOptions().isEmpty()) {
commands.addAll(tokenize(Configuration.getAdditionalOptions()));
}
commands.add("--alter=" + alterStatement);
commands.add("--alter-foreign-keys-method=auto");
if (database.getConnection() != null) {
DatabaseConnectionUtil connection = new DatabaseConnectionUtil(database.getConnection());
commands.add("--host=" + connection.getHost());
commands.add("--port=" + connection.getPort());
commands.add("--user=" + connection.getUser());
String pw = connection.getPassword();
if (pw != null) {
commands.add("--password=" + pw);
}
}
commands.add("--execute");
commands.add("D=" + database.getLiquibaseSchemaName() + ",t=" + tableName);
return commands;
}
/**
* Generates the command line that would be executed and return it as a single string.
* The password will be masked.
* @param database the database - needed to get the connection info
* @return the string
*/
public String printCommand(Database database) {
List<String> command = buildCommand(database);
return filterCommands(command);
}
/**
* Converts the given command list into a single string and mask the password
* @param command the command line arguments that would be used for pt-online-schema-change
* @return the string with masked password
*/
private String filterCommands(List<String> command) {
StringBuilder sb = new StringBuilder();
for (String s : command) {
sb.append(" ");
if (s.startsWith("--password")) {
sb.append("--password=***");
} else if (s.contains(" ")) {
sb.append(s.substring(0, s.indexOf('=') + 1)).append("\"").append(s.substring(s.indexOf('=') + 1))
.append("\"");
} else {
sb.append(s);
}
}
return sb.substring(1).toString();
}
/**
* Actually executes pt-online-schema change. Does not generate any Sql.
* @return always <code>null</code>
*/
@Override
public Sql[] generate(Database database) {
List<String> cmndline = buildCommand(database);
log.info("Executing: " + filterCommands(cmndline));
ProcessBuilder pb = new ProcessBuilder(cmndline);
pb.redirectErrorStream(true);
Process p = null;
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
final OutputStream tee = new FilterOutputStream(outputStream) {
@Override
public void write(int b) throws IOException {
if (b == '\n') {
log.info(outputStream.toString(Charset.defaultCharset().toString()));
outputStream.reset();
} else {
super.write(b);
}
}
};
try {
p = pb.start();
final InputStream in = p.getInputStream();
final InputStream err = p.getErrorStream();
IOThread reader = new IOThread(in, tee);
IOThread reader2 = new IOThread(err, tee);
reader.start();
reader2.start();
int exitCode = p.waitFor();
reader.join(5000);
reader2.join(5000);
// log the remaining output
log.info(outputStream.toString(Charset.defaultCharset().toString()));
if (exitCode != 0) {
throw new RuntimeException("Percona exited with " + exitCode);
}
} catch (IOException e) {
throw new UnexpectedLiquibaseException(e);
} catch (InterruptedException e) {
throw new UnexpectedLiquibaseException(e);
} finally {
if (p != null) {
StreamUtil.closeQuietly(p.getErrorStream());
StreamUtil.closeQuietly(p.getInputStream());
StreamUtil.closeQuietly(p.getOutputStream());
p.destroy();
}
StreamUtil.closeQuietly(outputStream);
}
return null;
}
@Override
public String toString() {
return PTOnlineSchemaChangeStatement.class.getSimpleName()
+ "[table: " + tableName + ", alterStatement: " + alterStatement + "]";
}
private static class IOThread extends Thread {
private Logger log = LogFactory.getInstance().getLog();
private InputStream from;
private OutputStream to;
public IOThread(InputStream from, OutputStream to) {
super();
setDaemon(true);
this.from = from;
this.to = to;
}
@Override
public void run() {
try {
StreamUtil.copy(from, to);
} catch (IOException e) {
log.debug("While copying streams", e);
}
}
}
public static synchronized PerconaToolkitVersion getVersion() {
if (available == null) {
checkIsAvailableAndGetVersion();
}
return perconaToolkitVersion != null ? perconaToolkitVersion : new PerconaToolkitVersion(null);
}
/**
* Checks whether the command is available and can be started.
* <p>
* <em>Implementation detail:</em>
* This is lazily detected once and then cached.
* </p>
* @return <code>true</code> if it is available and executable, <code>false</code> otherwise
* @see #COMMAND
*/
public static synchronized boolean isAvailable() {
if (available != null) {
return available.booleanValue();
}
checkIsAvailableAndGetVersion();
return available.booleanValue();
}
private static void checkIsAvailableAndGetVersion() {
ProcessBuilder pb = new ProcessBuilder(COMMAND, "--version");
pb.redirectErrorStream(true);
Process p = null;
try {
p = pb.start();
p.waitFor();
String output = StreamUtil.getStreamContents(p.getInputStream());
if (output != null) {
Matcher matcher = Pattern.compile("(\\d+\\.\\d+\\.\\d+)").matcher(output);
if (matcher.find()) {
perconaToolkitVersion = new PerconaToolkitVersion(matcher.group(1));
}
}
available = true;
log.info("Using percona toolkit: " + perconaToolkitVersion);
} catch (IOException e) {
available = false;
} catch (InterruptedException e) {
available = false;
} finally {
if (p != null) {
StreamUtil.closeQuietly(p.getErrorStream());
StreamUtil.closeQuietly(p.getInputStream());
StreamUtil.closeQuietly(p.getOutputStream());
p.destroy();
}
}
}
}