diff --git a/.classpath b/.classpath deleted file mode 100755 index 9c7e003..0000000 --- a/.classpath +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..6b42b28 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,3 @@ +# .git-blame-ignore-revs +# Format repository with Spotless (#61) +96e7537e66f73f276d4d001fbc56006222a3a7b0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0cf0a89 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @jenkinsci/notification-plugin-developers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..980b0ae --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +--- +version: 2 +updates: + # Maintain dependencies for your plugin + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "monthly" + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..d55671f --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,3 @@ +# https://github.com/jenkinsci/.github/blob/master/.github/release-drafter.adoc +_extends: .github +tag-template: notification-$NEXT_MINOR_VERSION diff --git a/.github/workflows/jenkins-security-scan.yml b/.github/workflows/jenkins-security-scan.yml new file mode 100644 index 0000000..ecda979 --- /dev/null +++ b/.github/workflows/jenkins-security-scan.yml @@ -0,0 +1,24 @@ +# Jenkins Security Scan +# For more information, see: https://www.jenkins.io/doc/developer/security/scan/ +--- +name: Jenkins Security Scan + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + security-events: write + contents: read + actions: read + +jobs: + security-scan: + uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2 + with: + java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. + # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..dca81a7 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,16 @@ +# Automates creation of Release Drafts using Release Drafter +# More Info: https://github.com/jenkinsci/.github/blob/master/.github/release-drafter.adoc + +on: + push: + branches: + - master + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c35a4ed..c502c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ /target /work +.settings/ +.project +.classpath +.idea +*.iml +go.sh diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 0000000..9440b18 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,7 @@ + + + io.jenkins.tools.incrementals + git-changelist-maven-extension + 1.13 + + diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 0000000..2a0299c --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,2 @@ +-Pconsume-incrementals +-Pmight-produce-incrementals diff --git a/.project b/.project deleted file mode 100755 index b581bda..0000000 --- a/.project +++ /dev/null @@ -1,24 +0,0 @@ - - - hudson-notification-plugin - - - - - - org.eclipse.jdt.core.javabuilder - - - - - org.maven.ide.eclipse.maven2Builder - - - - - - org.eclipse.jdt.groovy.core.groovyNature - org.eclipse.jdt.core.javanature - org.maven.ide.eclipse.maven2Nature - - diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs deleted file mode 100755 index d094481..0000000 --- a/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,6 +0,0 @@ -#Tue Oct 05 17:22:06 IST 2010 -eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.5 -org.eclipse.jdt.core.compiler.compliance=1.5 -org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning -org.eclipse.jdt.core.compiler.source=1.5 diff --git a/.settings/org.maven.ide.eclipse.prefs b/.settings/org.maven.ide.eclipse.prefs deleted file mode 100755 index 86a59f7..0000000 --- a/.settings/org.maven.ide.eclipse.prefs +++ /dev/null @@ -1,9 +0,0 @@ -#Tue Oct 05 17:21:50 IST 2010 -activeProfiles= -eclipse.preferences.version=1 -fullBuildGoals=process-test-resources -includeModules=false -resolveWorkspaceProjects=true -resourceFilterGoals=process-resources resources\:testResources -skipCompilerPlugin=true -version=1 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..cf3359f --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,7 @@ +#!/usr/bin/env groovy + +/* `buildPlugin` step provided by: https://github.com/jenkins-infra/pipeline-library */ +buildPlugin(useContainerAgent: true, configurations: [ + [platform: 'linux', jdk: 21], + [platform: 'windows', jdk: 17] +]) \ No newline at end of file diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..ca33bd4 --- /dev/null +++ b/circle.yml @@ -0,0 +1,19 @@ +# ----------------------------------------- +# https://circleci.com/docs/configuration +# https://circleci.com/docs/environment +# ----------------------------------------- + +machine: + java: + version: oraclejdk7 +dependencies: + override: + - mvn -s settings.xml clean package + cache_directories: + - ~/.m2 +test: + override: + - id +general: + artifacts: + - target/notification.hpi diff --git a/pom.xml b/pom.xml old mode 100755 new mode 100644 index 2d0f2ec..9017e84 --- a/pom.xml +++ b/pom.xml @@ -1,84 +1,175 @@ - - 4.0.0 - - org.jvnet.hudson.plugins - plugin - 1.391 - ../pom.xml - + + + 4.0.0 + + org.jenkins-ci.plugins + plugin + 5.26 + + - com.tikalk.hudson.plugins - notification - 1.0-SNAPSHOT - hpi - Hudson Notification plugin - Sends notification about jobs phases - http://wiki.hudson-ci.org/display/HUDSON/Notification+Plugin + com.tikalk.hudson.plugins + notification + ${revision}${changelist} + hpi + Jenkins Notification plugin + Sends notifications about jobs phases and status + https://github.com/jenkinsci/${project.artifactId}-plugin + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + - - - java.net-m2-repository - http://maven.hudson-labs.org/content/repositories/releases/ - - + + + markb + Mark Berner + markb@tikalk.com + Tikal Knowledge + http://tikalk.com + + + hagzag + Haggai Philip Zagury + hagzag@tikalk.com + Tikal Knowledge + http://tikalk.com + + + evgenyg + Evgeny Goldin + evgenyg@gmail.com + AKQA + http://akqa.com + + + cohencil + Chen Cohen + chenc@tikalk.com + http://tikalk.com + + - - - m.g.o-public - http://maven.glassfish.org/content/groups/public/ - - + + scm:git:https://github.com/${gitHubRepo}.git + scm:git:git@github.com:${gitHubRepo}.git + ${scmTag} + https://github.com/${gitHubRepo} + - - - m.g.o-public - http://maven.glassfish.org/content/groups/public/ - - + + 1.19 + -SNAPSHOT + + 2.479 + ${jenkins.baseline}.3 + jenkinsci/${project.artifactId}-plugin + false + - - - com.google.code.gson - gson - 1.4 - - + + + + io.jenkins.tools.bom + bom-${jenkins.baseline}.x + 5054.v620b_5d2b_d5e6 + pom + import + + + - - - markb - Mark Berner - markb@tikalk.com - Tikal Knowledge - http://tikalk.com - - + + + io.jenkins.plugins + gson-api + + + org.jenkins-ci.plugins + credentials + + + org.jenkins-ci.plugins + git + + + org.jenkins-ci.plugins + junit + true + + + org.jenkins-ci.plugins + plain-credentials + + + org.jenkins-ci.plugins + s3 + true + + + org.apache.commons + commons-lang3 + + + org.apache.httpcomponents + httpclient + + + org.apache.httpcomponents + httpcore + + + org.jenkins-ci + symbol-annotation + + + + + org.jenkins-ci.plugins + token-macro + true + + + net.sf.ezmorph + ezmorph + 1.0.6 + test + + + org.mockito + mockito-core + test + + - - - - org.apache.maven.plugins - maven-compiler-plugin - 2.0.2 - - 1.5 - 1.5 - - - - - - org.jvnet.wagon-svn - wagon-svn - 1.9 - - - - - scm:git:git://github.com/hudson/notification-plugin.git - scm:git:git@github.com:hudson/notification-plugin.git - http://github.com/hudson/notification-plugin - + + + + true + + + false + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + + + true + + + false + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + diff --git a/settings.xml b/settings.xml new file mode 100644 index 0000000..542aa34 --- /dev/null +++ b/settings.xml @@ -0,0 +1,40 @@ + + + + + * + remote-repos + http://jcenter.bintray.com/ + remote-repos + + + + + jcenter + + + + false + + central + libs-releases + http://jcenter.bintray.com + + + + + + false + + central + plugins-releases + http://jcenter.bintray.com + + + + + + jcenter + + diff --git a/src/main/java/com/tikal/hudson/plugins/notification/Endpoint.java b/src/main/java/com/tikal/hudson/plugins/notification/Endpoint.java new file mode 100644 index 0000000..03f6fd0 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/Endpoint.java @@ -0,0 +1,215 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +public class Endpoint { + + public static final Integer DEFAULT_TIMEOUT = 30000; + + public static final Integer DEFAULT_RETRIES = 0; + + public static final String DEFAULT_BRANCH = ".*"; + + private Protocol protocol = Protocol.HTTP; + + /** + * json as default + */ + private Format format = Format.JSON; + + private UrlInfo urlInfo; + + // For backwards compatbility + @Deprecated + private transient String url; + + private String event = "all"; + + private Integer timeout = DEFAULT_TIMEOUT; + + private Integer loglines = 0; + + private String buildNotes; + + private Integer retries = DEFAULT_RETRIES; + + private String branch = ".*"; + + /** + * Adds a new endpoint for notifications + * @param protocol - Protocol to use + * @param url Public URL + * @param event - Event to fire on. + * @param format - Format to send message in. + * @param timeout Timeout for sending data + * @param loglines - Number of lines to send + */ + @Deprecated + public Endpoint(Protocol protocol, String url, String event, Format format, Integer timeout, Integer loglines) { + setProtocol(protocol); + setUrlInfo(new UrlInfo(UrlType.PUBLIC, url)); + setEvent(event); + setFormat(format); + setTimeout(timeout); + setLoglines(loglines); + } + + /** + * Adds a new endpoint for notifications + * @param urlInfo Information about the target URL for the event. + */ + @DataBoundConstructor + public Endpoint(UrlInfo urlInfo) { + setUrlInfo(urlInfo); + } + + public UrlInfo getUrlInfo() { + if (this.urlInfo == null) { + this.urlInfo = new UrlInfo(UrlType.PUBLIC, ""); + } + return this.urlInfo; + } + + public void setUrlInfo(UrlInfo urlInfo) { + this.urlInfo = urlInfo; + } + + public int getTimeout() { + return timeout == null ? DEFAULT_TIMEOUT : timeout; + } + + /** + * Sets a timeout for the notification. + * @param timeout - Timeout in ms. Default is 30s (30000) + */ + @DataBoundSetter + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public Protocol getProtocol() { + return protocol; + } + + /** + * Sets the protocol for the + * @param protocol Protocol to use. Valid values are: UDP, TCP, HTTP. Default is HTTP. + * HTTP event target urls must start with 'http' + */ + @DataBoundSetter + public void setProtocol(Protocol protocol) { + this.protocol = protocol; + } + + public String getEvent() { + return event; + } + + /** + * Sets the specific event to contact the endpoint for. + * @param event 'STARTED' - Fire on job started. 'COMPLETED' - Fire on job completed. 'FINALIZED' - Fire on job finalized. + */ + @DataBoundSetter + public void setEvent(String event) { + this.event = event; + } + + public Format getFormat() { + if (this.format == null) { + this.format = Format.JSON; + } + return format; + } + + /** + * Format of the message sent to the endpoint should + * @param format 'XML' or 'JSON' + */ + @DataBoundSetter + public void setFormat(Format format) { + this.format = format; + } + + public Integer getLoglines() { + return this.loglines; + } + + /** + * Set the number of log lines to send with the message. + * @param loglines - Default 0, -1 for unlimited. + */ + @DataBoundSetter + public void setLoglines(Integer loglines) { + this.loglines = loglines; + } + + public String getBuildNotes() { + return buildNotes; + } + + /** + * Set any additional build information to be sent in message. + * @param buildNotes - the additional data + */ + @DataBoundSetter + public void setBuildNotes(String buildNotes) { + this.buildNotes = buildNotes; + } + + public boolean isJson() { + return getFormat() == Format.JSON; + } + + public Integer getRetries() { + return this.retries == null ? DEFAULT_RETRIES : this.retries; + } + + /** + * Number of retries before giving up on contacting an endpoint + * @param retries - Number of retries. Default 0. + */ + @DataBoundSetter + public void setRetries(Integer retries) { + this.retries = retries; + } + + protected Object readResolve() { + if (url != null) { + // Upgrade, this is a public URL + this.urlInfo = new UrlInfo(UrlType.PUBLIC, url); + } + return this; + } + + public String getBranch() { + return branch; + } + + /** + * Sets branch filter + * @param branch - regex + */ + @DataBoundSetter + public void setBranch(final String branch) { + this.branch = branch; + } + + @Override + public String toString() { + return protocol + ":" + urlInfo.getUrlOrId(); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/Format.java b/src/main/java/com/tikal/hudson/plugins/notification/Format.java new file mode 100644 index 0000000..01e057e --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/Format.java @@ -0,0 +1,46 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.thoughtworks.xstream.XStream; +import com.tikal.hudson.plugins.notification.model.JobState; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public enum Format { + XML { + private final transient XStream xstream = new XStream(); + + @Override + protected byte[] serialize(JobState jobState) throws IOException { + xstream.processAnnotations(JobState.class); + return xstream.toXML(jobState).getBytes(StandardCharsets.UTF_8); + } + }, + JSON { + private final transient Gson gson = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + + @Override + protected byte[] serialize(JobState jobState) throws IOException { + return gson.toJson(jobState).getBytes(StandardCharsets.UTF_8); + } + }; + + protected abstract byte[] serialize(JobState jobState) throws IOException; +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/HostnamePort.java b/src/main/java/com/tikal/hudson/plugins/notification/HostnamePort.java new file mode 100644 index 0000000..0be8426 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/HostnamePort.java @@ -0,0 +1,45 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import java.util.Scanner; +import java.util.regex.MatchResult; + +public class HostnamePort { + + public final String hostname; + + public final int port; + + public HostnamePort(String hostname, int port) { + this.hostname = hostname; + this.port = port; + } + + public static HostnamePort parseUrl(String url) { + try { + Scanner scanner = new Scanner(url); + scanner.findInLine("(.+):(\\d{1,5})"); + MatchResult result = scanner.match(); + if (result.groupCount() != 2) { + return null; + } + String hostname = result.group(1); + int port = Integer.parseInt(result.group(2)); + return new HostnamePort(hostname, port); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationProperty.java b/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationProperty.java index d105221..58bcf79 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationProperty.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationProperty.java @@ -1,131 +1,40 @@ -package com.tikal.hudson.plugins.notification; - -import hudson.Extension; -import hudson.model.JobProperty; -import hudson.model.JobPropertyDescriptor; -import hudson.model.AbstractProject; -import hudson.model.Job; - -import java.util.ArrayList; -import java.util.List; - -import net.sf.json.JSON; -import net.sf.json.JSONArray; -import net.sf.json.JSONObject; - -import org.kohsuke.stapler.DataBoundConstructor; -import org.kohsuke.stapler.StaplerRequest; - -import com.thoughtworks.xstream.annotations.XStreamAlias; - -public class HudsonNotificationProperty extends JobProperty> { - - private List targets = new ArrayList(); - - @DataBoundConstructor - public HudsonNotificationProperty() { - super(); - } - - public List getTargets() { - return targets; - } - - public void setTargets(List targets) { - this.targets = targets; - } - - @XStreamAlias(value = "target") - public static class Target { - - private Protocol protocol; - - private String url; - - @DataBoundConstructor - public Target(Protocol protocol, String url) { - this.protocol = protocol; - this.url = url; - } - - public Protocol getProtocol() { - return protocol; - } - - public void setProtocol(Protocol protocol) { - this.protocol = protocol; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - } - - @Extension - public static final class DescriptorImpl extends JobPropertyDescriptor { - - public DescriptorImpl() { - super(HudsonNotificationProperty.class); - load(); - } - - private List targets = new ArrayList(); - - public boolean isEnabled() { - return !targets.isEmpty(); - } - - public List getTargets() { - return targets; - } - - public void setTargets(List targets) { - this.targets = targets; - } - - @Override - public boolean isApplicable(@SuppressWarnings("rawtypes") Class jobType) { - return true; - } - - public String getDisplayName() { - return "Hudson Job Notification"; - } - - @Override - public HudsonNotificationProperty newInstance(StaplerRequest req, JSONObject formData) throws FormException { - System.out.println(formData.toString(0)); - - HudsonNotificationProperty notificationProperty = new HudsonNotificationProperty(); - if (formData != null && !formData.isNullObject()) { - JSON targetsData = (JSON) formData.get("targets"); - if (targetsData != null && !targetsData.isEmpty()) { - if (targetsData.isArray()) { - JSONArray targetsArrayData = (JSONArray) targetsData; - notificationProperty.setTargets(req.bindJSONToList(Target.class, targetsArrayData)); - } else { - JSONObject targetsObjectData = (JSONObject) targetsData; - notificationProperty.getTargets().add(req.bindJSON(Target.class, targetsObjectData)); - } - } - } - return notificationProperty; - } - - @Override - public boolean configure(StaplerRequest req, JSONObject formData) { - save(); - return true; - } - - } - - public DescriptorImpl getDescriptor() { - return (DescriptorImpl) super.getDescriptor(); - } -} \ No newline at end of file +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import hudson.model.Job; +import hudson.model.JobProperty; +import java.util.ArrayList; +import java.util.List; +import org.kohsuke.stapler.DataBoundConstructor; + +public class HudsonNotificationProperty extends JobProperty> { + + public final List endpoints; + + @DataBoundConstructor + public HudsonNotificationProperty(List endpoints) { + this.endpoints = new ArrayList<>(endpoints); + } + + public List getEndpoints() { + return endpoints; + } + + @SuppressWarnings("CastToConcreteClass") + @Override + public HudsonNotificationPropertyDescriptor getDescriptor() { + return (HudsonNotificationPropertyDescriptor) super.getDescriptor(); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationPropertyDescriptor.java b/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationPropertyDescriptor.java new file mode 100644 index 0000000..cc14de5 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/HudsonNotificationPropertyDescriptor.java @@ -0,0 +1,206 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.common.AbstractIdCredentialsListBoxModel; +import com.cloudbees.plugins.credentials.common.StandardCredentials; +import com.cloudbees.plugins.credentials.common.StandardListBoxModel; +import hudson.Extension; +import hudson.RelativePath; +import hudson.model.Item; +import hudson.model.ItemGroup; +import hudson.model.Job; +import hudson.model.JobPropertyDescriptor; +import hudson.security.ACL; +import hudson.security.Permission; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import javax.annotation.Nonnull; +import jenkins.model.Jenkins; +import net.sf.json.JSON; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.apache.commons.lang.StringUtils; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; + +@Extension +public final class HudsonNotificationPropertyDescriptor extends JobPropertyDescriptor { + + public HudsonNotificationPropertyDescriptor() { + super(HudsonNotificationProperty.class); + load(); + } + + private List endpoints = new ArrayList<>(); + + public boolean isEnabled() { + return !endpoints.isEmpty(); + } + + public List getTargets() { + return endpoints; + } + + public void setEndpoints(List endpoints) { + this.endpoints = new ArrayList<>(endpoints); + } + + @Override + public boolean isApplicable(Class jobType) { + return true; + } + + @Nonnull + @Override + public String getDisplayName() { + return "Hudson Job Notification"; + } + + public String getDefaultBranch() { + return Endpoint.DEFAULT_BRANCH; + } + + public int getDefaultTimeout() { + return Endpoint.DEFAULT_TIMEOUT; + } + + public int getDefaultRetries() { + return Endpoint.DEFAULT_RETRIES; + } + + @Override + public HudsonNotificationProperty newInstance(StaplerRequest req, JSONObject formData) throws FormException { + List endpoints = new ArrayList<>(); + if (formData != null && !formData.isNullObject()) { + JSON endpointsData = (JSON) formData.get("endpoints"); + if (endpointsData != null && !endpointsData.isEmpty()) { + if (endpointsData.isArray()) { + JSONArray endpointsArrayData = (JSONArray) endpointsData; + for (int i = 0; i < endpointsArrayData.size(); i++) { + JSONObject endpointsObject = endpointsArrayData.getJSONObject(i); + endpoints.add(convertJson(endpointsObject)); + } + } else { + endpoints.add(convertJson((JSONObject) endpointsData)); + } + } + } + + return new HudsonNotificationProperty(endpoints); + } + + private Endpoint convertJson(JSONObject endpointObjectData) throws FormException { + // Transform the data to get the public/secret URL data + JSONObject urlInfoData = endpointObjectData.getJSONObject("urlInfo"); + UrlInfo urlInfo; + if (urlInfoData.containsKey("publicUrl")) { + urlInfo = new UrlInfo(UrlType.PUBLIC, urlInfoData.getString("publicUrl")); + } else if (urlInfoData.containsKey("secretUrl")) { + urlInfo = new UrlInfo(UrlType.SECRET, urlInfoData.getString("secretUrl")); + } else { + throw new FormException("Expected either a public url or secret url id", "urlInfo"); + } + + Endpoint endpoint = new Endpoint(urlInfo); + endpoint.setEvent(endpointObjectData.getString("event")); + endpoint.setFormat(Format.valueOf(endpointObjectData.getString("format"))); + endpoint.setProtocol(Protocol.valueOf(endpointObjectData.getString("protocol"))); + endpoint.setTimeout(endpointObjectData.getInt("timeout")); + endpoint.setRetries(endpointObjectData.getInt("retries")); + endpoint.setLoglines(endpointObjectData.getInt("loglines")); + endpoint.setBuildNotes(endpointObjectData.getString("notes")); + endpoint.setBranch(endpointObjectData.getString("branch")); + + return endpoint; + } + + public FormValidation doCheckPublicUrl( + @QueryParameter(value = "publicUrl", fixEmpty = true) String publicUrl, + @RelativePath("..") @QueryParameter(value = "protocol") String protocolParameter) { + Protocol protocol = Protocol.valueOf(protocolParameter); + return checkUrl(publicUrl, UrlType.PUBLIC, protocol); + } + + public FormValidation doCheckSecretUrl( + @QueryParameter(value = "secretUrl", fixEmpty = true) String publicUrl, + @RelativePath("..") @QueryParameter(value = "protocol") String protocolParameter) { + Protocol protocol = Protocol.valueOf(protocolParameter); + return checkUrl(publicUrl, UrlType.SECRET, protocol); + } + + private FormValidation checkUrl(String urlOrId, UrlType urlType, Protocol protocol) { + String actualUrl = urlOrId; + if (urlType == UrlType.SECRET && !StringUtils.isEmpty(actualUrl)) { + actualUrl = Jenkins.get().getItems(ItemGroup.class).stream() + .map(ig -> Utils.getSecretUrl(urlOrId, ig)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + // Get the credentials + if (actualUrl == null) { + return FormValidation.error("Could not find secret text credentials with id " + urlOrId); + } + } + + try { + protocol.validateUrl(actualUrl); + return FormValidation.ok(); + } catch (Exception e) { + String message = e.getMessage(); + if (urlType == UrlType.SECRET && !StringUtils.isEmpty(actualUrl)) { + message = message.replace(actualUrl, "******"); + } + return FormValidation.error(message); + } + } + + public ListBoxModel doFillSecretUrlItems(@AncestorInPath Item owner, @QueryParameter String secretUrl) { + if (owner == null || !owner.hasPermission(Permission.CONFIGURE)) { + return new StandardListBoxModel(); + } + + // when configuring the job, you only want those credentials that are available to ACL.SYSTEM selectable + // as we cannot select from a user's credentials unless they are the only user submitting the build + // (which we cannot assume) thus ACL.SYSTEM is correct here. + AbstractIdCredentialsListBoxModel model = new StandardListBoxModel() + .includeEmptyValue() + .withAll(CredentialsProvider.lookupCredentials( + StringCredentials.class, owner, ACL.SYSTEM, Collections.emptyList())); + if (!StringUtils.isEmpty(secretUrl)) { + // Select current value, add if missing + for (ListBoxModel.Option option : model) { + if (option.value.equals(secretUrl)) { + option.selected = true; + break; + } + } + } + + return model; + } + + @Override + public boolean configure(StaplerRequest req, JSONObject formData) { + save(); + return true; + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/JobListener.java b/src/main/java/com/tikal/hudson/plugins/notification/JobListener.java index 17626f4..a2387f9 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/JobListener.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/JobListener.java @@ -1,40 +1,47 @@ -package com.tikal.hudson.plugins.notification; - -import hudson.Extension; -import hudson.model.Result; -import hudson.model.TaskListener; -import hudson.model.Run; -import hudson.model.listeners.RunListener; - -@Extension -@SuppressWarnings("rawtypes") -public class JobListener extends RunListener { - - public JobListener() { - super(Run.class); - } - - @Override - public void onStarted(Run r, TaskListener listener) { - Phase.STARTED.handlePhase(r, getStatus(r)); - } - - @Override - public void onCompleted(Run r, TaskListener listener) { - Phase.COMPLETED.handlePhase(r, getStatus(r)); - } - - @Override - public void onFinalized(Run r) { - Phase.FINISHED.handlePhase(r, getStatus(r)); - } - - private String getStatus(Run r) { - Result result = r.getResult(); - String status = null; - if (result != null) { - status = result.toString(); - } - return status; - } -} \ No newline at end of file +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import hudson.Extension; +import hudson.model.Executor; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.model.listeners.RunListener; + +@Extension +@SuppressWarnings("rawtypes") +public class JobListener extends RunListener { + + public JobListener() { + super(Run.class); + } + + @Override + public void onStarted(Run r, TaskListener listener) { + Executor e = r.getExecutor(); + Phase.QUEUED.handle( + r, TaskListener.NULL, e != null ? System.currentTimeMillis() - e.getTimeSpentInQueue() : 0L); + Phase.STARTED.handle(r, listener, r.getTimeInMillis()); + } + + @Override + public void onCompleted(Run r, TaskListener listener) { + Phase.COMPLETED.handle(r, listener, r.getTimeInMillis() + r.getDuration()); + } + + @Override + public void onFinalized(Run r) { + Phase.FINALIZED.handle(r, TaskListener.NULL, System.currentTimeMillis()); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/NotifyStep.java b/src/main/java/com/tikal/hudson/plugins/notification/NotifyStep.java new file mode 100644 index 0000000..460e1c7 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/NotifyStep.java @@ -0,0 +1,132 @@ +package com.tikal.hudson.plugins.notification; + +import hudson.Extension; +import hudson.FilePath; +import hudson.Util; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.Serializable; +import java.util.Objects; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.steps.Step; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +public class NotifyStep extends Step implements Serializable { + private static final long serialVersionUID = -2818860651754465006L; + + @CheckForNull + private String notes; + + @CheckForNull + public String getNotes() { + return notes; + } + + @DataBoundSetter + public void setNotes(@CheckForNull String notes) { + this.notes = Util.fixEmpty(notes); + } + + @CheckForNull + private String phase = Phase.STARTED.name(); + + @CheckForNull + public String getPhase() { + return phase; + } + + @DataBoundSetter + public void setPhase(@CheckForNull String phase) { + this.phase = Util.fixEmpty(phase); + } + + @CheckForNull + private String loglines = "0"; + + @CheckForNull + public String getLoglines() { + return loglines; + } + + @DataBoundSetter + public void setLoglines(@CheckForNull String loglines) { + this.loglines = Util.fixEmpty(loglines); + } + + /** + * Creates a new instance of {@link NotifyStep}. + */ + @DataBoundConstructor + public NotifyStep() { + super(); + + // empty constructor required for Stapler + } + + @Override + public StepExecution start(final StepContext context) { + return new Execution(context, this); + } + + /** + * Actually performs the execution of the associated step. + */ + static class Execution extends StepExecution { + private static final long serialVersionUID = -2840020502160375407L; + + private final NotifyStep notifyStep; + + Execution(@Nonnull final StepContext context, final NotifyStep step) { + super(context); + notifyStep = step; + } + + @Override + public boolean start() throws Exception { + String logLines = notifyStep.getLoglines(); + + Phase.NONE.handle( + Objects.requireNonNull(getContext().get(Run.class)), + getContext().get(TaskListener.class), + System.currentTimeMillis(), + true, + notifyStep.getNotes(), + Integer.parseInt(logLines != null ? logLines : "0"), + Phase.valueOf(notifyStep.getPhase())); + + getContext().onSuccess(null); + + return true; + } + } + + /** + * Descriptor for this step: defines the context and the UI labels. + */ + @Extension + @SuppressWarnings("unused") // most methods are used by the corresponding jelly view + public static class Descriptor extends StepDescriptor { + @Override + public String getFunctionName() { + return "notifyEndpoints"; + } + + @Nonnull + @Override + public String getDisplayName() { + return Messages.Notify_DisplayName(); + } + + @Override + public Set> getRequiredContext() { + return Set.of(FilePath.class, FlowNode.class, Run.class, TaskListener.class); + } + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/Phase.java b/src/main/java/com/tikal/hudson/plugins/notification/Phase.java index d4b7f8c..7e4dad2 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/Phase.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/Phase.java @@ -1,22 +1,431 @@ -package com.tikal.hudson.plugins.notification; - -import hudson.model.Run; - -import java.util.List; - -import com.tikal.hudson.plugins.notification.HudsonNotificationProperty.Target; - -public enum Phase { - STARTED, COMPLETED, FINISHED; - - @SuppressWarnings({ "unchecked", "rawtypes" }) - public void handlePhase(Run run, String status) { - HudsonNotificationProperty property = (HudsonNotificationProperty) run.getParent().getProperty(HudsonNotificationProperty.class); - if (property != null) { - List targets = property.getTargets(); - for (Target target : targets) { - target.getProtocol().sendNotification(target.getUrl(), run.getParent(), run.getNumber(), this, status); - } - } - } -} \ No newline at end of file +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import com.tikal.hudson.plugins.notification.model.BuildState; +import com.tikal.hudson.plugins.notification.model.JobState; +import com.tikal.hudson.plugins.notification.model.ScmState; +import com.tikal.hudson.plugins.notification.model.TestState; +import hudson.EnvVars; +import hudson.FilePath; +import hudson.model.AbstractBuild; +import hudson.model.Executor; +import hudson.model.Job; +import hudson.model.ParameterValue; +import hudson.model.ParametersAction; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.model.User; +import hudson.plugins.git.util.Build; +import hudson.plugins.git.util.BuildData; +import hudson.scm.ChangeLogSet; +import hudson.tasks.test.AbstractTestResultAction; +import hudson.tasks.test.TestResult; +import java.io.IOException; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import jenkins.model.Jenkins; +import org.apache.commons.lang.StringUtils; +import org.jenkinsci.plugins.tokenmacro.TokenMacro; + +@SuppressWarnings({"unchecked", "rawtypes"}) +public enum Phase { + QUEUED, + STARTED, + COMPLETED, + FINALIZED, + NONE; + + private Result findLastBuildThatFinished(Run run) { + Run previousRun = run.getPreviousCompletedBuild(); + while (previousRun != null) { + Result previousResults = previousRun.getResult(); + if (previousResults == null) { + throw new IllegalStateException("Previous result can't be null here"); + } + if (previousResults.equals(Result.SUCCESS) + || previousResults.equals(Result.FAILURE) + || previousResults.equals(Result.UNSTABLE)) { + return previousResults; + } + previousRun = previousRun.getPreviousCompletedBuild(); + } + return null; + } + + public void handle(Run run, TaskListener listener, long timestamp) { + handle(run, listener, timestamp, false, null, 0, this); + } + + /** + * Determines if input value for URL is valid. Valid values are not blank, and variables resolve/expand into valid URLs. + * Unresolved variables remain as strings prefixed with $, so those are not valid. + * @param urlInputValue Value user provided in input box for URL + * @param expandedUrl Value the urlInputValue 'expands' into. + * @param logger PrintStream used for logging. + * @return True if URL is populated with a non-blank value, or a variable that expands into a URL. + */ + private boolean isURLValid(String urlInputValue, String expandedUrl, PrintStream logger) { + boolean isValid = false; + // If Jenkins variable was used for URL, and it was unresolvable, log warning and return. + if (expandedUrl.contains("$")) { + logger.printf("Ignoring sending notification due to unresolved variable: %s%n", urlInputValue); + } else if (StringUtils.isBlank(expandedUrl)) { + logger.println("URL is not set, ignoring call to send notification."); + } else { + isValid = true; + } + return isValid; + } + + /** + * Determines if the endpoint specified should be notified at the current job phase. + */ + private boolean isRun(Endpoint endpoint, Result result, Result previousRunResult) { + String event = endpoint.getEvent(); + + if (event == null) { + return true; + } + + switch (event) { + case "all": + return true; + case "failed": + if (result == null) { + return false; + } + return this.equals(FINALIZED) && result.equals(Result.FAILURE); + case "failedAndFirstSuccess": + if (result == null || !this.equals(FINALIZED)) { + return false; + } + if (result.equals(Result.FAILURE)) { + return true; + } + return previousRunResult != null + && result.equals(Result.SUCCESS) + && previousRunResult.equals(Result.FAILURE); + case "manual": + return false; + default: + return event.equals(this.toString().toLowerCase()); + } + } + + private JobState buildJobState( + Job job, Run run, TaskListener listener, long timestamp, Endpoint target, Phase phase) + throws IOException, InterruptedException { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + assert jenkins != null; + + String rootUrl = jenkins.getRootUrl(); + JobState jobState = new JobState(); + BuildState buildState = new BuildState(); + ScmState scmState = new ScmState(); + Result result = run.getResult(); + ParametersAction paramsAction = run.getAction(ParametersAction.class); + EnvVars environment = run.getEnvironment(listener); + StringBuilder log = this.getLog(run, target); + + jobState.setName(job.getName()); + jobState.setDisplayName(job.getDisplayName()); + jobState.setUrl(job.getUrl()); + jobState.setBuild(buildState); + + buildState.setNumber(run.number); + buildState.setQueueId(run.getQueueId()); + buildState.setUrl(run.getUrl()); + buildState.setPhase(phase); + buildState.setTimestamp(timestamp); + buildState.setDuration(run.getDuration()); + buildState.setScm(scmState); + buildState.setLog(log); + buildState.setNotes(resolveMacros(run, listener, target.getBuildNotes())); + buildState.setTestSummary(getTestResults(run)); + + if (result != null) { + buildState.setStatus(result.toString()); + } + + if (rootUrl != null) { + buildState.setFullUrl(rootUrl + run.getUrl()); + } + + buildState.updateArtifacts(job, run); + + // TODO: Make this optional to reduce chat overload. + if (paramsAction != null) { + EnvVars env = new EnvVars(); + for (ParameterValue value : paramsAction.getParameters()) { + if (!value.isSensitive()) { + value.buildEnvironment(run, env); + } + } + buildState.setParameters(env); + } + + BuildData build = job.getAction(BuildData.class); + + if (build != null) { + if (!build.remoteUrls.isEmpty()) { + String url = build.remoteUrls.iterator().next(); + if (url != null) { + scmState.setUrl(url); + } + } + for (Map.Entry entry : build.buildsByBranchName.entrySet()) { + if (entry.getValue().hudsonBuildNumber == run.number) { + scmState.setBranch(entry.getKey()); + scmState.setCommit(entry.getValue().revision.getSha1String()); + } + } + } + + if (environment.get("GIT_URL") != null) { + scmState.setUrl(environment.get("GIT_URL")); + } + + if (environment.get("GIT_BRANCH") != null) { + scmState.setBranch(environment.get("GIT_BRANCH")); + } + + if (environment.get("GIT_COMMIT") != null) { + scmState.setCommit(environment.get("GIT_COMMIT")); + } + + scmState.setChanges(getChangedFiles(run)); + scmState.setCulprits(getCulprits(run)); + + return jobState; + } + + private String resolveMacros(Run build, TaskListener listener, String text) { + + String result = text; + try { + Executor executor = build.getExecutor(); + if (executor != null) { + FilePath workspace = executor.getCurrentWorkspace(); + if (workspace != null) { + result = TokenMacro.expandAll(build, workspace, listener, text); + } + } + } catch (Throwable e) { + // Catching Throwable here because the TokenMacro plugin is optional + // so will throw a ClassDefNotFoundError if the plugin is not installed or disabled. + e.printStackTrace(listener.error(String.format("Failed to evaluate macro '%s'", text))); + } + + return result; + } + + private TestState getTestResults(Run build) { + TestState resultSummary = null; + + AbstractTestResultAction testAction = build.getAction(AbstractTestResultAction.class); + if (testAction != null) { + int total = testAction.getTotalCount(); + int failCount = testAction.getFailCount(); + int skipCount = testAction.getSkipCount(); + + resultSummary = new TestState(); + resultSummary.setTotal(total); + resultSummary.setFailed(failCount); + resultSummary.setSkipped(skipCount); + resultSummary.setPassed(total - failCount - skipCount); + resultSummary.setFailedTests(getFailedTestNames(testAction)); + } + + return resultSummary; + } + + private List getFailedTestNames(AbstractTestResultAction testResultAction) { + List failedTests = new ArrayList<>(); + + List results = testResultAction.getFailedTests(); + + for (TestResult t : results) { + failedTests.add(t.getFullName()); + } + + return failedTests; + } + + private List getChangedFiles(Run run) { + List affectedPaths = new ArrayList<>(); + + if (run instanceof AbstractBuild) { + AbstractBuild build = (AbstractBuild) run; + + Object[] items = build.getChangeSet().getItems(); + + if (items != null && items.length > 0) { + for (Object o : items) { + if (o instanceof ChangeLogSet.Entry) { + affectedPaths.addAll(((ChangeLogSet.Entry) o).getAffectedPaths()); + } + } + } + } + + return affectedPaths; + } + + private List getCulprits(Run run) { + List culprits = new ArrayList<>(); + + if (run instanceof AbstractBuild) { + AbstractBuild build = (AbstractBuild) run; + Set buildCulprits = build.getCulprits(); + for (User user : buildCulprits) { + culprits.add(user.getId()); + } + } + + return culprits; + } + + private StringBuilder getLog(Run run, Endpoint target) { + StringBuilder log = new StringBuilder(); + Integer loglines = target.getLoglines(); + + if (loglines == null || loglines == 0) { + return log; + } + + try { + // The full log + if (loglines == -1) { + log.append(run.getLog()); + } else { + List logEntries = run.getLog(loglines); + for (String entry : logEntries) { + log.append(entry); + log.append("\n"); + } + } + } catch (IOException e) { + log.append("Unable to retrieve log"); + } + return log; + } + + public void handle( + Run run, + TaskListener listener, + long timestamp, + boolean manual, + final String buildNotes, + final Integer logLines, + Phase phase) { + final Job job = run.getParent(); + final HudsonNotificationProperty property = + (HudsonNotificationProperty) job.getProperty(HudsonNotificationProperty.class); + if (property == null) { + return; + } + + Result previousCompletedRunResults = findLastBuildThatFinished(run); + + for (Endpoint target : property.getEndpoints()) { + if ((!manual && !isRun(target, run.getResult(), previousCompletedRunResults)) + || Utils.isEmpty(target.getUrlInfo().getUrlOrId())) { + continue; + } + + if (Objects.nonNull(buildNotes)) { + target.setBuildNotes(buildNotes); + } + + if (Objects.nonNull(logLines) && logLines != 0) { + target.setLoglines(logLines); + } + + int triesRemaining = target.getRetries(); + boolean failed = false; + do { + // Represents a string that will be put into the log + // if there is an error contacting the target. + String urlIdString = "url 'unknown'"; + try { + EnvVars environment = run.getEnvironment(listener); + // Expand out the URL from environment + url. + String expandedUrl; + UrlInfo urlInfo = target.getUrlInfo(); + switch (urlInfo.getUrlType()) { + case PUBLIC: + expandedUrl = environment.expand(urlInfo.getUrlOrId()); + urlIdString = String.format("url '%s'", expandedUrl); + break; + case SECRET: + String urlSecretId = urlInfo.getUrlOrId(); + String actualUrl = Utils.getSecretUrl(urlSecretId, job.getParent()); + expandedUrl = environment.expand(actualUrl); + urlIdString = String.format("credentials id '%s'", urlSecretId); + break; + default: + throw new UnsupportedOperationException("Unknown URL type"); + } + + if (!isURLValid(urlIdString, expandedUrl, listener.getLogger())) { + continue; + } + + final String branch = target.getBranch(); + if (!manual + && environment.containsKey("BRANCH_NAME") + && !environment.get("BRANCH_NAME").matches(branch)) { + listener.getLogger() + .printf( + "Environment variable %s with value %s does not match configured branch filter %s%n", + "BRANCH_NAME", environment.get("BRANCH_NAME"), branch); + continue; + } else if (!manual && !environment.containsKey("BRANCH_NAME") && !".*".equals(branch)) { + listener.getLogger().printf("Environment does not contain %s variable%n", "BRANCH_NAME"); + continue; + } + + listener.getLogger().printf("Notifying endpoint with %s%n", urlIdString); + JobState jobState = buildJobState(job, run, listener, timestamp, target, phase); + target.getProtocol() + .send( + expandedUrl, + target.getFormat().serialize(jobState), + target.getTimeout(), + target.isJson()); + } catch (Throwable error) { + failed = true; + error.printStackTrace( + listener.error(String.format("Failed to notify endpoint with %s", urlIdString))); + listener.getLogger() + .printf( + "Failed to notify endpoint with %s - %s: %s%n", + urlIdString, error.getClass().getName(), error.getMessage()); + if (triesRemaining > 0) { + listener.getLogger() + .printf( + "Reattempting to notify endpoint with %s (%d tries remaining)%n", + urlIdString, triesRemaining); + } + } + } while (failed && --triesRemaining >= 0); + } + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/Protocol.java b/src/main/java/com/tikal/hudson/plugins/notification/Protocol.java index 56380f4..d1b92c1 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/Protocol.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/Protocol.java @@ -1,118 +1,159 @@ -package com.tikal.hudson.plugins.notification; - -import hudson.model.Job; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.MalformedURLException; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.URL; -import java.net.URLConnection; -import java.util.Scanner; -import java.util.regex.MatchResult; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.tikal.hudson.plugins.notification.model.BuildState; -import com.tikal.hudson.plugins.notification.model.JobState; - -@SuppressWarnings("rawtypes") -public enum Protocol { - UDP { - @Override - protected void send(String url, byte[] data) { - try { - HostnamePort hostnamePort = HostnamePort.parseUrl(url); - DatagramSocket socket = new DatagramSocket(); - DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName(hostnamePort.hostname), hostnamePort.port); - socket.send(packet); - } catch (Exception e) { - e.printStackTrace(); - } - } - }, - TCP { - @Override - protected void send(String url, byte[] data) { - try { - HostnamePort hostnamePort = HostnamePort.parseUrl(url); - SocketAddress endpoint = new InetSocketAddress(InetAddress.getByName(hostnamePort.hostname), hostnamePort.port); - Socket socket = new Socket(); - socket.connect(endpoint); - OutputStream output = socket.getOutputStream(); - output.write(data); - output.flush(); - output.close(); - } catch (Exception e) { - e.printStackTrace(); - } - } - }, - HTTP { - @Override - protected void send(String url, byte[] data) { - try { - URL targetUrl = new URL(url); - URLConnection connection = targetUrl.openConnection(); - OutputStream output = connection.getOutputStream(); - output.write(data); - output.flush(); - output.close(); - } catch (MalformedURLException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - } - }; - - private Gson gson = new GsonBuilder().create(); - - public void sendNotification(String url, Job job, int buildNumber, Phase phase, String status) { - send(url, buildMessage(job, buildNumber, phase, status)); - - } - - private byte[] buildMessage(Job job, int buildNumber, Phase phase, String status) { - JobState jobState = new JobState(); - jobState.setName(job.getName()); - jobState.setUrl(job.getUrl()); - BuildState buildState = new BuildState(); - buildState.setNumber(buildNumber); - buildState.setPhase(phase); - buildState.setStatus(status); - jobState.setBuild(buildState); - return gson.toJson(jobState).getBytes(); - } - - abstract protected void send(String url, byte[] data); - - private static class HostnamePort { - - final public String hostname; - - final public int port; - - public HostnamePort(String hostname, int port) { - this.hostname = hostname; - this.port = port; - } - - static public HostnamePort parseUrl(String url) { - Scanner scanner = new Scanner(url); - scanner.findInLine("(.+):(\\d{1,5})"); - MatchResult result = scanner.match(); - if (result.groupCount() != 2) { - return null; - } - String hostname = result.group(1); - int port = Integer.valueOf(result.group(2)); - return new HostnamePort(hostname, port); - } - } -} \ No newline at end of file +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.Base64; +import jenkins.model.Jenkins; + +public enum Protocol { + UDP { + @Override + protected void send(String url, byte[] data, int timeout, boolean isJson) throws IOException { + HostnamePort hostnamePort = HostnamePort.parseUrl(url); + DatagramSocket socket = new DatagramSocket(); + DatagramPacket packet = new DatagramPacket( + data, data.length, InetAddress.getByName(hostnamePort.hostname), hostnamePort.port); + socket.send(packet); + } + }, + TCP { + @Override + protected void send(String url, byte[] data, int timeout, boolean isJson) throws IOException { + HostnamePort hostnamePort = HostnamePort.parseUrl(url); + SocketAddress endpoint = + new InetSocketAddress(InetAddress.getByName(hostnamePort.hostname), hostnamePort.port); + Socket socket = new Socket(); + socket.setSoTimeout(timeout); + socket.connect(endpoint, timeout); + OutputStream output = socket.getOutputStream(); + output.write(data); + output.flush(); + output.close(); + } + }, + HTTP { + @Override + protected void send(String url, byte[] data, int timeout, boolean isJson) throws IOException { + + URL targetUrl = new URL(url); + if (!targetUrl.getProtocol().startsWith("http")) { + throw new IllegalArgumentException("Not an http(s) url: " + url); + } + + // Verifying if the HTTP_PROXY is available + final String httpProxyUrl = System.getenv().get("http_proxy"); + URL proxyUrl = null; + if (httpProxyUrl != null && !httpProxyUrl.isEmpty()) { + proxyUrl = new URL(httpProxyUrl); + if (!proxyUrl.getProtocol().startsWith("http")) { + throw new IllegalArgumentException("Not an http(s) url: " + httpProxyUrl); + } + } + + Proxy proxy = Proxy.NO_PROXY; + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins != null && jenkins.proxy != null) { + proxy = jenkins.proxy.createProxy(targetUrl.getHost()); + } else if (proxyUrl != null) { + // Proxy connection to the address provided + final int proxyPort = proxyUrl.getPort() > 0 ? proxyUrl.getPort() : 80; + proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyUrl.getHost(), proxyPort)); + } + + HttpURLConnection connection = (HttpURLConnection) targetUrl.openConnection(proxy); + connection.setRequestProperty( + "Content-Type", String.format("application/%s;charset=UTF-8", isJson ? "json" : "xml")); + String userInfo = targetUrl.getUserInfo(); + if (null != userInfo) { + // TODO see if UTF-8 can be used instead of platform default encoding + String b64UserInfo = Base64.getEncoder().encodeToString(userInfo.getBytes(Charset.defaultCharset())); + + String authorizationHeader = "Basic " + b64UserInfo; + connection.setRequestProperty("Authorization", authorizationHeader); + } + connection.setFixedLengthStreamingMode(data.length); + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setConnectTimeout(timeout); + connection.setReadTimeout(timeout); + connection.connect(); + try { + try (OutputStream output = connection.getOutputStream()) { + output.write(data); + output.flush(); + } + } finally { + // Follow an HTTP Temporary Redirect if we get one, + // + // NB: Normally using the HttpURLConnection interface, we'd call + // connection.setInstanceFollowRedirects(true) to enable 307 redirect following but + // since we have the connection in streaming mode this does not work and we instead + // re-direct manually. + if (307 == connection.getResponseCode()) { + String location = connection.getHeaderField("Location"); + connection.disconnect(); + send(location, data, timeout, isJson); + } else { + connection.disconnect(); + } + } + } + + @Override + public void validateUrl(String url) { + // do not validate if Jenkins Variable is used. + if (!url.contains("$")) { + try { + // noinspection ResultOfObjectAllocationIgnored + new URL(url); + } catch (MalformedURLException e) { + throw new RuntimeException(String.format( + "%sUse http://hostname:port/path for endpoint URL", + isEmpty(url) ? "" : "Invalid URL '" + url + "'. ")); + } + } + } + }; + + protected abstract void send(String url, byte[] data, int timeout, boolean isJson) throws IOException; + + public void validateUrl(String url) { + try { + HostnamePort hnp = HostnamePort.parseUrl(url); + if (hnp == null) { + throw new Exception(); + } + } catch (Exception e) { + throw new RuntimeException(String.format( + "%sUse hostname:port for endpoint URL", isEmpty(url) ? "" : "Invalid URL '" + url + "'. ")); + } + } + + private static boolean isEmpty(String s) { + return ((s == null) || (s.trim().isEmpty())); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/UrlInfo.java b/src/main/java/com/tikal/hudson/plugins/notification/UrlInfo.java new file mode 100644 index 0000000..e9d98c1 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/UrlInfo.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017 mmitche. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * + * @author mmitche + */ +public class UrlInfo { + + private String urlOrId; + private UrlType urlType; + + @DataBoundConstructor + public UrlInfo(UrlType urlType, String urlOrId) { + setUrlOrId(urlOrId); + setUrlType(urlType); + } + + public String getUrlOrId() { + return urlOrId; + } + + public void setUrlOrId(String urlOrId) { + this.urlOrId = urlOrId; + } + + public UrlType getUrlType() { + return urlType; + } + + public void setUrlType(UrlType urlType) { + this.urlType = urlType; + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/UrlType.java b/src/main/java/com/tikal/hudson/plugins/notification/UrlType.java new file mode 100644 index 0000000..dd66510 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/UrlType.java @@ -0,0 +1,25 @@ +/* + * Copyright 2017 mmitche. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification; + +/** + * + * @author mmitche + */ +public enum UrlType { + SECRET, + PUBLIC +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/Utils.java b/src/main/java/com/tikal/hudson/plugins/notification/Utils.java new file mode 100644 index 0000000..becfd85 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/Utils.java @@ -0,0 +1,68 @@ +package com.tikal.hudson.plugins.notification; + +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import hudson.model.ItemGroup; +import hudson.security.ACL; +import hudson.util.Secret; +import java.util.Arrays; +import java.util.Collections; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; + +/** + * Helper utilities + */ +public final class Utils { + private Utils() {} + + /** + * Determines if any of Strings specified is either null or empty. + * @param strings - Strings to check for empty (whitespace is trimmed) or null. + * @return True if any string is empty + */ + @SuppressWarnings("MethodWithMultipleReturnPoints") + public static boolean isEmpty(String... strings) { + if ((strings == null) || (strings.length < 1)) { + return true; + } + + for (String s : strings) { + if ((s == null) || (s.trim().isEmpty())) { + return true; + } + } + + return false; + } + + /** + * Verifies neither of Strings specified is null or empty. + * @param strings Strings to check for empty (whitespace is trimmed) or null. + * @throws java.lang.IllegalArgumentException Throws this exception if any string is empty. + */ + public static void verifyNotEmpty(String... strings) { + if (isEmpty(strings)) { + throw new IllegalArgumentException( + String.format("Some String arguments are null or empty: %s", Arrays.toString(strings))); + } + } + + /** + * Get the actual URL from the credential id + * @param credentialId Credential id to lookup + * @param itemGroup the item group to look for the credential + * @return Actual URL + */ + public static String getSecretUrl(String credentialId, ItemGroup itemGroup) { + // Grab the secret text + StringCredentials creds = CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentials( + StringCredentials.class, itemGroup, ACL.SYSTEM, Collections.emptyList()), + CredentialsMatchers.withId(credentialId)); + if (creds == null) { + return null; + } + Secret secretUrl = creds.getSecret(); + return secretUrl.getPlainText(); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/model/BuildState.java b/src/main/java/com/tikal/hudson/plugins/notification/model/BuildState.java index 25f1f68..7cd0402 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/model/BuildState.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/model/BuildState.java @@ -1,46 +1,277 @@ -package com.tikal.hudson.plugins.notification.model; - -import com.tikal.hudson.plugins.notification.Phase; - -public class BuildState { - - private int number; - - private Phase phase; - - private String status; - - private String url; - - public int getNumber() { - return number; - } - - public void setNumber(int number) { - this.number = number; - } - - public Phase getPhase() { - return phase; - } - - public void setPhase(Phase phase) { - this.phase = phase; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } -} +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification.model; + +import static com.tikal.hudson.plugins.notification.Utils.isEmpty; +import static com.tikal.hudson.plugins.notification.Utils.verifyNotEmpty; + +import com.tikal.hudson.plugins.notification.Phase; +import hudson.model.AbstractBuild; +import hudson.model.Job; +import hudson.model.Run; +import hudson.plugins.s3.Entry; +import hudson.plugins.s3.S3BucketPublisher; +import hudson.util.DescribableList; +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import jenkins.model.Jenkins; + +public class BuildState { + + private String fullUrl; + + private int number; + + private long queueId; + + private long timestamp; + + private long duration; + + private Phase phase; + + private String status; + + private String url; + + private String displayName; + + private ScmState scm; + + private Map parameters; + + private StringBuilder log; + + private String notes; + + private TestState testSummary; + + /** + * Map of artifacts: file name => Map of artifact locations ( location name => artifact URL ) + * --- + * artifacts: + * notification.hpi: + * s3: https://s3-eu-west-1.amazonaws.com/evgenyg-bakery-artifacts/jobs/notification-plugin/78/notification.hpi + * archive: http://localhost:8080/job/notification-plugin/78/artifact/target/notification.hpi + * notification.jar: + * archive: http://localhost:8080/job/notification-plugin/78/artifact/target/notification.jar + */ + private final Map> artifacts = new HashMap<>(); + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public long getQueueId() { + return queueId; + } + + public void setQueueId(long queue) { + this.queueId = queue; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public long getDuration() { + return duration; + } + + public void setDuration(long duration) { + this.duration = duration; + } + + public Phase getPhase() { + return phase; + } + + public void setPhase(Phase phase) { + this.phase = phase; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getFullUrl() { + return fullUrl; + } + + public void setFullUrl(String fullUrl) { + this.fullUrl = fullUrl; + } + + public Map getParameters() { + return parameters; + } + + public void setParameters(Map params) { + this.parameters = new HashMap<>(params); + } + + public Map> getArtifacts() { + return artifacts; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public ScmState getScm() { + return scm; + } + + public void setScm(ScmState scmState) { + this.scm = scmState; + } + + public StringBuilder getLog() { + return this.log; + } + + public void setLog(StringBuilder log) { + this.log = log; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String buildNotes) { + this.notes = buildNotes; + } + + public TestState getTestSummary() { + return testSummary; + } + + public void setTestSummary(TestState testSummary) { + this.testSummary = testSummary; + } + + /** + * Updates artifacts Map with S3 links, if corresponding publisher is available. + * @param job Job to update + * @param run Run to update + */ + public void updateArtifacts(Job job, Run run) { + updateArchivedArtifacts(run); + updateS3Artifacts(job, run); + } + + private void updateArchivedArtifacts(Run run) { + @SuppressWarnings("unchecked") + List buildArtifacts = run.getArtifacts(); + + for (Run.Artifact a : buildArtifacts) { + String artifactUrl = Jenkins.get().getRootUrl() + run.getUrl() + "artifact/" + a.getHref(); + updateArtifact(a.relativePath, "archive", artifactUrl); + } + } + + private void updateS3Artifacts(Job job, Run run) { + if (Jenkins.get().getPlugin("s3") == null) { + return; + } + if (!(run instanceof AbstractBuild)) { + return; + } + if (isEmpty(job.getName())) { + return; + } + + DescribableList publishers = ((AbstractBuild) run).getProject().getPublishersList(); + S3BucketPublisher s3Publisher = (S3BucketPublisher) publishers.get(S3BucketPublisher.class); + + if (s3Publisher == null) { + return; + } + + for (Entry entry : s3Publisher.getEntries()) { + + if (isEmpty(entry.sourceFile, entry.selectedRegion, entry.bucket)) { + continue; + } + String fileName = new File(entry.sourceFile).getName(); + if (isEmpty(fileName)) { + continue; + } + + // https://s3-eu-west-1.amazonaws.com/evgenyg-temp/ + String bucketUrl = String.format( + "https://s3-%s.amazonaws.com/%s", + entry.selectedRegion.toLowerCase().replace('_', '-'), entry.bucket); + + String fileUrl = entry.managedArtifacts + ? + // https://s3-eu-west-1.amazonaws.com/evgenyg-temp/jobs/notification-plugin/21/notification.hpi + String.format("%s/jobs/%s/%s/%s", bucketUrl, job.getName(), run.getNumber(), fileName) + : + // https://s3-eu-west-1.amazonaws.com/evgenyg-temp/notification.hpi + String.format("%s/%s", bucketUrl, fileName); + + updateArtifact(fileName, "s3", fileUrl); + } + } + + /** + * Updates an artifact URL. + * + * @param fileName artifact file name + * @param locationName artifact location name, like "s3" or "archive" + * @param locationUrl artifact URL at the location specified + */ + private void updateArtifact(String fileName, String locationName, String locationUrl) { + verifyNotEmpty(fileName, locationName, locationUrl); + + if (!artifacts.containsKey(fileName)) { + artifacts.put(fileName, new HashMap<>()); + } + + if (artifacts.get(fileName).containsKey(locationName)) { + throw new RuntimeException(String.format( + "Adding artifacts mapping '%s/%s/%s' - artifacts Map already contains mapping of location '%s': %s", + fileName, locationName, locationUrl, locationName, artifacts)); + } + + artifacts.get(fileName).put(locationName, locationUrl); + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/model/JobState.java b/src/main/java/com/tikal/hudson/plugins/notification/model/JobState.java index 7540161..0a2d3c5 100755 --- a/src/main/java/com/tikal/hudson/plugins/notification/model/JobState.java +++ b/src/main/java/com/tikal/hudson/plugins/notification/model/JobState.java @@ -1,34 +1,60 @@ -package com.tikal.hudson.plugins.notification.model; - -public class JobState { - - private String name; - - private String url; - - private BuildState build; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public BuildState getBuild() { - return build; - } - - public void setBuild(BuildState build) { - this.build = build; - } -} +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification.model; + +import com.thoughtworks.xstream.annotations.XStreamAlias; + +@XStreamAlias("job") +public class JobState { + + private String name; + + private String displayName; + + private String url; + + private BuildState build; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public BuildState getBuild() { + return build; + } + + public void setBuild(BuildState build) { + this.build = build; + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/model/ScmState.java b/src/main/java/com/tikal/hudson/plugins/notification/model/ScmState.java new file mode 100644 index 0000000..ecaa20a --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/model/ScmState.java @@ -0,0 +1,68 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification.model; + +import java.util.List; + +public class ScmState { + private String url; + + private String branch; + + private String commit; + + private List changes; + + private List culprits; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getBranch() { + return branch; + } + + public void setBranch(String branch) { + this.branch = branch; + } + + public String getCommit() { + return commit; + } + + public void setCommit(String commit) { + this.commit = commit; + } + + public List getChanges() { + return changes; + } + + public void setChanges(List changes) { + this.changes = changes; + } + + public List getCulprits() { + return culprits; + } + + public void setCulprits(List culprits) { + this.culprits = culprits; + } +} diff --git a/src/main/java/com/tikal/hudson/plugins/notification/model/TestState.java b/src/main/java/com/tikal/hudson/plugins/notification/model/TestState.java new file mode 100644 index 0000000..c4854c9 --- /dev/null +++ b/src/main/java/com/tikal/hudson/plugins/notification/model/TestState.java @@ -0,0 +1,64 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification.model; + +import java.util.List; + +public class TestState { + private int total; + private int failed; + private int passed; + private int skipped; + private List failedTests; + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public int getFailed() { + return failed; + } + + public void setFailed(int failed) { + this.failed = failed; + } + + public int getPassed() { + return passed; + } + + public void setPassed(int passed) { + this.passed = passed; + } + + public int getSkipped() { + return skipped; + } + + public void setSkipped(int skipped) { + this.skipped = skipped; + } + + public List getFailedTests() { + return failedTests; + } + + public void setFailedTests(List failedTests) { + this.failedTests = failedTests; + } +} diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/config.jelly b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/config.jelly index 766d6c9..197750e 100755 --- a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/config.jelly +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/config.jelly @@ -1,41 +1,110 @@ + + xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form" xmlns:c="/lib/credentials" xmlns:p="/lib/notification"> - - - - - -
- - - - - - - - - -
- - - -
- - - -
- -
-
- - - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-branch.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-branch.html new file mode 100644 index 0000000..c63110a --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-branch.html @@ -0,0 +1,6 @@ +
+

