com.cloudbees.jenkins.plugins.amazonecs.ECSCloud.java Source code

Java tutorial

Introduction

Here is the source code for com.cloudbees.jenkins.plugins.amazonecs.ECSCloud.java

Source

/*
 * The MIT License
 *
 *  Copyright (c) 2015, CloudBees, Inc.
 *
 *  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 com.cloudbees.jenkins.plugins.amazonecs;

import com.amazonaws.ClientConfiguration;
import com.amazonaws.regions.Region;
import com.amazonaws.regions.RegionUtils;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.cloudwatch.AmazonCloudWatchClient;
import com.amazonaws.services.cloudwatch.model.SetAlarmStateRequest;
import com.amazonaws.services.ecs.AmazonECSClient;
import com.amazonaws.services.ecs.model.ClientException;
import com.amazonaws.services.ecs.model.ContainerOverride;
import com.amazonaws.services.ecs.model.Failure;
import com.amazonaws.services.ecs.model.RunTaskRequest;
import com.amazonaws.services.ecs.model.RunTaskResult;
import com.amazonaws.services.ecs.model.StopTaskRequest;
import com.amazonaws.services.ecs.model.TaskOverride;
import com.cloudbees.jenkins.plugins.awscredentials.AWSCredentialsHelper;
import com.cloudbees.jenkins.plugins.awscredentials.AmazonWebServicesCredentials;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import hudson.AbortException;
import hudson.Extension;
import hudson.ProxyConfiguration;
import hudson.model.Computer;
import hudson.model.Descriptor;
import hudson.model.Label;
import hudson.model.Node;
import hudson.model.Slave;
import hudson.slaves.Cloud;
import hudson.slaves.JNLPLauncher;
import hudson.slaves.NodeProvisioner;
import hudson.util.ListBoxModel;
import java.io.IOException;
import jenkins.model.Jenkins;
import jenkins.model.JenkinsLocationConfiguration;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;

import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * @author <a href="mailto:nicolas.deloof@gmail.com">Nicolas De Loof</a>
 */
public class ECSCloud extends Cloud {

    private final List<ECSTaskTemplate> templates;

    /**
     * Id of the {@link AmazonWebServicesCredentials} used to connect to Amazon ECS
     */
    @Nonnull
    private final String credentialsId;

    private final String cluster;

    /**
     * An optional CloudWatch alarm to trigger, when ECS returns RESOURCE:CPU or
     * RESOURCE:MEMORY failures
     */
    private final String cloudWatchAlarmName;
    private final int instanceCap;

    private String regionName;

    /**
     * Tunnel connection through
     */
    @CheckForNull
    private String tunnel;

    private String jenkinsUrl;

    @DataBoundConstructor
    public ECSCloud(String name, List<ECSTaskTemplate> templates, @Nonnull String credentialsId, String cluster,
            String regionName, String cloudWatchAlarmName, String instanceCapStr, String jenkinsUrl) {
        super(name);
        this.credentialsId = credentialsId;
        this.cluster = cluster;
        this.templates = templates;
        this.regionName = regionName;
        this.cloudWatchAlarmName = cloudWatchAlarmName;
        this.jenkinsUrl = Strings.emptyToNull(jenkinsUrl);
        if (templates != null) {
            for (ECSTaskTemplate template : templates) {
                template.setOwer(this);
            }
        }

        if (instanceCapStr.isEmpty()) {
            this.instanceCap = Integer.MAX_VALUE;
        } else {
            this.instanceCap = Integer.parseInt(instanceCapStr);
        }
    }

    public List<ECSTaskTemplate> getTemplates() {
        return templates;
    }

    public String getCredentialsId() {
        return credentialsId;
    }

    public String getCluster() {
        return cluster;
    }

    public String getRegionName() {
        return regionName;
    }

    public void setRegionName(String regionName) {
        this.regionName = regionName;
    }

    public String getTunnel() {
        return tunnel;
    }

    public String getCloudWatchAlarmName() {
        return cloudWatchAlarmName;
    }

    public String getInstanceCapStr() {
        if (instanceCap == Integer.MAX_VALUE)
            return "";
        else
            return String.valueOf(instanceCap);
    }

    public String getJenkinsUrl() {
        return jenkinsUrl;
    }

    @DataBoundSetter
    public void setTunnel(String tunnel) {
        this.tunnel = tunnel;
    }

