org.eclipse.skalli.gerrit.client.internal.GerritClientImpl.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.skalli.gerrit.client.internal.GerritClientImpl.java

Source

/*******************************************************************************
 * 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>&#123;&quot;type&quot;:&quot;row&quot;;</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;
    }

}