Only notify if branch matches pattern. + Regex rules / pattern definition in + JAVA docs. +

+
diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-endpoints.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-endpoints.html new file mode 100644 index 0000000..8f77edd --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-endpoints.html @@ -0,0 +1,2 @@ +
Sends notifications about Job status to the defined endpoints +using UDP, TCP or HTTP protocols.
\ No newline at end of file diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-name.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-name.html deleted file mode 100755 index 3fe2997..0000000 --- a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-name.html +++ /dev/null @@ -1,5 +0,0 @@ -
- Help file for fields are discovered through a file name convention. This file is an line help for - the "name" field. You can have arbitrary HTML here. You can write this file as a Jelly script - if you need a dynamic content (but if you do so, change the extension to .jelly) -
\ No newline at end of file diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-notes.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-notes.html new file mode 100644 index 0000000..c786331 --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-notes.html @@ -0,0 +1,6 @@ +
+

Provides a place to include additional build information in the message. + This can contain tokens as defined by the + token-macro plugin. +

+
diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-retries.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-retries.html new file mode 100644 index 0000000..f01b397 --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-retries.html @@ -0,0 +1 @@ +
Number of retries to before abandoning attempt to contact the endpoint.
\ No newline at end of file diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-timeout.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-timeout.html new file mode 100644 index 0000000..5e8902f --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-timeout.html @@ -0,0 +1 @@ +
Sets connection timeout (in milliseconds) for TCP and HTTP. Default timeout is 30 seconds (30,000 ms)
\ No newline at end of file diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-url.html b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-url.html new file mode 100644 index 0000000..c8b0c19 --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/HudsonNotificationProperty/help-url.html @@ -0,0 +1,8 @@ +
+
    +
  • URL format for UDP and TCP protocols is + (hostname|IpAddress):portNumber, e.g. 10.10.10.10:12345
  • +
  • URL format for HTTP protocol is any legal http address, e.g. + http://[user:password@]myhost.com:8080/monitor
  • +