    @CheckForNull
    private static AmazonWebServicesCredentials getCredentials(@Nullable String credentialsId) {
        return AWSCredentialsHelper.getCredentials(credentialsId, Jenkins.getActiveInstance());
    }

    @Override
    public boolean canProvision(Label label) {
        return getTemplate(label) != null;
    }

    private ECSTaskTemplate getTemplate(Label label) {
        for (ECSTaskTemplate t : templates) {
            if (t.getMode() == Node.Mode.NORMAL) {
                if (label == null || label.matches(t.getLabelSet())) {
                    return t;
                }
            } else if (t.getMode() == Node.Mode.EXCLUSIVE) {
                if (label != null && label.matches(t.getLabelSet())) {
                    return t;
                }
            }
        }
        return null;
    }

    @Override
    public Collection<NodeProvisioner.PlannedNode> provision(Label label, int excessWorkload) {
        try {

            List<NodeProvisioner.PlannedNode> r = new ArrayList<NodeProvisioner.PlannedNode>();
            final ECSTaskTemplate template = getTemplate(label);
            if (template == null) {
                return Collections.emptyList();
            }

            int slaveCount = 0;
            final List<Node> nodes = Jenkins.getInstance().getNodes();
            for (Node node : nodes) {
                slaveCount += node instanceof ECSSlave ? 1 : 0;
            }

            int instancesRemain = instanceCap - slaveCount;
            for (int i = 1; i <= Math.min(instancesRemain, excessWorkload); i++) {
                r.add(new NodeProvisioner.PlannedNode(template.getDisplayName(),
                        Computer.threadPoolForRemoting.submit(new ProvisioningCallback(template, label)), 1));
            }
            return r;
        } catch (Exception e) {
            LOGGER.log(Level.WARNING, "Failed to provision ECS slave", e);
            return Collections.emptyList();
        }
    }

    /* package */ AmazonECSClient getAmazonECSClient() {
        return getAmazonECSClient(credentialsId, regionName);
    }

    /* package */ AmazonCloudWatchClient getAmazonCloudWatchClient() {
        return getAmazonCloudWatchClient(credentialsId, regionName);
    }

    private static AmazonCloudWatchClient getAmazonCloudWatchClient(String credentialsId, String regionName) {
        final AmazonCloudWatchClient client;
        ClientConfiguration clientConfiguration = getClientConfiguration();
        AmazonWebServicesCredentials credentials = getCredentials(credentialsId);
        if (credentials == null) {
            // no credentials provided, rely on com.amazonaws.auth.DefaultAWSCredentialsProviderChain
            // to use IAM Role define at the EC2 instance level ...
            client = new AmazonCloudWatchClient(clientConfiguration);
        } else {
            logAmazonCredentials(credentials);
            client = new AmazonCloudWatchClient(credentials, clientConfiguration);
        }
        client.setRegion(getRegion(regionName));
        LOGGER.log(Level.FINE, "Selected CloudWatch Region: {0}", regionName);
        return client;
    }

    private static AmazonECSClient getAmazonECSClient(String credentialsId, String regionName) {
        final AmazonECSClient client;
        ClientConfiguration clientConfiguration = getClientConfiguration();
        AmazonWebServicesCredentials credentials = getCredentials(credentialsId);
        if (credentials == null) {
            // no credentials provided, rely on com.amazonaws.auth.DefaultAWSCredentialsProviderChain
            // to use IAM Role define at the EC2 instance level ...
            client = new AmazonECSClient(clientConfiguration);
        } else {
            logAmazonCredentials(credentials);
            client = new AmazonECSClient(credentials, clientConfiguration);
        }
        client.setRegion(getRegion(regionName));
        LOGGER.log(Level.FINE, "Selected ECS Region: {0}", regionName);
        return client;
    }

    private static ClientConfiguration getClientConfiguration() {
        ProxyConfiguration proxy = Jenkins.getInstance().proxy;
        ClientConfiguration clientConfiguration = new ClientConfiguration();
        if (proxy != null) {
            clientConfiguration.setProxyHost(proxy.name);
            clientConfiguration.setProxyPort(proxy.port);
            clientConfiguration.setProxyUsername(proxy.getUserName());
            clientConfiguration.setProxyPassword(proxy.getPassword());
        }
        return clientConfiguration;
    }

