Java tutorial
/******************************************************************************* * Copyright (c) 2010-2014 SAP AG and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * SAP AG - initial API and implementation *******************************************************************************/ package org.eclipse.skalli.gerrit.client.internal; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.math.NumberUtils; import org.eclipse.skalli.commons.CollectionUtils; import org.eclipse.skalli.commons.HtmlUtils; import org.eclipse.skalli.gerrit.client.GerritClient; import org.eclipse.skalli.gerrit.client.GerritFeature; import org.eclipse.skalli.gerrit.client.GerritVersion; import org.eclipse.skalli.gerrit.client.SubmitType; import org.eclipse.skalli.gerrit.client.config.GerritServerConfig; import org.eclipse.skalli.gerrit.client.exception.CommandException; import org.eclipse.skalli.gerrit.client.exception.ConnectionException; import org.eclipse.skalli.gerrit.client.internal.GSQL.ResultFormat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.jcraft.jsch.ChannelExec; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; @SuppressWarnings("nls") public class GerritClientImpl implements GerritClient { private final static Logger LOG = LoggerFactory.getLogger(GerritClientImpl.class); private final static int TIMEOUT = 2500; private static final int SLEEP_INTERVAL = 500; private static final char[] REPO_NAME_INVALID_CHARS = { '\\', ':', '~', '?', '*', '<', '>', '|', '%', '"' }; enum Cache { ALL, PROJECTS, GROUPS } private static final String GERRIT_VERSION_PREFIX = "gerrit version "; private final String ACCOUNTS_PREFIX = "username:"; private final int ACCOUNTS_QUERY_BLOCKSIZE = 100; private final Pattern UNSUPPORTED_GSQL = Pattern.compile( ".*(show|insert|update|delete|merge|create|alter|rename|truncate|drop)\\s.*", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); final GerritServerConfig gerritConfig; final int port; final String onBehalfOf; JSch client = null; Session session = null; ChannelExec channel = null; GerritVersion serverVersion = null; GerritClientImpl(GerritServerConfig gerritConfig, String onBehalfOf) { this.gerritConfig = gerritConfig; this.port = NumberUtils.toInt(gerritConfig.getPort(), GerritClient.DEFAULT_PORT); this.onBehalfOf = onBehalfOf; } @Override public void connect() throws ConnectionException { LOG.info(MessageFormat.format("Trying to connect to Gerrit {0}:{1}.", gerritConfig.getHost(), port)); File privateKeyFile = null; try { client = new JSch(); JSch.setLogger(new JschLogger()); Properties config = new Properties(); config.put("StrictHostKeyChecking", "no"); config.put("server_host_key", "ssh-rsa"); JSch.setConfig(config); privateKeyFile = getPrivateKeyFile(gerritConfig.getPrivateKey()); client.addIdentity(privateKeyFile.getAbsolutePath(), gerritConfig.getPassphrase()); session = client.getSession(gerritConfig.getUser(), gerritConfig.getHost(), port); session.setTimeout(TIMEOUT); session.connect(); } catch (JSchException e) { throw andDisconnect(new ConnectionException("Failed to connect to Gerrit", e)); } finally { if (privateKeyFile != null) { privateKeyFile.delete(); } } LOG.info(String.format("Connected to Gerrit %s:%s (%s)", gerritConfig.getHost(), port, session.getServerVersion())); } @Override public GerritVersion getVersion() throws ConnectionException, CommandException { if (serverVersion == null) { List<String> result = null; try { result = sshCommand("gerrit version"); } catch (CommandException e) { throw andDisconnect(new CommandException("Failed to retrieve Gerrit version", e)); } if (result.size() != 1) { throw andDisconnect(new CommandException( MessageFormat.format("Failed to retrieve Gerrit version: Invalid result size ({0})", CollectionUtils.toString(result, ',')))); } String versionString = result.get(0); if (StringUtils.isBlank(versionString)) { return GerritVersion.GERRIT_UNKNOWN_VERSION; } if (!versionString.startsWith(GERRIT_VERSION_PREFIX)) { return GerritVersion.GERRIT_UNKNOWN_VERSION; } serverVersion = GerritVersion.asGerritVersion(versionString.substring(GERRIT_VERSION_PREFIX.length())); } return serverVersion; } private File getPrivateKeyFile(String privateKey) { File privateKeyFile = null; try { privateKeyFile = File.createTempFile("gerrit_key", "ssh"); FileUtils.writeStringToFile(privateKeyFile, privateKey, "ISO8859_1"); } catch (IOException e) { LOG.error("Failed to write key file."); //$NON-NLS-1$ throw new RuntimeException("Failed to write key file.", e); //$NON-NLS-1$ } return privateKeyFile; } @Override public void disconnect() { if (channel != null) { channel.disconnect(); channel = null; } if (session != null) { session.disconnect(); session = null; } LOG.info("Disconnected"); } @Override public void createProject(String name, String branch, Set<String> ownerList, String parent, boolean permissionsOnly, String description, SubmitType submitType, boolean useContributorAgreements, boolean useSignedOffBy, boolean emptyCommit) throws ConnectionException, CommandException { final StringBuffer sb = new StringBuffer("gerrit create-project"); if (name == null) { throw andDisconnect(new IllegalArgumentException("'name' is required")); } String checkFailedMsg = checkProjectName(name); if (checkFailedMsg != null) { throw andDisconnect(new IllegalArgumentException(checkFailedMsg)); } appendArgument(sb, "name", name); appendArgument(sb, "branch", branch); appendArgument(sb, "owner", ownerList != null ? ownerList.toArray(new String[0]) : new String[0]); appendArgument(sb, "parent", parent); appendArgument(sb, "permissions-only", permissionsOnly); appendArgument(sb, "description", description); appendArgument(sb, "submit-type", submitType != null ? submitType.name() : null); appendArgument(sb, "use-contributor-agreements", useContributorAgreements); appendArgument(sb, "use-signed-off-by", useSignedOffBy); appendArgument(sb, "require-change-id", true); appendArgument(sb, "use-content-merge", true); // available since 2.1.6-rc1 & needed so that Hudson does not struggle with empty projects. appendArgument(sb, "empty-commit", emptyCommit); sshCommand(sb.toString()); } @Override public List<String> getProjects() throws ConnectionException, CommandException { return getProjects("all"); } @Override public List<String> getProjects(String type) throws ConnectionException, CommandException { GerritVersion version = getVersion(); final StringBuffer sb = new StringBuffer("gerrit ls-projects"); if (version.supports(GerritFeature.LS_PROJECTS_TYPE_ATTR)) { appendArgument(sb, "type", type); } return sshCommand(sb.toString()); } @Override public boolean projectExists(final String name) throws ConnectionException, CommandException { if (name == null) { return false; } return getProjects().contains(name); } @Override public void createGroup(final String name, final String owner, final String description, final Set<String> members) throws ConnectionException, CommandException { if (name == null) { throw andDisconnect(new IllegalArgumentException("'name' is required")); } String checkFailedMsg = checkGroupName(name); if (checkFailedMsg != null) { throw andDisconnect(new IllegalArgumentException(checkFailedMsg)); } final StringBuffer sb = new StringBuffer("gerrit create-group"); appendArgument(sb, "owner", owner); appendArgument(sb, "description", description); appendArgument(sb, "member", getKnownAccounts(members).toArray(new String[0])); appendArgument(sb, "visible-to-all", true); appendArgument(sb, name); sshCommand(sb.toString()); } @Override public List<String> getGroups() throws ConnectionException, CommandException { List<String> result = Collections.emptyList(); GerritVersion version = getVersion(); if (version.supports(GerritFeature.LS_GROUPS)) { StringBuffer sb = new StringBuffer("gerrit ls-groups"); if (version.supports(GerritFeature.LS_GROUPS_VISIBLE_TO_ALL_ATTR)) { appendArgument(sb, "visible-to-all", true); } result = sshCommand(sb.toString()); } else { result = new ArrayList<String>(); List<String> gsqlResult = gsql("SELECT name FROM " + GSQL.Tables.ACCOUNT_GROUPS, ResultFormat.JSON); for (final String entry : gsqlResult) { if (isRow(entry)) { result.add(JSONUtil.getString(entry, "columns.name")); } } } return result; } @Override public List<String> getGroups(String... projectNames) throws ConnectionException, CommandException { List<String> result = Collections.emptyList(); if (projectNames == null || projectNames.length == 0) { return result; } // Gerrit throws exceptions for --project options that correspond to // no Gerrit project; thus, we have to filter out thise project names before // sending the ls-groups command Set<String> allProjects = new HashSet<String>(getProjects()); GerritVersion version = getVersion(); if (version.supports(GerritFeature.LS_GROUPS_PROJECT_ATTR)) { StringBuffer sb = new StringBuffer("gerrit ls-groups"); if (version.supports(GerritFeature.LS_GROUPS_VISIBLE_TO_ALL_ATTR)) { appendArgument(sb, "visible-to-all", true); } for (String projectName : projectNames) { if (allProjects.contains(projectName)) { appendArgument(sb, "project", projectName); } } result = sshCommand(sb.toString()); } else if (version.supports(GerritFeature.REF_RIGHTS_TABLE)) { result = new ArrayList<String>(); StringBuffer sb = new StringBuffer(); sb.append("SELECT name FROM ").append(GSQL.Tables.ACCOUNT_GROUP_NAMES) .append(" WHERE group_id IN (SELECT group_id FROM ").append(GSQL.Tables.REF_RIGHTS) .append(" WHERE"); for (String projectName : projectNames) { if (allProjects.contains(projectName)) { sb.append(" project_name='").append(projectName).append("' OR"); } } sb.replace(sb.length() - 3, sb.length(), ""); sb.append(");"); List<String> gsqlResult = gsql(sb.toString(), ResultFormat.JSON); for (String entry : gsqlResult) { if (isRow(entry)) { result.add(JSONUtil.getString(entry, "columns.name")); } } } return result; } @Override public boolean groupExists(final String name) throws ConnectionException, CommandException { if (name == null) { return false; } List<String> groups = getGroups(); for (String group : groups) { if (name.equals(group)) { return true; } } return false; } @Override public Set<String> getKnownAccounts(Set<String> variousAccounts) throws ConnectionException, CommandException { if (variousAccounts == null || variousAccounts.isEmpty()) { return Collections.emptySet(); } Set<String> result = new HashSet<String>(); GerritVersion version = getVersion(); if (version.supports(GerritFeature.ACCOUNT_CHECK_OBSOLETE)) { for (String account : variousAccounts) { if (StringUtils.isNotBlank(account)) { result.add(account); } } } else { int variousAccountsSize = variousAccounts.size(); int blocks = (int) Math.ceil((float) variousAccountsSize / ACCOUNTS_QUERY_BLOCKSIZE); for (int i = 0; i < blocks; i++) { List<String> worklist = new ArrayList<String>(variousAccounts); int startIndex = i * ACCOUNTS_QUERY_BLOCKSIZE; int endIndex = Math.min((i + 1) * ACCOUNTS_QUERY_BLOCKSIZE, variousAccountsSize); result.addAll(queryKnownAccounts(worklist.subList(startIndex, endIndex))); } } return result; } @Override public String checkGroupName(String name) { if (StringUtils.isBlank(name)) { return "Group names must not be blank"; } if (StringUtils.trim(name).length() < name.length()) { return "Group names must not start or end with whitespace"; } if (containsWhitespace(name, true)) { return "Group names must not contain whitespace"; } if (HtmlUtils.containsTags(name)) { return "Group names must not contain HTML tags"; } return null; } @Override public String checkProjectName(String name) { if (StringUtils.isBlank(name)) { return "Repository names must not be blank"; } if (StringUtils.trim(name).length() < name.length()) { return "Repository names must not start or end with whitespace"; } if (containsWhitespace(name, false)) { return "Repository names must not contain whitespace"; } if (name.startsWith("/")) { return "Repository names must not start with a slash"; } if (name.endsWith("/")) { return "Repository names must not end with a trailing slash"; } if (HtmlUtils.containsTags(name)) { return "Repository names must not contain HTML tags"; } if (StringUtils.containsAny(name, REPO_NAME_INVALID_CHARS)) { return "Repository names must not contain any of the following characters: " + "'\', ':', '~', '?', '*', '<', '>', '|', '%', '\"'"; } if (name.startsWith("../") //$NON-NLS-1$ || name.contains("/../") //$NON-NLS-1$ || name.contains("/./")) { //$NON-NLS-1$ return "Repository names must not contain \"../\", \"/../\" or \"/./\""; } return null; } private boolean containsWhitespace(String s, boolean allowBlanks) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (allowBlanks && c == ' ') { continue; } if (Character.isWhitespace(c)) { return true; } if (c == '\0') { return true; } } return false; } /** * Utility method for checking accounts. * * This indirection was introduced to allow splitting the call if the parameter list is huge. * Depending on the database this could easily fail. Hence split it into separate SQL queries * and merge the results. * * @throws ConnectionException in case of connection / communication problems * @throws CommandException in case of unsuccessful commands */ private Collection<String> queryKnownAccounts(Collection<String> variousAccounts) throws ConnectionException, CommandException { final List<String> result = new ArrayList<String>(); final StringBuffer sb = new StringBuffer(); sb.append("SELECT external_id FROM ").append(GSQL.Tables.ACCOUNT_EXTERNAL_IDS) .append(" WHERE external_id IN ("); boolean noRealParameters = true; for (String variousAccount : variousAccounts) { if (!StringUtils.isBlank(variousAccount)) { sb.append("'").append(ACCOUNTS_PREFIX).append(variousAccount).append("', "); noRealParameters = false; } } sb.delete(sb.length() - 2, sb.length()); sb.append(");"); if (noRealParameters) { return result; } final List<String> gsqlResult = gsql(sb.toString(), ResultFormat.JSON); for (final String entry : gsqlResult) { if (isRow(entry)) { result.add( StringUtils.removeStart(JSONUtil.getString(entry, "columns.external_id"), ACCOUNTS_PREFIX)); } } return result; } /** * Performs a single GSQL statement according to <a href= * "http://gerrit.googlecode.com/svn/documentation/2.1.5/cmd-gsql.html" * >gerrit gsql</a> (<a href= * "http://gerrit.googlecode.com/svn/documentation/2.1.5/cmd-gsql.html#options" * >options</a>). * * Note that only SELECT statements are allowed * * @param query * the query to execute (only SELECT allowed) * @param format * <code>PRETTY</code> or <code>JSON</code> * * @return the resulting lines depending in the specified format * <code>format</code>. The last line includes query statistics. * * @throws ConnectionException in case of connection / communication problems * @throws CommandException in case of unsuccessful commands */ List<String> gsql(final String query, final ResultFormat format) throws ConnectionException, CommandException { if (StringUtils.isBlank(query)) { LOG.info("No query passed. Returning an empty result."); return Collections.emptyList(); } // only allow READ access via gsql() if (UNSUPPORTED_GSQL.matcher(query).matches()) { throw new UnsupportedOperationException( String.format("Your command contains unsupported GSQL: '%s'", query)); } final StringBuffer sb = new StringBuffer("gerrit gsql"); sb.append(" --format ").append(format.name()); sb.append(" -c \"").append(query).append("\""); return sshCommand(sb.toString()); } /** * Performs a SSH command * * @param command * the command to execute * * @return the resulting lines * * @throws ConnectionException in case of connection / communication problems * @throws CommandException in case of unsuccessful commands */ private List<String> sshCommand(final String command) throws ConnectionException, CommandException { LOG.info(MessageFormat.format("Sending on behalf of ''{0}'': ''{1}''", onBehalfOf, command)); boolean manuallyConnected = false; ByteArrayOutputStream baosOut = new ByteArrayOutputStream(); ByteArrayOutputStream baosErr = new ByteArrayOutputStream(); ByteArrayInputStream baisIn = new ByteArrayInputStream(new byte[0]); ChannelExec channel = null; try { if (client == null || session == null) { connect(); manuallyConnected = true; } channel = (ChannelExec) session.openChannel("exec"); channel.setInputStream(baisIn); channel.setOutputStream(baosOut); channel.setErrStream(baosErr); channel.setCommand(command); channel.connect(); while (!channel.isClosed()) { try { Thread.sleep(SLEEP_INTERVAL); } catch (InterruptedException e) { throw andDisconnect(new CommandException()); } } List<String> result = new LinkedList<String>(); InputStreamReader inR = new InputStreamReader(new ByteArrayInputStream(baosOut.toByteArray()), "ISO-8859-1"); BufferedReader buf = new BufferedReader(inR); String line; while ((line = buf.readLine()) != null) { result.add(line); } if (result.size() > 0) { checkForErrorsInResponse(result.get(0)); } if (baosErr.size() > 0) { InputStreamReader errISR = new InputStreamReader(new ByteArrayInputStream(baosErr.toByteArray()), "ISO-8859-1"); BufferedReader errBR = new BufferedReader(errISR); StringBuffer errSB = new StringBuffer("Gerrit CLI returned with an error:"); String errLine; while ((errLine = errBR.readLine()) != null) { errSB.append("\n").append(errLine); } throw andDisconnect(new CommandException(errSB.toString())); } return result; } catch (JSchException e) { throw andDisconnect(new ConnectionException("Failed to create/open channel.", e)); } catch (IOException e) { throw andDisconnect(new ConnectionException("Failed to read errors from channel.", e)); } finally { closeQuietly(channel, baisIn, baosOut, baosErr, manuallyConnected); } } private void closeQuietly(ChannelExec channel, ByteArrayInputStream baisIn, ByteArrayOutputStream baosOut, ByteArrayOutputStream baosErr, boolean forceDisconnect) { if (channel != null) { IOUtils.closeQuietly(baisIn); IOUtils.closeQuietly(baosOut); IOUtils.closeQuietly(baosErr); channel.disconnect(); if (forceDisconnect) { disconnect(); } } } /** * Unfortunately Gerrit sometimes returns its error messages in the normal response instead of the error stream. * Therefore this utility method should check for common erros and could be extended accordingly. * * @param firstLine * * @throws CommandException in case of unsuccessful commands */ private void checkForErrorsInResponse(String firstLine) throws CommandException { if (firstLine == null) { return; } if (firstLine.startsWith("Error when trying to")) { throw andDisconnect(new CommandException(firstLine)); } if (firstLine.startsWith("{\"type\":\"error\"")) { throw andDisconnect(new CommandException( String.format("Command returned with error: '%s'", JSONUtil.getString(firstLine, "message")))); } } /** * Helper for constructing SSH commands (name arguments) * * @param sb * the buffer that is worked on * @param argument * the name of the argument * @param value * display it or not */ private void appendArgument(final StringBuffer sb, final String argument, final boolean value) { if (value) { sb.append(" --").append(argument); } } /** * Helper for constructing SSH commands (value arguments) * * @param sb * the buffer that is worked on * @param value * the value to append */ private void appendArgument(final StringBuffer sb, final String value) { if (!StringUtils.isBlank(value)) { sb.append(" \"").append(value).append("\""); } } /** * Helper for constructing SSH commands (named value arguments) * * @param sb * the buffer that is worked on * @param argument * the name of the argument(s) * @param values * the values to append */ private void appendArgument(final StringBuffer sb, final String argument, final String... values) { for (final String value : values) { if (!StringUtils.isBlank(value)) { appendArgument(sb, argument, true); appendArgument(sb, value); } } } /** * Checks whether a returned (JSON) string is a GSQL table row * * @param entry * the entry as serialized JSON String * * @return <code>true</code> if it starts with * <code>{"type":"row";</code>, otherwise * <code>false</code> */ boolean isRow(final String entry) { return entry.startsWith("{\"type\":\"row\""); } /** * Terminate connection in error case, before throwing the exception <code>e</code> * * @throws T */ private <T extends Throwable> T andDisconnect(T e) { LOG.error("The last command could not be completed", e); disconnect(); return e; } }