+
diff --git a/src/main/resources/com/tikal/hudson/plugins/notification/Messages.properties b/src/main/resources/com/tikal/hudson/plugins/notification/Messages.properties new file mode 100644 index 0000000..e95c3ee --- /dev/null +++ b/src/main/resources/com/tikal/hudson/plugins/notification/Messages.properties @@ -0,0 +1 @@ +Notify.DisplayName=Notify configured endpoints diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly index 3a17698..ad9ec4b 100755 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -1 +1,6 @@ -
This plugin allows Hudson to send build notifications.
+ +
+ This plugin from + Tikal Knowledge + allows sending running Jobs status notifications. +
diff --git a/src/main/resources/lib/notification/blockWrapper.jelly b/src/main/resources/lib/notification/blockWrapper.jelly new file mode 100644 index 0000000..d43a2fe --- /dev/null +++ b/src/main/resources/lib/notification/blockWrapper.jelly @@ -0,0 +1,16 @@ + + + + + +
+ +
+
+ + + +
+
+
+
diff --git a/src/main/resources/lib/notification/blockWrapperCentered.jelly b/src/main/resources/lib/notification/blockWrapperCentered.jelly new file mode 100644 index 0000000..0e9d7ed --- /dev/null +++ b/src/main/resources/lib/notification/blockWrapperCentered.jelly @@ -0,0 +1,16 @@ + + + + + +
+ +
+
+ + + +
+
+
+
diff --git a/src/main/resources/lib/notification/cellWrapper.jelly b/src/main/resources/lib/notification/cellWrapper.jelly new file mode 100644 index 0000000..0a30187 --- /dev/null +++ b/src/main/resources/lib/notification/cellWrapper.jelly @@ -0,0 +1,16 @@ + + + + + +
+ +
+
+ + + + + +
+
diff --git a/src/main/resources/lib/notification/rowWrapper.jelly b/src/main/resources/lib/notification/rowWrapper.jelly new file mode 100644 index 0000000..710190d --- /dev/null +++ b/src/main/resources/lib/notification/rowWrapper.jelly @@ -0,0 +1,16 @@ + + + + + +
+ +
+
+ + + + + +
+
diff --git a/src/main/resources/lib/notification/taglib b/src/main/resources/lib/notification/taglib new file mode 100644 index 0000000..e69de29 diff --git a/src/main/webapp/help-globalConfig.html b/src/main/webapp/help-globalConfig.html index 7b5ebed..f6b7a1f 100755 --- a/src/main/webapp/help-globalConfig.html +++ b/src/main/webapp/help-globalConfig.html @@ -1,8 +1,14 @@
-