    public static void logAmazonCredentials(AmazonWebServicesCredentials credentials) {
        if (LOGGER.isLoggable(Level.FINE)) {
            String awsAccessKeyId = credentials.getCredentials().getAWSAccessKeyId();
            String obfuscatedAccessKeyId = StringUtils.left(awsAccessKeyId, 4)
                    + StringUtils.repeat("*", awsAccessKeyId.length() - (2 * 4))
                    + StringUtils.right(awsAccessKeyId, 4);
            LOGGER.log(Level.FINE, "Connect to Amazon with IAM Access Key {1}",
                    new Object[] { obfuscatedAccessKeyId });
        }
    }

    private void triggerCloudWatchAlarm(String reason) {
        if (StringUtils.isEmpty(cloudWatchAlarmName)) {
            LOGGER.log(Level.FINE, "Not triggering alarm " + cloudWatchAlarmName);
            return;
        }
        final AmazonCloudWatchClient client = getAmazonCloudWatchClient();

        LOGGER.log(Level.INFO, "Triggering alarm " + cloudWatchAlarmName);
        try {
            final SetAlarmStateRequest req = new SetAlarmStateRequest().withAlarmName(cloudWatchAlarmName)
                    .withStateReason("Jenkins received " + reason).withStateValue("ALARM");
            client.setAlarmState(req);
        } catch (ClientException e) {
            LOGGER.log(Level.WARNING,
                    "Couldn't trigger alarm " + cloudWatchAlarmName + " caught exception: " + e.getMessage(), e);
        }
    }

    void deleteTask(String taskArn, String clusterArn) {
        final AmazonECSClient client = getAmazonECSClient();

        LOGGER.log(Level.INFO, "Delete ECS Slave task: {0}", taskArn);
        try {
            client.stopTask(new StopTaskRequest().withTask(taskArn).withCluster(clusterArn));
        } catch (ClientException e) {
            LOGGER.log(Level.SEVERE, "Couldn't stop task arn " + taskArn + " caught exception: " + e.getMessage(),
                    e);
        }
    }

    private class ProvisioningCallback implements Callable<Node> {

        private final ECSTaskTemplate template;
        @CheckForNull
        private Label label;

        public ProvisioningCallback(ECSTaskTemplate template, @Nullable Label label) {
            this.template = template;
            this.label = label;
        }

        public Node call() throws Exception {

            String uniq = Long.toHexString(System.nanoTime());
            ECSSlave slave = new ECSSlave(ECSCloud.this, name + "-" + uniq, template.getRemoteFSRoot(),
                    label == null ? null : label.toString(), new JNLPLauncher());
            Jenkins.getInstance().addNode(slave);
            LOGGER.log(Level.INFO, "Created Slave: {0}", slave.getNodeName());

            final AmazonECSClient client = getAmazonECSClient();

            slave.setClusterArn(cluster);

            Collection<String> command = getDockerRunCommand(slave);
            String definitionArn = template.getTaskDefinitionArn();
            slave.setTaskDefinitonArn(definitionArn);

            final RunTaskResult runTaskResult;
            try {
                runTaskResult = client.runTask(new RunTaskRequest().withTaskDefinition(definitionArn)
                        .withOverrides(new TaskOverride().withContainerOverrides(
                                new ContainerOverride().withName("jenkins-slave").withCommand(command)))
                        .withCluster(cluster));
            } catch (Exception e) {
                LOGGER.log(Level.SEVERE, "Error connecting to ECS cluster.", e);
                removeNode(slave);
                throw e;
            }

            if (!runTaskResult.getFailures().isEmpty()) {
                LOGGER.log(Level.WARNING, "Slave {0} - Failure to run task with definition {1} on ECS cluster {2}",
                        new Object[] { slave.getNodeName(), definitionArn, cluster });
                for (Failure failure : runTaskResult.getFailures()) {
                    LOGGER.log(Level.WARNING, "Slave {0} - Failure reason={1}, arn={2}",
                            new Object[] { slave.getNodeName(), failure.getReason(), failure.getArn() });
                    if (failure.getReason().equals("RESOURCE:CPU")
                            || failure.getReason().equals("RESOURCE:MEMORY")) {
                        triggerCloudWatchAlarm(failure.getReason());
                        if (null != slave.getComputer()) {
                            LOGGER.log(Level.WARNING, "Slave resources unavailable, deleting slave={0} arn={1}",
                                    new Object[] { slave.getNodeName(), failure.getArn() });
                            removeNode(slave);
                        }
                        try {
                            Thread.sleep(60000);
                            break;
                        } catch (InterruptedException ex) {
                            Thread.currentThread().interrupt();
                        }
                    }
                }
                throw new AbortException("Failed to run slave container " + slave.getNodeName());
            }

            String taskArn = runTaskResult.getTasks().get(0).getTaskArn();
            LOGGER.log(Level.INFO, "Slave {0} - Slave Task Started : {1}",
                    new Object[] { slave.getNodeName(), taskArn });
            slave.setTaskArn(taskArn);

            int timeout = 100; // wait 100 seconds
            boolean connected = false;
            try {
                connected = waitForAgentConnection(slave, taskArn, timeout);

                LOGGER.log(Level.INFO, "ECS Slave " + slave.getNodeName() + " (ecs task {0}) connected", taskArn);
                return slave;
            } finally {
                if (!connected) {
                    removeNode(slave);
                }
            }
        }

