/*
* The MIT License
*
* Copyright (c) 2012, CloudBees, Inc., Stephen Connolly.
*
* 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.sshagent;
import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator;
import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey;
import com.cloudbees.plugins.credentials.CredentialsProvider;
import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernameListBoxModel;
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.model.AbstractBuild;
import hudson.model.AbstractDescribableImpl;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.model.Descriptor;
import hudson.model.Item;
import hudson.model.Queue;
import hudson.model.queue.Tasks;
import hudson.security.ACL;
import hudson.tasks.BuildWrapper;
import hudson.tasks.BuildWrapperDescriptor;
import hudson.util.IOException2;
import hudson.util.ListBoxModel;
import hudson.util.Secret;
import java.io.IOException;
import java.io.ObjectStreamException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import jenkins.model.Jenkins;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.Stapler;
/**
* A build wrapper that provides an SSH agent using supplied credentials
*/
public class SSHAgentBuildWrapper extends BuildWrapper {
/**
* The {@link StandardUsernameCredentials#getId()} of the credentials to use.
*/
private transient String user;
/**
* The {@link StandardUsernameCredentials#getId()}s of the credentials
* to use.
*
* @since 1.5
*/
private final List<String> credentialIds;
/**
* When {@code true} then any missing credentials will be ignored. When {@code false} then the build will be failed
* if any of the required credentials cannot be resolved.
*
* @since 1.5
*/
private final boolean ignoreMissing;
/**
* Constructs a new instance.
*
* @param user the {@link SSHUserPrivateKey#getId()} of the credentials to use.
* @deprecated use {@link #SSHAgentBuildWrapper(java.util.List,boolean)}
*/
@Deprecated
@SuppressWarnings("unused") // used via stapler
public SSHAgentBuildWrapper(String user) {
this(Collections.singletonList(user), false);
}
/**
* Constructs a new instance.
*
* @param credentialHolders the {@link com.cloudbees.jenkins.plugins.sshagent.SSHAgentBuildWrapper.CredentialHolder}s of the credentials to use.
* @param ignoreMissing {@code true} missing credentials will not cause a build failure.
* @since 1.5
*/
@DataBoundConstructor
@SuppressWarnings("unused") // used via stapler
public SSHAgentBuildWrapper(CredentialHolder[] credentialHolders, boolean ignoreMissing) {
this(CredentialHolder.toIdList(credentialHolders), ignoreMissing);
}
/**
* Constructs a new instance.
*
* @param credentialIds the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s
* of the credentials to use.
* @param ignoreMissing {@code true} missing credentials will not cause a build failure.
* @since 1.5
*/
@SuppressWarnings("unused") // used via stapler
public SSHAgentBuildWrapper(List<String> credentialIds, boolean ignoreMissing) {
this.credentialIds = new ArrayList<String>(new LinkedHashSet<String>(credentialIds));
this.ignoreMissing = ignoreMissing;
}
/**
* Migrate legacy data format.
*
* @since 1.5
*/
private Object readResolve() throws ObjectStreamException {
if (user != null) {
return new SSHAgentBuildWrapper(Collections.singletonList(user),false);
}
return this;
}
/**
* Gets the {@link SSHUserPrivateKey#getId()} of the credentials to use.
*
* @return the {@link SSHUserPrivateKey#getId()} of the credentials to use.
*/
@SuppressWarnings("unused") // used via stapler
@Deprecated
public String getUser() {
return credentialIds.isEmpty() ? null : credentialIds.get(0);
}
/**
* Gets the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s of the
* credentials to use.
*
* @return the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s of the
* credentials to use.
* @since 1.5
*/
public List<String> getCredentialIds() {
return Collections.unmodifiableList(credentialIds);
}
/**
* When {@code true} then any missing credentials will be ignored. When {@code false} then the build will be failed
* if any of the required credentials cannot be resolved.
* @return {@code true} missing credentials will not cause a build failure.
*/
@SuppressWarnings("unused") // used via stapler
public boolean isIgnoreMissing() {
return ignoreMissing;
}
/**
* Returns the value objects used to hold the credential ids.
*
* @return the value objects used to hold the credential ids.
* @since 1.5
*/
@SuppressWarnings("unused") // used via stapler
public CredentialHolder[] getCredentialHolders() {
List<CredentialHolder> result = new ArrayList<CredentialHolder>(credentialIds.size());
for (String id : credentialIds) {
result.add(new CredentialHolder(id));
}
return result.toArray(new CredentialHolder[result.size()]);
}
/**
* {@inheritDoc}
*/
@Override
public void preCheckout(AbstractBuild build, Launcher launcher, BuildListener listener)
throws IOException, InterruptedException {
// first collect all the keys (this is so we can bomb out before starting an agent
List<SSHUserPrivateKey> keys = new ArrayList<SSHUserPrivateKey>();
for (String id : new LinkedHashSet<String>(getCredentialIds())) {
final SSHUserPrivateKey c = CredentialsProvider.findCredentialById(
id,
SSHUserPrivateKey.class,
build
);
CredentialsProvider.track(build, c);
if (c == null && !ignoreMissing) {
IOException ioe = new IOException(Messages.SSHAgentBuildWrapper_CredentialsNotFound());
ioe.printStackTrace(listener.fatalError(""));
throw ioe;
}
if (c != null && !keys.contains(c)) {
keys.add(c);
}
}
SSHAgentEnvironment environment = null;
for (hudson.model.Environment env: build.getEnvironments()) {
if (env instanceof SSHAgentEnvironment) {
environment = (SSHAgentEnvironment) env;
// strictly speaking we should break here, but we continue in case there are multiples
// the last one wins, so we want the last one
}
}
if (environment == null) {
// none so let's add one
environment = createSSHAgentEnvironment(build, launcher, listener);
build.getEnvironments().add(environment);
}
for (SSHUserPrivateKey key : keys) {
environment.add(key);
listener.getLogger().println(Messages.SSHAgentBuildWrapper_UsingCredentials(description(key)));
}
}
/**
* {@inheritDoc}
*/
@Override
public Environment setUp(AbstractBuild build, final Launcher launcher, BuildListener listener)
throws IOException, InterruptedException {
// Jenkins needs this:
// null would stop the build, and super implementation throws UnsupportedOperationException
return new NoOpEnvironment();
}
private SSHAgentEnvironment createSSHAgentEnvironment(AbstractBuild build, Launcher launcher, BuildListener listener)
throws IOException, InterruptedException {
try {
return new SSHAgentEnvironment(launcher, listener, build.getWorkspace());
} catch (IOException e) {
throw new IOException2(Messages.SSHAgentBuildWrapper_CouldNotStartAgent(), e);
} catch (InterruptedException e) {
e.printStackTrace(listener.fatalError(Messages.SSHAgentBuildWrapper_CouldNotStartAgent()));
throw e;
} catch (Throwable e) {
throw new IOException2(Messages.SSHAgentBuildWrapper_CouldNotStartAgent(), e);
}
}
/**
* Helper method that returns a safe description of a {@link StandardUsernameCredentials}.
*
* @param c the credentials.
* @return the description.
*/
@Nonnull
public static String description(@Nonnull StandardUsernameCredentials c) {
String description = Util.fixEmptyAndTrim(c.getDescription());
return c.getUsername() + (description != null ? " (" + description + ")" : "");
}
/**
* Our descriptor.
*/
@Extension
public static class DescriptorImpl extends BuildWrapperDescriptor {
/**
* {@inheritDoc}
*/
@Override
public boolean isApplicable(AbstractProject<?, ?> item) {
return true;
}
/**
* {@inheritDoc}
*/
@Override
public String getDisplayName() {
return Messages.SSHAgentBuildWrapper_DisplayName();
}
}
/**
* The SSH Agent environment.
*/
private class SSHAgentEnvironment extends Environment {
/**
* The proxy for the real remote agent that is on the other side of the channel (as the agent needs to
* run on a remote machine)
*/
private final RemoteAgent agent;
/**
* Construct the environment and initialize on the remote node.
*
* @param launcher the launcher for the remote node.
* @param listener the listener for reporting progress.
* @param sshUserPrivateKey the private key to add to the agent.
* @throws Throwable if things go wrong.
* @deprecated use {@link #SSHAgentEnvironment(hudson.Launcher, hudson.model.BuildListener, java.util.List)}
*/
@Deprecated
public SSHAgentEnvironment(Launcher launcher, final BuildListener listener,
final SSHUserPrivateKey sshUserPrivateKey) throws Throwable {
this(launcher, listener, Collections.singletonList(sshUserPrivateKey));
}
/**
* Construct the environment and initialize on the remote node.
*
* @param launcher the launcher for the remote node.
* @param listener the listener for reporting progress.
* @param sshUserPrivateKeys the private keys to add to the agent.
* @throws Throwable if things go wrong.
* @since 1.5
* @deprecated use {@link #SSHAgentEnvironment(Launcher, BuildListener)} and {@link #add(SSHUserPrivateKey)}.
*/
@Deprecated
public SSHAgentEnvironment(Launcher launcher, final BuildListener listener,
final List<SSHUserPrivateKey> sshUserPrivateKeys) throws Throwable {
this(launcher, listener);
for (SSHUserPrivateKey sshUserPrivateKey : sshUserPrivateKeys) {
add(sshUserPrivateKey);
}
}
@Deprecated
public SSHAgentEnvironment(Launcher launcher, final BuildListener listener) throws Throwable {
this(launcher, listener, (FilePath) null);
}
/**
* Construct the environment and initialize on the remote node.
*
* @param launcher the launcher for the remote node.
* @param listener the listener for reporting progress.
* @throws Throwable if things go wrong.
* @since 1.9
*/
public SSHAgentEnvironment(Launcher launcher, BuildListener listener, @CheckForNull FilePath workspace) throws Throwable {
RemoteAgent agent = null;
listener.getLogger().println("[ssh-agent] Looking for ssh-agent implementation...");
Map<String, Throwable> faults = new LinkedHashMap<String, Throwable>();
for (RemoteAgentFactory factory : Jenkins.getActiveInstance().getExtensionList(RemoteAgentFactory.class)) {
if (factory.isSupported(launcher, listener)) {
try {
listener.getLogger().println("[ssh-agent] " + factory.getDisplayName());
agent = factory.start(launcher, listener, workspace != null ? SSHAgentStepExecution.tempDir(workspace) : null);
break;
} catch (Throwable t) {
faults.put(factory.getDisplayName(), t);
}
}
}
if (agent == null) {
listener.getLogger().println("[ssh-agent] FATAL: Could not find a suitable ssh-agent provider");
listener.getLogger().println("[ssh-agent] Diagnostic report");
for (Map.Entry<String, Throwable> fault : faults.entrySet()) {
listener.getLogger().println("[ssh-agent] * " + fault.getKey());
StringWriter sw = new StringWriter();
fault.getValue().printStackTrace(new PrintWriter(sw));
for (String line : StringUtils.split(sw.toString(), "\n")) {
listener.getLogger().println("[ssh-agent] " + line);
}
}
throw new RuntimeException("[ssh-agent] Could not find a suitable ssh-agent provider.");
}
this.agent = agent;
listener.getLogger().println(Messages.SSHAgentBuildWrapper_Started());
}
/**
* Adds a key to the agent.
*
* @param key the key.
* @throws IOException if the key cannot be added.
* @since 1.9
*/
public void add(SSHUserPrivateKey key) throws IOException, InterruptedException {
final Secret passphrase = key.getPassphrase();
final String effectivePassphrase = passphrase == null ? null : passphrase.getPlainText();
for (String privateKey : key.getPrivateKeys()) {
agent.addIdentity(privateKey, effectivePassphrase, description(key));
}
}
/**
* {@inheritDoc}
*/
@Override
public void buildEnvVars(Map<String, String> env) {
env.put("SSH_AUTH_SOCK", agent.getSocket());
}
/**
* {@inheritDoc}
*/
@Override
public boolean tearDown(AbstractBuild build, BuildListener listener)
throws IOException, InterruptedException {
if (agent != null) {
agent.stop();
listener.getLogger().println(Messages.SSHAgentBuildWrapper_Stopped());
}
return true;
}
}
/**
* A value object to make it possible to pass back multiple credentials via the UI.
*
* @since 1.5
*/
public static class CredentialHolder extends AbstractDescribableImpl<CredentialHolder> {
/**
* The id.
*/
private final String id;
/**
* Stapler's constructor.
*
* @param id the ID.
*/
@DataBoundConstructor
public CredentialHolder(String id) {
this.id = id;
}
/**
* Gets the id.
*
* @return the id.
*/
public String getId() {
return id;
}
/**
* Converts an array of value objects into a list of ids.
*
* @param credentialHolders the array of value objects.
* @return the possibly empty but never null list of ids.
*/
@NonNull
public static List<String> toIdList(@Nullable CredentialHolder[] credentialHolders) {
List<String> result = new ArrayList<String>(credentialHolders == null ? 0 : credentialHolders.length);
if (credentialHolders != null) {
for (CredentialHolder h : credentialHolders) {
result.add(h.getId());
}
}
return result;
}
/**
* Our descriptor.
*/
@Extension
public static class DescriptorImpl extends Descriptor<CredentialHolder> {
/**
* {@inheritDoc}
*/
@Override
public String getDisplayName() {
return Messages.SSHAgentBuildWrapper_CredentialHolder_DisplayName();
}
/**
* Populate the list of credentials available to the job.
*
* @return the list box model.
*/
@SuppressWarnings("unused") // used by stapler
public ListBoxModel doFillIdItems() {
Item item = Stapler.getCurrentRequest().findAncestorObject(Item.class);
return new StandardUsernameListBoxModel()
.includeMatchingAs(
item instanceof Queue.Task ? Tasks.getAuthenticationOf((Queue.Task) item) : ACL.SYSTEM,
item,
SSHUserPrivateKey.class,
Collections.<DomainRequirement>emptyList(),
SSHAuthenticator.matcher()
);
}
}
}
private class NoOpEnvironment extends Environment {
}
}