- This HTML fragment will be injected into the configuration screen - when the user clicks the 'help' icon. See global.jelly for how the - form decides which page to load. - You can have any HTML fragment here. -

-
+

This plugin from Tikal +Knowledge allows sending Job Status notifications.

+
+{
+    "name":"JobName",
+    "url":"JobUrl",
+    "build":{
+        "number":1,
+        "phase":"STARTED",
+        "status":"FAILED"
+    }
+}
+
diff --git a/src/main/webapp/help-projectConfig.html b/src/main/webapp/help-projectConfig.html new file mode 100755 index 0000000..f6b7a1f --- /dev/null +++ b/src/main/webapp/help-projectConfig.html @@ -0,0 +1,14 @@ +
+

This plugin from Tikal +Knowledge allows sending Job Status notifications.

+
+{
+    "name":"JobName",
+    "url":"JobUrl",
+    "build":{
+        "number":1,
+        "phase":"STARTED",
+        "status":"FAILED"
+    }
+}
+
diff --git a/src/test/java/com/tikal/hudson/plugins/notification/PhaseTest.java b/src/test/java/com/tikal/hudson/plugins/notification/PhaseTest.java new file mode 100644 index 0000000..04f2b41 --- /dev/null +++ b/src/test/java/com/tikal/hudson/plugins/notification/PhaseTest.java @@ -0,0 +1,294 @@ +package com.tikal.hudson.plugins.notification; + +import static com.tikal.hudson.plugins.notification.UrlType.PUBLIC; +import static com.tikal.hudson.plugins.notification.UrlType.SECRET; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.isA; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.tikal.hudson.plugins.notification.model.JobState; +import hudson.EnvVars; +import hudson.model.Job; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.IOException; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.util.List; +import jenkins.model.Jenkins; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class PhaseTest { + @Mock + private Run run; + + @Mock + private Job job; + + @Mock + private TaskListener listener; + + @Mock + private HudsonNotificationProperty property; + + @Mock + private Endpoint endpoint; + + @Mock + private UrlInfo urlInfo; + + @Mock + private EnvVars environment; + + @Mock + private PrintStream logger; + + @Mock + private Jenkins jenkins; + + @Test + public void testIsRun() throws ReflectiveOperationException { + Endpoint endPoint = new Endpoint(null); + Method isRunMethod = Phase.class.getDeclaredMethod("isRun", Endpoint.class, Result.class, Result.class); + isRunMethod.setAccessible(true); + + assertEquals( + "returns true for null endpoint event", + isRunMethod.invoke(Phase.QUEUED, endPoint, null, null), + Boolean.TRUE); + + endPoint.setEvent("all"); + for (Phase phaseValue : Phase.values()) { + assertEquals( + "all Event returns true for Phase " + phaseValue.toString(), + isRunMethod.invoke(phaseValue, endPoint, null, null), + Boolean.TRUE); + } + + endPoint.setEvent("queued"); + assertEquals( + "queued Event returns true for Phase Queued", + isRunMethod.invoke(Phase.QUEUED, endPoint, null, null), + Boolean.TRUE); + assertEquals( + "queued Event returns false for Phase Started", + isRunMethod.invoke(Phase.STARTED, endPoint, null, null), + Boolean.FALSE); + + endPoint.setEvent("started"); + assertEquals( + "started Event returns true for Phase Started", + isRunMethod.invoke(Phase.STARTED, endPoint, null, null), + Boolean.TRUE); + assertEquals( + "started Event returns false for Phase Completed", + isRunMethod.invoke(Phase.COMPLETED, endPoint, null, null), + Boolean.FALSE); + + endPoint.setEvent("completed"); + assertEquals( + "completed Event returns true for Phase Completed", + isRunMethod.invoke(Phase.COMPLETED, endPoint, null, null), + Boolean.TRUE); + assertEquals( + "completed Event returns false for Phase Finalized", + isRunMethod.invoke(Phase.FINALIZED, endPoint, null, null), + Boolean.FALSE); + + endPoint.setEvent("finalized"); + assertEquals( + "finalized Event returns true for Phase Finalized", + isRunMethod.invoke(Phase.FINALIZED, endPoint, null, null), + Boolean.TRUE); + assertEquals( + "finalized Event returns true for Phase Queued", + isRunMethod.invoke(Phase.QUEUED, endPoint, null, null), + Boolean.FALSE); + + endPoint.setEvent("failed"); + assertEquals( + "failed Event returns false for Phase Finalized and no status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, null, null), + Boolean.FALSE); + assertEquals( + "failed Event returns false for Phase Finalized and success status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.SUCCESS, null), + Boolean.FALSE); + assertEquals( + "failed Event returns true for Phase Finalized and success failure", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.FAILURE, null), + Boolean.TRUE); + assertEquals( + "failed Event returns false for Phase not Finalized and success failure", + isRunMethod.invoke(Phase.COMPLETED, endPoint, Result.FAILURE, null), + Boolean.FALSE); + + endPoint.setEvent("failedAndFirstSuccess"); + assertEquals( + "failedAndFirstSuccess Event returns false for Phase Finalized and no status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, null, null), + Boolean.FALSE); + assertEquals( + "failedAndFirstSuccess Event returns false for Phase Finalized and no previous status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.SUCCESS, null), + Boolean.FALSE); + assertEquals( + "failedAndFirstSuccess Event returns true for Phase Finalized and no previous status and failed status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.FAILURE, null), + Boolean.TRUE); + assertEquals( + "failedAndFirstSuccess Event returns true for Phase Finalized and failed status", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.FAILURE, Result.FAILURE), + Boolean.TRUE); + assertEquals( + "failedAndFirstSuccess Event returns true for Phase Finalized and success status with previous status of failure", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.SUCCESS, Result.FAILURE), + Boolean.TRUE); + assertEquals( + "failedAndFirstSuccess Event returns false for Phase Finalized and success status with previous status of success", + isRunMethod.invoke(Phase.FINALIZED, endPoint, Result.SUCCESS, Result.SUCCESS), + Boolean.FALSE); + assertEquals( + "failedAndFirstSuccess Event returns false for Phase not Finalized", + isRunMethod.invoke(Phase.COMPLETED, endPoint, Result.SUCCESS, Result.FAILURE), + Boolean.FALSE); + } + + @Test + public void testRunNoProperty() { + when(run.getParent()).thenReturn(job); + + Phase.STARTED.handle(run, listener, 0L); + + verify(job).getProperty(HudsonNotificationProperty.class); + verifyNoInteractions(listener, endpoint, property); + } + + @Test + public void testRunNoPreviousRunUrlNull() { + when(run.getParent()).thenReturn(job); + when(job.getProperty(HudsonNotificationProperty.class)).thenReturn(property); + when(property.getEndpoints()).thenReturn(List.of(endpoint)); + when(endpoint.getUrlInfo()).thenReturn(urlInfo); + + Phase.STARTED.handle(run, listener, 0L); + + verify(run).getPreviousCompletedBuild(); + verifyNoInteractions(listener); + } + + @Test + public void testRunNoPreviousRunUrlTypePublicUnresolvedUrl() throws IOException, InterruptedException { + when(run.getParent()).thenReturn(job); + when(job.getProperty(HudsonNotificationProperty.class)).thenReturn(property); + when(property.getEndpoints()).thenReturn(List.of(endpoint)); + when(endpoint.getUrlInfo()).thenReturn(urlInfo); + when(run.getEnvironment(listener)).thenReturn(environment); + when(urlInfo.getUrlOrId()).thenReturn("$someUrl"); + when(urlInfo.getUrlType()).thenReturn(PUBLIC); + when(environment.expand("$someUrl")).thenReturn("$someUrl"); + when(listener.getLogger()).thenReturn(logger); + + Phase.STARTED.handle(run, listener, 0L); + + verify(logger).printf("Ignoring sending notification due to unresolved variable: %s%n", "url '$someUrl'"); + verify(run).getPreviousCompletedBuild(); + } + + @Test + public void testRunPreviousRunUrlTypePublic() throws IOException, InterruptedException { + byte[] data = "data".getBytes(); + try (MockedStatic jenkinsMockedStatic = mockStatic(Jenkins.class)) { + jenkinsMockedStatic.when(Jenkins::getInstanceOrNull).thenReturn(jenkins); + jenkinsMockedStatic.when(Jenkins::get).thenReturn(jenkins); + + Protocol httpProtocolSpy = spy(Protocol.HTTP); + when(endpoint.getProtocol()).thenReturn(httpProtocolSpy); + doNothing().when(httpProtocolSpy).send(anyString(), any(byte[].class), anyInt(), anyBoolean()); + + Format jsonFormatSpy = spy(Format.JSON); + JobState jobState = new JobState(); + when(endpoint.getFormat()).thenReturn(jsonFormatSpy); + doReturn(data).when(jsonFormatSpy).serialize(isA(JobState.class)); + assertEquals(data, jsonFormatSpy.serialize(jobState)); + + when(run.getParent()).thenReturn(job); + when(job.getProperty(HudsonNotificationProperty.class)).thenReturn(property); + when(property.getEndpoints()).thenReturn(List.of(endpoint)); + when(endpoint.getUrlInfo()).thenReturn(urlInfo); + when(endpoint.getBranch()).thenReturn("branchName"); + when(run.getEnvironment(listener)).thenReturn(environment); + when(urlInfo.getUrlOrId()).thenReturn("$someUrl"); + when(urlInfo.getUrlType()).thenReturn(PUBLIC); + when(environment.expand("$someUrl")).thenReturn("expandedUrl"); + when(environment.containsKey("BRANCH_NAME")).thenReturn(true); + when(environment.get("BRANCH_NAME")).thenReturn("branchName"); + when(listener.getLogger()).thenReturn(logger); + when(endpoint.getTimeout()).thenReturn(42); + + Phase.STARTED.handle(run, listener, 1L); + + verify(logger).printf("Notifying endpoint with %s%n", "url 'expandedUrl'"); + verify(httpProtocolSpy).send("expandedUrl", data, 42, false); + verify(run).getPreviousCompletedBuild(); + } + } + + @Test + public void testRunPreviousRunUrlTypeSecret() throws IOException, InterruptedException { + byte[] data = "data".getBytes(); + try (MockedStatic jenkinsMockedStatic = mockStatic(Jenkins.class); + MockedStatic utilsMockedStatic = mockStatic(Utils.class)) { + jenkinsMockedStatic.when(Jenkins::getInstanceOrNull).thenReturn(jenkins); + jenkinsMockedStatic.when(Jenkins::get).thenReturn(jenkins); + utilsMockedStatic + .when(() -> Utils.getSecretUrl("credentialsId", jenkins)) + .thenReturn("$secretUrl"); + + Protocol httpProtocolSpy = spy(Protocol.HTTP); + when(endpoint.getProtocol()).thenReturn(httpProtocolSpy); + doNothing().when(httpProtocolSpy).send(anyString(), any(byte[].class), anyInt(), anyBoolean()); + + Format jsonFormatSpy = spy(Format.JSON); + JobState jobState = new JobState(); + when(endpoint.getFormat()).thenReturn(jsonFormatSpy); + doReturn(data).when(jsonFormatSpy).serialize(isA(JobState.class)); + assertEquals(data, jsonFormatSpy.serialize(jobState)); + + when(run.getParent()).thenReturn(job); + when(job.getProperty(HudsonNotificationProperty.class)).thenReturn(property); + when(property.getEndpoints()).thenReturn(List.of(endpoint)); + when(endpoint.getUrlInfo()).thenReturn(urlInfo); + when(endpoint.getBranch()).thenReturn(".*"); + when(run.getEnvironment(listener)).thenReturn(environment); + when(job.getParent()).thenReturn(jenkins); + when(urlInfo.getUrlOrId()).thenReturn("credentialsId"); + when(urlInfo.getUrlType()).thenReturn(SECRET); + when(environment.expand("$secretUrl")).thenReturn("secretUrl"); + when(listener.getLogger()).thenReturn(logger); + when(endpoint.getTimeout()).thenReturn(42); + + Phase.STARTED.handle(run, listener, 1L); + + verify(logger).printf("Notifying endpoint with %s%n", "credentials id 'credentialsId'"); + verify(httpProtocolSpy).send("secretUrl", data, 42, false); + verify(run).getPreviousCompletedBuild(); + } + } +} diff --git a/src/test/java/com/tikal/hudson/plugins/notification/ProtocolTest.java b/src/test/java/com/tikal/hudson/plugins/notification/ProtocolTest.java new file mode 100644 index 0000000..71dba4c --- /dev/null +++ b/src/test/java/com/tikal/hudson/plugins/notification/ProtocolTest.java @@ -0,0 +1,252 @@ +/* + * The MIT License + * + * Copyright (c) 2011, 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.tikal.hudson.plugins.notification; + +import com.google.common.base.MoreObjects; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import junit.framework.TestCase; + +/** + * @author Kohsuke Kawaguchi + */ +public class ProtocolTest extends TestCase { + + static class Request { + private final String url; + private final String method; + private final String body; + private final String userInfo; + + Request(HttpExchange he) throws IOException { + InetSocketAddress address = he.getLocalAddress(); + this.url = "http://" + address.getHostString() + ":" + + address.getPort() + + he.getRequestURI().toString(); + this.method = he.getRequestMethod(); + this.body = new String(he.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + String auth = he.getRequestHeaders().getFirst("Authorization"); + this.userInfo = + (null == auth) ? null : new String(Base64.getDecoder().decode(auth.split(" ")[1])) + "@"; + } + + Request(String url, String method, String body) { + this.url = url; + this.method = method; + this.body = body; + this.userInfo = null; + } + + @Override + public int hashCode() { + return Objects.hash(url, method, body); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Request)) { + return false; + } + Request other = (Request) obj; + return Objects.equals(url, other.url) + && Objects.equals(method, other.method) + && Objects.equals(body, other.body); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("url", url) + .add("method", method) + .add("body", body) + .toString(); + } + + public String getUrl() { + return url; + } + + public String getUrlWithAuthority() { + if (null == userInfo) { + // Detect possible bug: userInfo never moved from URI to Authorization header + return null; + } else { + return url.replaceFirst("^http://", "http://" + userInfo); + } + } + } + + static class RecordingServlet implements HttpHandler { + private final BlockingQueue requests; + + public RecordingServlet(BlockingQueue requests) { + this.requests = requests; + } + + @Override + public void handle(HttpExchange he) throws IOException { + + Request request = new Request(he); + try { + requests.put(request); + } catch (InterruptedException e) { + throw new IOException("Interrupted while adding request to queue", e); + } + he.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0); + he.close(); + } + } + + static class RedirectHandler implements HttpHandler { + private final BlockingQueue requests; + private final String redirectURI; + + public RedirectHandler(BlockingQueue requests, String redirectURI) { + this.requests = Objects.requireNonNull(requests); + this.redirectURI = Objects.requireNonNull(redirectURI); + } + + @Override + public void handle(HttpExchange he) throws IOException { + Request request = new Request(he); + try { + requests.put(request); + } catch (InterruptedException e) { + throw new IOException("Interrupted while adding request to queue", e); + } + he.getResponseHeaders().set("Location", redirectURI); + he.sendResponseHeaders(307, -1); + he.close(); + } + } + + private List servers; + + interface UrlFactory { + String getUrl(String path); + } + + private UrlFactory startServer(HttpHandler handler, String path) throws Exception { + return startSecureServer(handler, path, ""); + } + + private UrlFactory startSecureServer(HttpHandler handler, String path, String authority) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext(path, handler); + + server.start(); + servers.add(server); + + if (!authority.isEmpty()) { + authority += "@"; + } + + InetSocketAddress address = server.getAddress(); + final URL serverUrl = + new URL(String.format("http://%s%s:%d", authority, address.getHostString(), address.getPort())); + return new UrlFactory() { + @Override + public String getUrl(String path) { + try { + return new URL(serverUrl, path).toExternalForm(); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } + }; + } + + @Override + public void setUp() { + servers = new LinkedList<>(); + } + + @Override + public void tearDown() { + for (HttpServer server : servers) { + server.stop(1); + } + } + + public void testHttpPost() throws Exception { + BlockingQueue requests = new LinkedBlockingQueue<>(); + + UrlFactory urlFactory = startServer(new RecordingServlet(requests), "/realpath"); + + assertTrue(requests.isEmpty()); + + String uri = urlFactory.getUrl("/realpath"); + Protocol.HTTP.send(uri, "Hello".getBytes(), 30000, true); + + assertEquals(new Request(uri, "POST", "Hello"), requests.take()); + assertTrue(requests.isEmpty()); + } + + public void testHttpPostWithBasicAuth() throws Exception { + BlockingQueue requests = new LinkedBlockingQueue<>(); + + UrlFactory urlFactory = startSecureServer(new RecordingServlet(requests), "/realpath", "fred:foo"); + + assertTrue(requests.isEmpty()); + + String uri = urlFactory.getUrl("/realpath"); + Protocol.HTTP.send(uri, "Hello".getBytes(), 30000, true); + + Request theRequest = requests.take(); + assertTrue(requests.isEmpty()); + assertEquals(new Request(uri, "POST", "Hello").getUrl(), theRequest.getUrlWithAuthority()); + } + + public void testHttpPostWithRedirects() throws Exception { + BlockingQueue requests = new LinkedBlockingQueue<>(); + + UrlFactory urlFactory = startServer(new RecordingServlet(requests), "/realpath"); + + String redirectUri = urlFactory.getUrl("/realpath"); + UrlFactory redirectorUrlFactory = startServer(new RedirectHandler(requests, redirectUri), "/path"); + + assertTrue(requests.isEmpty()); + + String uri = redirectorUrlFactory.getUrl("/path"); + Protocol.HTTP.send(uri, "RedirectMe".getBytes(), 30000, true); + + assertEquals(new Request(uri, "POST", "RedirectMe"), requests.take()); + assertEquals(new Request(redirectUri, "POST", "RedirectMe"), requests.take()); + assertTrue(requests.isEmpty()); + } +} diff --git a/src/test/java/com/tikal/hudson/plugins/notification/test/HostnamePortTest.java b/src/test/java/com/tikal/hudson/plugins/notification/test/HostnamePortTest.java new file mode 100644 index 0000000..c1ed4f2 --- /dev/null +++ b/src/test/java/com/tikal/hudson/plugins/notification/test/HostnamePortTest.java @@ -0,0 +1,34 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tikal.hudson.plugins.notification.test; + +import com.tikal.hudson.plugins.notification.HostnamePort; +import org.junit.Assert; +import org.junit.Test; + +public class HostnamePortTest { + + @Test + public void parseUrlTest() { + HostnamePort hnp = HostnamePort.parseUrl("111"); + Assert.assertNull(hnp); + hnp = HostnamePort.parseUrl(null); + Assert.assertNull(hnp); + hnp = HostnamePort.parseUrl("localhost:123"); + Assert.assertEquals("localhost", hnp.hostname); + Assert.assertEquals(123, hnp.port); + hnp = HostnamePort.parseUrl("localhost:123456"); + Assert.assertEquals(12345, hnp.port); + } +}