        private boolean waitForAgentConnection(ECSSlave agent, String taskArn, int timeout) {
            // now wait for slave to be online
            for (int i = 0; i < timeout; i++) {
                if (agent.getComputer() == null) {
                    throw new IllegalStateException(
                            "Slave " + agent.getNodeName() + " - Node was deleted, computer is null");
                }
                if (agent.getComputer().isOnline()) {
                    break;
                }
                LOGGER.log(Level.FINE, "Waiting for slave {0}(ecs task {1}) to connect ({2}/{3}).",
                        new Object[] { agent.getNodeName(), taskArn, i, timeout });
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }

            if (!agent.getComputer().isOnline()) {
                throw new IllegalStateException("ECS Slave " + agent.getNodeName() + " (ecs task " + taskArn
                        + ") is not connected after " + timeout + " seconds");
            }
            return true;
        }

        private void removeNode(ECSSlave slave) {
            final Slave node = slave.getComputer().getNode();
            if (node == null) {
                return;
            }

            try {
                slave.getComputer().setTemporarilyOffline(true, null);
                Jenkins.getInstance().removeNode(node);
            } catch (IOException e) {
                LOGGER.log(Level.WARNING, "Error while removing agent: " + slave.getNodeName());
            }
        }
    }

    private Collection<String> getDockerRunCommand(ECSSlave slave) {
        Collection<String> command = new ArrayList<String>();
        command.add("-url");
        command.add(Objects.firstNonNull(jenkinsUrl, JenkinsLocationConfiguration.get().getUrl()));
        if (StringUtils.isNotBlank(tunnel)) {
            command.add("-tunnel");
            command.add(tunnel);
        }
        command.add(slave.getComputer().getJnlpMac());
        command.add(slave.getComputer().getName());
        return command;
    }

    @Extension
    public static class DescriptorImpl extends Descriptor<Cloud> {

        @Override
        public String getDisplayName() {
            return Messages.DisplayName();
        }

        public ListBoxModel doFillCredentialsIdItems() {
            return AWSCredentialsHelper.doFillCredentialsIdItems(Jenkins.getActiveInstance());
        }

        public ListBoxModel doFillRegionNameItems() {
            final ListBoxModel options = new ListBoxModel();
            for (Region region : RegionUtils.getRegions()) {
                options.add(region.getName());
            }
            return options;
        }

        public ListBoxModel doFillClusterItems(@QueryParameter String credentialsId,
                @QueryParameter String regionName) {
            try {
                final AmazonECSClient client = getAmazonECSClient(credentialsId, regionName);

                final ListBoxModel options = new ListBoxModel();
                for (String arn : client.listClusters().getClusterArns()) {
                    options.add(arn);
                }
                return options;
            } catch (RuntimeException e) {
                // missing credentials will throw an "AmazonClientException: Unable to load AWS credentials from any provider in the chain"
                LOGGER.log(Level.INFO, "Exception searching clusters for credentials=" + credentialsId
                        + ", regionName=" + regionName, e);
                return new ListBoxModel();
            }
        }

    }

    private static final Logger LOGGER = Logger.getLogger(ECSCloud.class.getName());

    public static Region getRegion(String regionName) {
        if (StringUtils.isNotEmpty(regionName)) {
            return RegionUtils.getRegion(regionName);
        } else {
            return Region.getRegion(Regions.US_EAST_1);
        }
    }

}