package com.nirima.jenkins.plugins.docker.builder;
import com.cloudbees.plugins.credentials.Credentials;
import com.cloudbees.plugins.credentials.common.CertificateCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.PushImageCmd;
import com.github.dockerjava.api.exception.DockerException;
import com.github.dockerjava.api.model.AuthConfig;
import com.github.dockerjava.api.model.BuildResponseItem;
import com.github.dockerjava.api.model.Identifier;
import com.github.dockerjava.api.model.PushResponseItem;
import com.github.dockerjava.core.DockerClientConfig;
import com.github.dockerjava.core.KeystoreSSLConfig;
import com.github.dockerjava.core.LocalDirectorySSLConfig;
import com.github.dockerjava.core.NameParser;
import com.github.dockerjava.core.command.BuildImageResultCallback;
import com.github.dockerjava.core.command.PushImageResultCallback;
import com.github.dockerjava.api.model.AuthConfigurations;
import com.nirima.jenkins.plugins.docker.DockerPluginConfiguration;
import com.nirima.jenkins.plugins.docker.DockerRegistry;
import com.nirima.jenkins.plugins.docker.utils.DockerDirectoryCredentials;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shaded.com.google.common.base.Strings;
import com.nirima.jenkins.plugins.docker.DockerCloud;
import com.nirima.jenkins.plugins.docker.action.DockerBuildImageAction;
import com.nirima.jenkins.plugins.docker.client.ClientBuilderForPlugin;
import com.nirima.jenkins.plugins.docker.client.ClientConfigBuilderForPlugin;
import com.nirima.jenkins.plugins.docker.client.DockerCmdExecConfig;
import com.nirima.jenkins.plugins.docker.client.DockerCmdExecConfigBuilderForPlugin;
import com.nirima.jenkins.plugins.docker.utils.JenkinsUtils;
import hudson.AbortException;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.model.*;
import hudson.remoting.VirtualChannel;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Builder;
import hudson.util.FormValidation;
import jenkins.MasterToSlaveFileCallable;
import jenkins.tasks.SimpleBuildStep;
import org.apache.commons.lang.Validate;
import org.jenkinsci.plugins.tokenmacro.MacroEvaluationException;
import org.jenkinsci.plugins.tokenmacro.TokenMacro;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import shaded.com.google.common.base.Joiner;
import shaded.com.google.common.base.Optional;
import shaded.com.google.common.base.Splitter;
import javax.annotation.CheckForNull;
import java.io.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import static com.nirima.jenkins.plugins.docker.utils.JenkinsUtils.getAuthConfigFor;
import static com.nirima.jenkins.plugins.docker.utils.JenkinsUtils.getAuthConfigurations;
import static com.nirima.jenkins.plugins.docker.utils.LogUtils.printResponseItemToListener;
import static hudson.plugins.sshslaves.SSHLauncher.lookupSystemCredentials;
import static org.apache.commons.lang.StringUtils.isEmpty;
/**
* Builder extension to build / publish an image from a Dockerfile.
*/
public class DockerBuilderPublisher extends Builder implements Serializable, SimpleBuildStep {
private static final Logger LOGGER = LoggerFactory.getLogger(DockerBuilderPublisher.class);
/**
* The docker spec says "<i>A tag name may contain lowercase and uppercase
* characters, digits, underscores, periods and dashes. A tag name may not
* start with a period or a dash and may contain a maximum of 128
* characters</i>". This is a simplified version of that specification.
*/
private static final String TAG_REGEX = "[a-zA-Z0-9-_.]+";
/**
* The docker spec says "<i>Name components may contain lowercase
* characters, digits and separators. A separator is defined as a period,
* one or two underscores, or one or more dashes. A name component may not
* start or end with a separator</i>". This is a simplified version of that
* specification.
*/
private static final String NAME_COMPONENT_REGEX = "[a-z0-9-_.]+";
/**
* The docker spec says "<i>The (registry) hostname must comply with
* standard DNS rules, but may not contain underscores. If a hostname is
* present, it may optionally be followed by a port number in the format
* :8080</i>". This is a simplified version of that specification.
*/
private static final String REGISTRY_HOSTNAME_REGEX = "[a-zA-Z0-9-.]+(:[0-9]+)?";
/**
* The docker spec says "<i>An image name is made up of slash-separated name
* components, optionally prefixed by a registry hostname</i>".
*/
private static final String IMAGE_NAME_REGEX = "(" + REGISTRY_HOSTNAME_REGEX + "/)?" + NAME_COMPONENT_REGEX + "(/"
+ NAME_COMPONENT_REGEX + ")*";
/**
* A regex matching IMAGE[:TAG] (from the "docker tag" command) where IMAGE
* matches {@link #IMAGE_NAME_REGEX} and TAG matches {@link #TAG_REGEX}.
*/
private static final String VALID_REPO_REGEX = "^" + IMAGE_NAME_REGEX + "(:" + TAG_REGEX + ")?$";
/** Compiled version of {@link #VALID_REPO_REGEX}. */
private static final Pattern VALID_REPO_PATTERN = Pattern.compile(VALID_REPO_REGEX);
public final String dockerFileDirectory;
/**
* @deprecated use {@link #tags}
*/
@Deprecated
public String tag;
@CheckForNull
private List<String> tags;
public final boolean pushOnSuccess;
public final boolean cleanImages;
public final boolean cleanupWithJenkinsJobDelete;
public final String cloud;
@DataBoundConstructor
public DockerBuilderPublisher(String dockerFileDirectory,
String cloud,
String tagsString,
boolean pushOnSuccess,
boolean cleanImages,
boolean cleanupWithJenkinsJobDelete) {
this.dockerFileDirectory = dockerFileDirectory;
setTagsString(tagsString);
this.tag = null;
this.cloud = cloud;
this.pushOnSuccess = pushOnSuccess;
this.cleanImages = cleanImages;
this.cleanupWithJenkinsJobDelete = cleanupWithJenkinsJobDelete;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public String getTagsString() {
return getTags() == null ? "" : Joiner.on("\n").join(getTags());
}
public void setTagsString(String tagsString) {
setTags(filterStringToList(tagsString));
}
public static List<String> filterStringToList(String str) {
return str == null ? Collections.<String>emptyList() : Splitter.on("\n").omitEmptyStrings().trimResults().splitToList(str);
}
public static void verifyTags(String tagsString) {
final List<String> verifyTags = filterStringToList(tagsString);
for (String verifyTag : verifyTags) {
// Our strings are subjected to variable substitution before they are used, so ${foo} might be valid.
// So we do some fake substitution to help prevent incorrect complaints.
final String expandedTag = verifyTag.replaceAll("\\$\\{[^}]*NUMBER\\}", "1234").replaceAll("\\$\\{[^}]*\\}", "xyz");
if (!VALID_REPO_PATTERN.matcher(expandedTag).matches()) {
throw new IllegalArgumentException("Tag " + verifyTag + " doesn't match "+ VALID_REPO_REGEX);
}
}
}
protected DockerCloud getCloud(Launcher launcher) {
DockerCloud theCloud;
if( !Strings.isNullOrEmpty(cloud) ) {
theCloud = JenkinsUtils.getServer(cloud);
} else {
Optional<DockerCloud> cloud = JenkinsUtils.getCloudForChannel(launcher.getChannel());
if (!cloud.isPresent())
throw new RuntimeException("Could not find the cloud this project was built on");
theCloud = cloud.get();
}
// Triton can't do docker build. Ensure we're not trying to do that.
if( theCloud.isTriton() ) {
LOGGER.warn("Selected cloud for build does not support this feature. Finding an alternative");
for(DockerCloud dc : JenkinsUtils.getServers()) {
if( !dc.isTriton() ) {
LOGGER.warn("Picked {} cloud instead", dc.getDisplayName());
return dc;
}
}
}
return theCloud;
}
class Run implements Serializable {
final transient Launcher launcher;
final TaskListener listener;
final FilePath fpChild;
final List<String> tagsToUse;
// Marshal the builder across the wire.
private transient DockerClient _client;
final DockerClientConfig clientConfig;
final DockerCmdExecConfig dockerCmdExecConfig;
final transient hudson.model.Run<?,?> run;
final String url;
private Run(hudson.model.Run<?, ?> run, final Launcher launcher, final TaskListener listener, FilePath fpChild, List<String> tagsToUse, DockerCloud dockerCloud) {
this.run = run;
this.launcher = launcher;
this.listener = listener;
this.fpChild = fpChild;
this.tagsToUse = tagsToUse;
// Don't build it yet. This may happen on a remote server.
clientConfig = ClientConfigBuilderForPlugin.dockerClientConfig()
.forCloud(dockerCloud).build();
dockerCmdExecConfig = DockerCmdExecConfigBuilderForPlugin.builder()
.forCloud(dockerCloud).build();
url = dockerCloud.getServerUrl();
}
// public Run(hudson.model.Run<?, ?> run, FilePath workspace, Launcher launcher, TaskListener listener, DockerCloud cloud) {
// this(run, launcher, listener,new FilePath(workspace, dockerFileDirectory), expandTags(run, launcher, listener), cloud);
// }
private DockerClient getClient() {
if (_client == null) {
Validate.notNull(clientConfig, "Could not get client because we could not find the cloud that the " +
"project was built on. Was this build run on Docker?");
_client = ClientBuilderForPlugin.builder()
.withDockerCmdExecConfig(dockerCmdExecConfig)
.withDockerClientConfig(clientConfig)
.build();
}
return _client;
}
boolean run() throws IOException, InterruptedException {
final PrintStream llog = listener.getLogger();
llog.println("Docker Build");
String imageId = buildImage();
// The ID of the image we just generated
if (imageId == null) {
return false;
}
llog.println("Docker Build Response : " + imageId);
// Add an action to the build
run.addAction(new DockerBuildImageAction(url, imageId, tagsToUse, cleanupWithJenkinsJobDelete, pushOnSuccess));
run.save();
if (pushOnSuccess) {
llog.println("Pushing " + tagsToUse);
pushImages();
}
if (cleanImages) {
// For some reason, docker delete doesn't delete all tagged
// versions, despite force = true.
// So, do it multiple times (protect against infinite looping).
llog.println("Cleaning local images [" + imageId + "]");
try {
cleanImages(imageId);
} catch (Exception ex) {
llog.println("Error attempting to clean images");
}
}
llog.println("Docker Build Done");
return true;
}
private void cleanImages(String id) {
getClient().removeImageCmd(id)
.withForce(true)
.exec();
}
private String buildImage() throws IOException, InterruptedException {
final AuthConfigurations authConfigurations = getAuthConfigurations();
return fpChild.act(new MasterToSlaveFileCallable<String>() {
public String invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
log("Docker Build: building image at path " + f.getAbsolutePath());
BuildImageResultCallback resultCallback = new BuildImageResultCallback() {
public void onNext(BuildResponseItem item) {
String text = item.getStream();
if (text != null) {
log(text);
}
super.onNext(item);
}
};
String imageId = getClient().buildImageCmd(f)
.withBuildAuthConfigs(authConfigurations)
.exec(resultCallback)
.awaitImageId();
if (imageId == null) {
throw new AbortException("Built image id is null. Some error accured");
}
// tag built image with tags
for (String tag : tagsToUse) {
final NameParser.ReposTag reposTag = NameParser.parseRepositoryTag(tag);
final String commitTag = isEmpty(reposTag.tag) ? "latest" : reposTag.tag;
log("Tagging built image with " + reposTag.repos + ":" + commitTag);
getClient().tagImageCmd(imageId, reposTag.repos, commitTag).withForce().exec();
}
return imageId;
}
});
}
protected void log(String s)
{
final PrintStream llog = listener.getLogger();
llog.println(s);
}
private void pushImages() {
for (String tagToUse : tagsToUse) {
Identifier identifier = Identifier.fromCompoundString(tagToUse);
PushImageResultCallback resultCallback = new PushImageResultCallback() {
public void onNext(PushResponseItem item) {
if( item == null ) {
// docker-java not happy if you pass it nulls.
log("Received NULL Push Response. Ignoring");
return;
}
printResponseItemToListener(listener, item);
super.onNext(item);
}
};
try {
PushImageCmd cmd = getClient().pushImageCmd(identifier);
AuthConfig authConfig = getAuthConfigFor(tagToUse);
if( authConfig != null ) {
cmd.withAuthConfig(authConfig);
}
cmd.exec(resultCallback).awaitSuccess();
} catch(DockerException ex) {
// Private Docker registries fall over regularly. Tell the user so they
// have some clue as to what to do as the exception gives no hint.
log("Exception pushing docker image. Check that the destination registry is running.");
throw ex;
}
}
}
}
@Override
public void perform(hudson.model.Run<?, ?> run, FilePath workspace, Launcher launcher, TaskListener listener) throws InterruptedException, IOException {
List<String> expandedTags;
if( run instanceof AbstractBuild) {
expandedTags = expandTags((AbstractBuild<?, ?>) run, launcher, listener);
} else {
expandedTags = tags;
}
new Run(run, launcher, listener,new FilePath(workspace, dockerFileDirectory), expandedTags, getCloud(launcher)).run();
}
@Override
public DescriptorImpl getDescriptor() {
return (DescriptorImpl) super.getDescriptor();
}
private List<String> expandTags(AbstractBuild<?, ?> build, Launcher launcher, TaskListener listener) {
List<String> eTags = new ArrayList<>(tags.size());
for (String tag : tags) {
try {
eTags.add(TokenMacro.expandAll(build, listener, tag));
} catch (MacroEvaluationException | IOException | InterruptedException e) {
listener.getLogger().println("Couldn't macro expand tag " + tag);
}
}
return eTags;
}
@Extension
public static final class DescriptorImpl extends BuildStepDescriptor<Builder> {
public FormValidation doCheckTagsString(@QueryParameter String tagsString) {
try {
verifyTags(tagsString);
} catch (Throwable t) {
return FormValidation.error(t.getMessage());
}
return FormValidation.ok();
}
@Override
public boolean isApplicable(Class<? extends AbstractProject> jobType) {
return true;
}
@Override
public String getDisplayName() {
return "Build / Publish Docker Image";
}
}
}