/* * 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 org.jenkinsci.plugins.docker.commons.credentials; import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.common.IdCredentials; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; import com.cloudbees.plugins.credentials.domains.DomainRequirement; import com.cloudbees.plugins.credentials.domains.HostnameRequirement; import hudson.Extension; import hudson.FilePath; import hudson.Util; import hudson.model.AbstractBuild; import hudson.model.AbstractDescribableImpl; import hudson.model.Describable; import hudson.model.Descriptor; import hudson.model.Item; import hudson.model.Run; import hudson.model.TaskListener; import hudson.remoting.VirtualChannel; import hudson.util.ListBoxModel; import jenkins.authentication.tokens.api.AuthenticationTokens; import jenkins.model.Jenkins; import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Collections; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import static com.cloudbees.plugins.credentials.CredentialsMatchers.*; import hudson.AbortException; import hudson.EnvVars; import hudson.Launcher; import hudson.slaves.WorkspaceList; import org.jenkinsci.plugins.docker.commons.tools.DockerTool; /** * Encapsulates the endpoint of DockerHub and how to interact with it. * * <p> * As {@link Describable} it comes with pre-baked configuration form that you can use in * your builders/publishers/etc that interact with Docker daemon. * * @author Kohsuke Kawaguchi */ public class DockerRegistryEndpoint extends AbstractDescribableImpl<DockerRegistryEndpoint> { /** * Some regex magic to parse docker registry:port/namespace/name:tag into parts, using the same constraints as * docker push. * * registry must not contain / and must contain a . * * everything but name is optional */ private static final Pattern DOCKER_REGISTRY_PATTERN = Pattern .compile("(([^/]+\\.[^/]+)/)?(([a-z0-9_]+)/)?([a-zA-Z0-9-_\\.]+)(:([a-z0-9-_\\.]+))?"); private static final Logger LOGGER = Logger.getLogger(DockerRegistryEndpoint.class.getName()); /** * Null if this is on the public docker hub. */ private final String url; private final @CheckForNull String credentialsId; @DataBoundConstructor public DockerRegistryEndpoint(String url, String credentialsId) { this.url = Util.fixEmpty(url); this.credentialsId = Util.fixEmpty(credentialsId); } /** * Parse the registry endpoint out of a registry:port/namespace/name:tag string as created by * {@link #imageName(String)}. Credentials are set to the id passed. The url is built from registry:port into * https://registry:port, the same way docker push does. * * @param s * @param credentialsId * passed to the constructor, can be null * @throws IllegalArgumentException * if string can't be parsed * @return The DockerRegistryEndpoint corresponding to the registry:port part of the string */ public static DockerRegistryEndpoint fromImageName(String s, @CheckForNull String credentialsId) { Matcher matcher = DOCKER_REGISTRY_PATTERN.matcher(s); if (!matcher.matches() || matcher.groupCount() < 7) { throw new IllegalArgumentException(s + " does not match regex " + DOCKER_REGISTRY_PATTERN); } String url; try { // docker push always uses https url = matcher.group(2) == null ? null : new URL("https://" + matcher.group(2)).toString(); } catch (MalformedURLException e) { throw new IllegalArgumentException(s + " can not be parsed as URL: " + e.getMessage()); } // not used, but could be /*String namespace = matcher.group(4); String repoName = matcher.group(5); String tag = matcher.group(7);*/ return new DockerRegistryEndpoint(url, credentialsId); } /** * Gets the endpoint URL, such as "https://index.docker.io/v1/" */ public @Nonnull URL getEffectiveUrl() throws IOException { if (url != null) { return new URL(url); } else { return new URL("https://index.docker.io/v1/"); } } /** * For stapler. */ public @Nullable String getUrl() { return url; } /** * {@linkplain IdCredentials#getId() ID} of the credentials used to talk to this endpoint. */ public @Nullable String getCredentialsId() { return credentialsId; } /** * Plugins that want to refer to a {@link IdCredentials} should do so via ID string, * and use this method to resolve it and convert to {@link DockerRegistryToken}. * * Implements the logic {@link CredentialsProvider#findCredentialById(String, Class, Run, DomainRequirement...)} * but for an {@link Item}. * * @param context * If you are a build step trying to access DockerHub in the context of a build/job, * specify that job. Otherwise null. If you are scoped to something else, you might * have to interact with {@link CredentialsProvider} directly. * * @deprecated Call {@link #getToken(Run)} */ @Deprecated public @CheckForNull DockerRegistryToken getToken(Item context) { if (credentialsId == null) { return null; } // as a build step, your access to credentials are constrained by what the build // can access, hence Jenkins.getAuthentication() List<DomainRequirement> requirements = Collections.emptyList(); try { requirements = Collections.<DomainRequirement>singletonList(new HostnameRequirement(getEffectiveUrl().getHost())); } catch (IOException e) { // shrug off this error and move on. We are matching with ID anyway. LOGGER.log(Level.FINE, "Unable to add domain requirement for endpoint URL", e); } // look for subtypes that know how to create a token, such as Google Container Registry return AuthenticationTokens.convert(DockerRegistryToken.class, firstOrNull(CredentialsProvider.lookupCredentials( IdCredentials.class, context, Jenkins.getAuthentication(), requirements), allOf(AuthenticationTokens.matcher(DockerRegistryToken.class), withId(credentialsId)))); } /** * Plugins that want to refer to a {@link IdCredentials} should do so via ID string, * and use this method to resolve it and convert to {@link DockerRegistryToken}. * * @param context * If you are a build step trying to access DockerHub in the context of a build/job, * specify that build. Otherwise null. If you are scoped to something else, you might * have to interact with {@link CredentialsProvider} directly. */ @CheckForNull DockerRegistryToken getToken(@CheckForNull Run context) { if (credentialsId == null) { return null; } List<DomainRequirement> requirements = Collections.emptyList(); try { requirements = Collections.<DomainRequirement>singletonList(new HostnameRequirement(getEffectiveUrl().getHost())); } catch (IOException e) { LOGGER.log(Level.FINE, "Unable to add domain requirement for endpoint URL", e); } return AuthenticationTokens.convert(DockerRegistryToken.class, CredentialsProvider.findCredentialById(credentialsId, IdCredentials.class, context, requirements)); } /** * @deprecated Call {@link #newKeyMaterialFactory(Run, FilePath, Launcher, EnvVars, TaskListener, String)} */ @Deprecated public KeyMaterialFactory newKeyMaterialFactory(@Nonnull AbstractBuild build) throws IOException, InterruptedException { final FilePath workspace = build.getWorkspace(); if (workspace == null) { throw new IllegalStateException("Requires workspace."); } return newKeyMaterialFactory(build.getParent(), workspace.getChannel()); } /** * @deprecated Call {@link #newKeyMaterialFactory(Run, FilePath, Launcher, EnvVars, TaskListener, String)} */ @Deprecated public KeyMaterialFactory newKeyMaterialFactory(Item context, @Nonnull VirtualChannel target) throws IOException, InterruptedException { return newKeyMaterialFactory(context, target, null, TaskListener.NULL); } /** * @deprecated Call {@link #newKeyMaterialFactory(Run, FilePath, Launcher, EnvVars, TaskListener, String)} */ @Deprecated public KeyMaterialFactory newKeyMaterialFactory(@CheckForNull Item context, @Nonnull VirtualChannel target, @CheckForNull Launcher launcher, @Nonnull TaskListener listener) throws IOException, InterruptedException { if (credentialsId == null) { return KeyMaterialFactory.NULL; // nothing needed to be done } DockerRegistryToken token = getToken(context); if (token == null) { throw new AbortException("Could not find credentials matching " + credentialsId); } return token.newKeyMaterialFactory(getEffectiveUrl(), target, launcher, listener); } /** * @deprecated Call {@link #newKeyMaterialFactory(Run, FilePath, Launcher, EnvVars, TaskListener, String)} */ @Deprecated public KeyMaterialFactory newKeyMaterialFactory(@CheckForNull Item context, @Nonnull FilePath workspace, @Nonnull Launcher launcher, @Nonnull TaskListener listener, @Nonnull String dockerExecutable) throws IOException, InterruptedException { return newKeyMaterialFactory(context, workspace, launcher, new EnvVars(), listener, dockerExecutable); } /** * @deprecated Call {@link #newKeyMaterialFactory(Run, FilePath, Launcher, EnvVars, TaskListener, String)} */ @Deprecated public KeyMaterialFactory newKeyMaterialFactory(@CheckForNull Item context, @Nonnull FilePath workspace, @Nonnull Launcher launcher, @Nonnull EnvVars env, @Nonnull TaskListener listener, @Nonnull String dockerExecutable) throws IOException, InterruptedException { if (credentialsId == null) { return KeyMaterialFactory.NULL; // nothing needed to be done } DockerRegistryToken token = getToken(context); if (token == null) { throw new AbortException("Could not find credentials matching " + credentialsId); } return token.newKeyMaterialFactory(getEffectiveUrl(), workspace, launcher, env, listener, dockerExecutable); } /** * Makes the credentials available locally and returns {@link KeyMaterialFactory} that gives you the parameters * needed to access it. * @param context The build trying to access DockerHub * @param workspace a workspace being used for operations ({@link WorkspaceList#tempDir} will be applied) * @param dockerExecutable as in {@link DockerTool#getExecutable}, with a 1.8+ client */ public KeyMaterialFactory newKeyMaterialFactory(@CheckForNull Run context, @Nonnull FilePath workspace, @Nonnull Launcher launcher, @Nonnull EnvVars env, @Nonnull TaskListener listener, @Nonnull String dockerExecutable) throws IOException, InterruptedException { if (credentialsId == null) { return KeyMaterialFactory.NULL; // nothing needed to be done } DockerRegistryToken token = getToken(context); if (token == null) { throw new AbortException("Could not find credentials matching " + credentialsId); } return token.newKeyMaterialFactory(getEffectiveUrl(), workspace, launcher, env, listener, dockerExecutable); } /** * Decorates the repository ID namespace/name (ie. "jenkinsci/workflow-demo") with registry prefix * (docker.acme.com:80/jenkinsci/workflow-demo). * * @param userAndRepo * the namespace/name part to append to the registry * @return the full registry:port/namespace/name string * @throws IOException */ public String imageName(@Nonnull String userAndRepo) throws IOException { if (userAndRepo == null) { throw new IllegalArgumentException("Image name cannot be null."); } if (url == null) { return userAndRepo; } URL effectiveUrl = getEffectiveUrl(); StringBuilder s = new StringBuilder(effectiveUrl.getHost()); if (effectiveUrl.getPort() > 0 ) { s.append(':').append(effectiveUrl.getPort()); } if (userAndRepo.startsWith(String.valueOf(s))) { return userAndRepo; } return s.append('/').append(userAndRepo).toString(); } @Override public String toString() { return "DockerRegistryEndpoint[" + url + ";credentialsId=" + credentialsId + "]"; } @Override public int hashCode() { int hash = 3; hash = 31 * hash + (this.url != null ? this.url.hashCode() : 0); hash = 31 * hash + (this.credentialsId != null ? this.credentialsId.hashCode() : 0); return hash; } @Override public boolean equals(Object obj) { if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } final DockerRegistryEndpoint other = (DockerRegistryEndpoint) obj; if ((this.url == null) ? (other.url != null) : !this.url.equals(other.url)) { return false; } if ((this.credentialsId == null) ? (other.credentialsId != null) : !this.credentialsId.equals(other.credentialsId)) { return false; } return true; } @Extension public static class DescriptorImpl extends Descriptor<DockerRegistryEndpoint> { @Override public String getDisplayName() { return "Docker Hub"; } public ListBoxModel doFillCredentialsIdItems(@AncestorInPath Item item) { if (item == null && !Jenkins.getActiveInstance().hasPermission(Jenkins.ADMINISTER) || item != null && !item.hasPermission(Item.EXTENDED_READ)) { return new StandardListBoxModel(); } // TODO may also need to specify a specific authentication and domain requirements return new StandardListBoxModel() .withEmptySelection() .withMatching(AuthenticationTokens.matcher(DockerRegistryToken.class), CredentialsProvider.lookupCredentials( StandardCredentials.class, item, null, Collections.<DomainRequirement>emptyList() ) ); } } }