diff --git a/pom.xml b/pom.xml index 4bacf6a3..65114a78 100644 --- a/pom.xml +++ b/pom.xml @@ -151,7 +151,14 @@ 1.15 true + + org.jenkins-ci.plugins + script-security + 1.37 + + + org.hamcrest java-hamcrest 2.0.0.0 diff --git a/src/main/java/jenkins/plugins/logstash/LogstashBuildWrapper.java b/src/main/java/jenkins/plugins/logstash/LogstashBuildWrapper.java index 81ec9f41..9873a99c 100644 --- a/src/main/java/jenkins/plugins/logstash/LogstashBuildWrapper.java +++ b/src/main/java/jenkins/plugins/logstash/LogstashBuildWrapper.java @@ -35,8 +35,17 @@ import hudson.model.BuildListener; import hudson.tasks.BuildWrapper; import hudson.tasks.BuildWrapperDescriptor; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; + +import java.io.IOException; +import java.io.OutputStream; + +import javax.annotation.CheckForNull; + +import org.kohsuke.stapler.DataBoundSetter; /** + * Logstash note on each output line. * * This BuildWrapper is not used anymore. * We just keep it to be able to convert projects that have the BuildWrapper configured at startup or when posting the xml via the rest api @@ -48,6 +57,9 @@ public class LogstashBuildWrapper extends BuildWrapper { + @CheckForNull + private SecureGroovyScript secureGroovyScript; + /** * Create a new {@link LogstashBuildWrapper}. */ @@ -55,6 +67,11 @@ public class LogstashBuildWrapper extends BuildWrapper public LogstashBuildWrapper() {} + @DataBoundSetter + public void setSecureGroovyScript(@CheckForNull SecureGroovyScript script) { + this.secureGroovyScript = script != null ? script.configuringWithNonKeyItem() : null; + } + /** * {@inheritDoc} */ @@ -66,11 +83,26 @@ public Environment setUp(AbstractBuild build, Launcher launcher, BuildListener l { }; } - + @Override - public DescriptorImpl getDescriptor() - { - return (DescriptorImpl)super.getDescriptor(); + public DescriptorImpl getDescriptor() { + return (DescriptorImpl) super.getDescriptor(); + } + + @CheckForNull + public SecureGroovyScript getSecureGroovyScript() { + // FIXME probbably needs to be moved + return secureGroovyScript; + } + + // Method to encapsulate calls for unit-testing + LogstashWriter getLogStashWriter(AbstractBuild build, OutputStream errorStream) { + LogstashScriptProcessor processor = null; + if (secureGroovyScript != null) { + processor = new LogstashScriptProcessor(secureGroovyScript, errorStream); + } + + return new LogstashWriter(build, errorStream, null, build.getCharset(), processor); } /** diff --git a/src/main/java/jenkins/plugins/logstash/LogstashInstallation.java b/src/main/java/jenkins/plugins/logstash/LogstashInstallation.java index ae5922df..27ff38f3 100644 --- a/src/main/java/jenkins/plugins/logstash/LogstashInstallation.java +++ b/src/main/java/jenkins/plugins/logstash/LogstashInstallation.java @@ -72,7 +72,6 @@ public Descriptor() { load(); } - @Override public String getDisplayName() { return Messages.DisplayName(); diff --git a/src/main/java/jenkins/plugins/logstash/LogstashOutputStream.java b/src/main/java/jenkins/plugins/logstash/LogstashOutputStream.java index f6ae5de2..412ad0c6 100644 --- a/src/main/java/jenkins/plugins/logstash/LogstashOutputStream.java +++ b/src/main/java/jenkins/plugins/logstash/LogstashOutputStream.java @@ -30,6 +30,8 @@ import java.io.IOException; import java.io.OutputStream; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + /** * Output stream that writes each line to the provided delegate output stream * and also sends it to an indexer for logstash to consume. @@ -54,6 +56,9 @@ LogstashWriter getLogstashWriter() } @Override + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") protected void eol(byte[] b, int len) throws IOException { delegate.write(b, 0, len); this.flush(); @@ -79,6 +84,7 @@ public void flush() throws IOException { */ @Override public void close() throws IOException { + logstash.close(); delegate.close(); super.close(); } diff --git a/src/main/java/jenkins/plugins/logstash/LogstashPayloadProcessor.java b/src/main/java/jenkins/plugins/logstash/LogstashPayloadProcessor.java new file mode 100644 index 00000000..e39f60ea --- /dev/null +++ b/src/main/java/jenkins/plugins/logstash/LogstashPayloadProcessor.java @@ -0,0 +1,50 @@ +/* + * The MIT License + * + * Copyright 2017 Red Hat inc, and individual contributors + * + * 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 jenkins.plugins.logstash; + +import net.sf.json.JSONObject; + +/** + * Interface describing processors of persisted payload. + * + * @author Aleksandar Kostadinov + * @since 1.4.0 + */ +public interface LogstashPayloadProcessor { + /** + * Modifies a JSON payload compatible with the Logstash schema. + * + * @param payload the JSON payload that has been constructed so far. + * @return The formatted JSON object, can be null to ignore this payload. + */ + JSONObject process(JSONObject payload) throws Exception; + + /** + * Finalizes any operations, for example returns cashed lines at end of build. + * + * @return A formatted JSON object, can be null when it has nothing. + */ + JSONObject finish() throws Exception; +} diff --git a/src/main/java/jenkins/plugins/logstash/LogstashScriptProcessor.java b/src/main/java/jenkins/plugins/logstash/LogstashScriptProcessor.java new file mode 100644 index 00000000..a0cf545a --- /dev/null +++ b/src/main/java/jenkins/plugins/logstash/LogstashScriptProcessor.java @@ -0,0 +1,123 @@ +/* + * The MIT License + * + * Copyright 2017 Red Hat inc. and individual contributors + * + * 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 jenkins.plugins.logstash; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.util.LinkedHashMap; + +import javax.annotation.Nonnull; + +import groovy.lang.Binding; + +import net.sf.json.JSONObject; + +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; +import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.Whitelisted; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * This class is handling custom groovy script processing of JSON payload. + * Each call to process executes the script provided in job configuration. + * Script is executed under the same binding each time so that it has ability + * to persist data during build execution if desired by script author. + * When build is finished, script will receive null as the payload and can + * return any cached but non-sent data back for persisting. + * The return value of script is the payload to be persisted unless null. + * + * @author Aleksandar Kostadinov + * @since 1.4.0 + */ +public class LogstashScriptProcessor implements LogstashPayloadProcessor{ + @Nonnull + private final SecureGroovyScript script; + + @Nonnull + private final OutputStream consoleOut; + + /** Groovy binding for script execution */ + @Nonnull + private final Binding binding; + + /** Classloader for script execution */ + @Nonnull + private final ClassLoader classLoader; + + public LogstashScriptProcessor(SecureGroovyScript script, OutputStream consoleOut) { + this.script = script; + this.consoleOut = consoleOut; + + // TODO: should we put variables in the binding like manager, job, etc.? + binding = new Binding(); + binding.setVariable("console", new BuildConsoleWrapper()); + + // not sure what the diff is compared to getClass().getClassLoader(); + final Jenkins jenkins = Jenkins.getInstance(); + classLoader = jenkins.getPluginManager().uberClassLoader; + } + + /** + * Helper method to allow logging to build console. + */ + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") + private void buildLogPrintln(Object o) throws IOException { + consoleOut.write(o.toString().getBytes()); + consoleOut.write("\n".getBytes()); + consoleOut.flush(); + } + + /* + * good examples in: + * https://github.com/jenkinsci/envinject-plugin/blob/master/src/main/java/org/jenkinsci/plugins/envinject/service/EnvInjectEnvVars.java + * https://github.com/jenkinsci/groovy-postbuild-plugin/pull/11/files + */ + @Override + public JSONObject process(JSONObject payload) throws Exception { + binding.setVariable("payload", payload); + script.evaluate(classLoader, binding); + return (JSONObject) binding.getVariable("payload"); + } + + @Override + public JSONObject finish() throws Exception { + buildLogPrintln("Tearing down Script Log Processor.."); + return process(null); + } + + /** + * Helper to allow access from sandboxed script to output messages to console. + */ + private class BuildConsoleWrapper { + @Whitelisted + public void println(Object o) throws IOException { + buildLogPrintln(o); + } + } +} diff --git a/src/main/java/jenkins/plugins/logstash/LogstashWriter.java b/src/main/java/jenkins/plugins/logstash/LogstashWriter.java index ba4d26c9..9f4afee1 100644 --- a/src/main/java/jenkins/plugins/logstash/LogstashWriter.java +++ b/src/main/java/jenkins/plugins/logstash/LogstashWriter.java @@ -42,6 +42,10 @@ import java.util.Date; import java.util.List; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import javax.annotation.CheckForNull; + /** * A writer that wraps all Logstash DAOs. Handles error reporting and per build connection state. * Each call to write (one line or multiple lines) sends a Logstash payload to the DAO. @@ -49,6 +53,7 @@ * * @author Rusty Gerard * @author Liam Newman + * @author Aleksandar Kostadinov * @since 1.0.5 */ public class LogstashWriter { @@ -62,11 +67,23 @@ public class LogstashWriter { private boolean connectionBroken; private Charset charset; + @CheckForNull + private final LogstashPayloadProcessor payloadProcessor; + + public LogstashWriter(Run run, OutputStream error, TaskListener listener) { + this(run, error, listener, null); + } + public LogstashWriter(Run run, OutputStream error, TaskListener listener, Charset charset) { + this(run, error, listener, charset, null); + } + + public LogstashWriter(Run run, OutputStream error, TaskListener listener, Charset charset, LogstashPayloadProcessor payloadProcessor) { this.errorStream = error != null ? error : System.err; this.build = run; this.listener = listener; this.charset = charset; + this.payloadProcessor = payloadProcessor; this.dao = this.getDaoOrNull(); if (this.dao == null) { this.jenkinsUrl = ""; @@ -165,8 +182,36 @@ String getJenkinsUrl() { * Write a list of lines to the indexer as one Logstash payload. */ private void write(List lines) { + write(dao.buildPayload(buildData, jenkinsUrl, lines)); + } + + /** + * Write JSONObject payload to the Logstash indexer. + * @since 1.0.5 + */ + private void write(JSONObject payload) { buildData.updateResult(); - JSONObject payload = dao.buildPayload(buildData, jenkinsUrl, lines); + if (payloadProcessor != null) { + JSONObject processedPayload = payload; + try { + processedPayload = payloadProcessor.process(payload); + } catch (Exception e) { + String msg = ExceptionUtils.getMessage(e) + "\n" + + "[logstash-plugin]: Error in payload processing.\n"; + + logErrorMessage(msg); + } + if (processedPayload != null) { writeRaw(processedPayload); } + } else { + writeRaw(payload); + } + } + + /** + * Write JSONObject payload to the Logstash indexer. + * @since 1.0.5 + */ + private void writeRaw(JSONObject payload) { try { dao.push(payload.toString()); } catch (IOException e) { @@ -203,6 +248,9 @@ private LogstashIndexerDao getDaoOrNull() { /** * Write error message to errorStream and set connectionBroken to true. */ + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") private void logErrorMessage(String msg) { try { connectionBroken = true; @@ -214,4 +262,23 @@ private void logErrorMessage(String msg) { } } + + /** + * Signal payload processor that there will be no more lines + */ + public void close() { + if (payloadProcessor != null) { + JSONObject payload = null; + try { + // calling finish() is mandatory to avoid memory leaks + payload = payloadProcessor.finish(); + } catch (Exception e) { + String msg = ExceptionUtils.getMessage(e) + "\n" + + "[logstash-plugin]: Error with payload processor on finish.\n"; + + logErrorMessage(msg); + } + if (payload != null) writeRaw(payload); + } + } } diff --git a/src/main/java/jenkins/plugins/logstash/persistence/AbstractLogstashIndexerDao.java b/src/main/java/jenkins/plugins/logstash/persistence/AbstractLogstashIndexerDao.java index 0fea9d4d..40b08362 100644 --- a/src/main/java/jenkins/plugins/logstash/persistence/AbstractLogstashIndexerDao.java +++ b/src/main/java/jenkins/plugins/logstash/persistence/AbstractLogstashIndexerDao.java @@ -25,11 +25,14 @@ package jenkins.plugins.logstash.persistence; import java.util.Calendar; +import java.util.Date; import java.util.List; import jenkins.plugins.logstash.LogstashConfiguration; import net.sf.json.JSONObject; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + /** * Abstract data access object for Logstash indexers. * diff --git a/src/main/java/jenkins/plugins/logstash/persistence/BuildData.java b/src/main/java/jenkins/plugins/logstash/persistence/BuildData.java index d96cec29..c091b2db 100644 --- a/src/main/java/jenkins/plugins/logstash/persistence/BuildData.java +++ b/src/main/java/jenkins/plugins/logstash/persistence/BuildData.java @@ -50,12 +50,15 @@ import java.io.IOException; import java.lang.invoke.MethodHandles; import net.sf.json.JSONObject; +import javax.annotation.CheckForNull; import org.apache.commons.lang.StringUtils; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + /** * POJO for mapping build info to JSON. * @@ -93,6 +96,9 @@ public TestData() { this(null); } + @SuppressFBWarnings( + value="URF_UNREAD_FIELD", + justification="TODO: not sure how to fix this") public TestData(Action action) { AbstractTestResultAction testResultAction = null; if (action instanceof AbstractTestResultAction) { @@ -151,7 +157,7 @@ public List getFailedTests() } private String id; - private String result; + @CheckForNull private String result; private String projectName; private String fullProjectName; private String displayName; @@ -256,7 +262,7 @@ private void initData(Run build, Date currentTime) { url = build.getUrl(); buildNum = build.getNumber(); buildDuration = currentTime.getTime() - build.getStartTimeInMillis(); - timestamp = LogstashConfiguration.getInstance().getDateFormatter().format(build.getTimestamp().getTime()); + timestamp = LogstashConfiguration.getInstance().getDateFormatter().format(build.getTime()); updateResult(); } diff --git a/src/main/java/jenkins/plugins/logstash/persistence/ElasticSearchDao.java b/src/main/java/jenkins/plugins/logstash/persistence/ElasticSearchDao.java index d96e077c..c2aa9ee4 100644 --- a/src/main/java/jenkins/plugins/logstash/persistence/ElasticSearchDao.java +++ b/src/main/java/jenkins/plugins/logstash/persistence/ElasticSearchDao.java @@ -52,6 +52,7 @@ import jenkins.plugins.logstash.configuration.ElasticSearch; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Elastic Search Data Access Object. @@ -77,6 +78,9 @@ public ElasticSearchDao(URI uri, String username, String password) { } // Factored for unit testing + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") ElasticSearchDao(HttpClientBuilder factory, URI uri, String username, String password) { if (uri == null) @@ -192,6 +196,9 @@ public void push(String data) throws IOException { } } + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") private String getErrorMessage(CloseableHttpResponse response) { ByteArrayOutputStream byteStream = null; PrintStream stream = null; diff --git a/src/main/java/jenkins/plugins/logstash/persistence/RabbitMqDao.java b/src/main/java/jenkins/plugins/logstash/persistence/RabbitMqDao.java index 16753078..0a231ba5 100644 --- a/src/main/java/jenkins/plugins/logstash/persistence/RabbitMqDao.java +++ b/src/main/java/jenkins/plugins/logstash/persistence/RabbitMqDao.java @@ -33,6 +33,8 @@ import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + /** * RabbitMQ Data Access Object. * @@ -122,6 +124,9 @@ public String getVirtualHost() * @see jenkins.plugins.logstash.persistence.LogstashIndexerDao#push(java.lang.String) */ @Override + @SuppressFBWarnings( + value="DM_DEFAULT_ENCODING", + justification="TODO: not sure how to fix this") public void push(String data) throws IOException { Connection connection = null; Channel channel = null; diff --git a/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/config.jelly b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/config.jelly new file mode 100644 index 00000000..4c7cbf08 --- /dev/null +++ b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/config.jelly @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help-script.html b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help-script.html new file mode 100644 index 00000000..27404758 --- /dev/null +++ b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help-script.html @@ -0,0 +1,37 @@ +

+ With this script you can filter, modify and/or group messages that are sent to the + configured logstash backend. The following variables are available during execution: +

    +
  • payload - JSONObject contaning the payload to be persisted.
  • +
  • console - a class to output messages to build log without persisting them. It also works in a sandbox: console.println("my logged message").
  • +
+

+

+ The script will be executed once per each line of build console output with + the variable payload set to the complete JSON payload to be + sent including message, timestamp, build url, etc. + The line text in particular will be present under the "message" + key as an array with a single string element. +

+

+ Once script completes execution, the payload variable will be read out + of current Binding and passed down to the Logstash backend to be persisted. If + payload is set to null by the script, then nothing will be + persisted. +

+

+ The script within a build will be executed always using the same Binding. + This means that variables can be saved between script invocations. +

+

+ At the end of the build a payload == null will be submitted. You can use this to + output any messages that you have cached for grouping or other purposes. +

+

+ Example script to filter out some console messages by pattern: +


+    if (payload && payload["message"][0] =~ /my needless pattern/) {
+      payload = null
+    }
+  
+

diff --git a/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help.html b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help.html new file mode 100644 index 00000000..d0e26e2b --- /dev/null +++ b/src/main/resources/jenkins/plugins/logstash/LogstashBuildWrapper/help.html @@ -0,0 +1,6 @@ +
+

+ Send individual log lines to Logstash. You can optionally filter and modify them + via groovy script in from advanced configuration. +

+
diff --git a/src/test/java/jenkins/plugins/logstash/LogstashOutputStreamTest.java b/src/test/java/jenkins/plugins/logstash/LogstashOutputStreamTest.java index 13c4774f..e52e08fd 100644 --- a/src/test/java/jenkins/plugins/logstash/LogstashOutputStreamTest.java +++ b/src/test/java/jenkins/plugins/logstash/LogstashOutputStreamTest.java @@ -15,6 +15,7 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; +import org.mockito.InOrder; import org.mockito.junit.MockitoJUnitRunner; @SuppressWarnings("resource") @@ -122,4 +123,17 @@ public void eolSuccessNoDao() throws Exception { assertEquals("Results don't match", msg, buffer.toString()); verify(mockWriter).isConnectionBroken(); } + + @Test + public void writerClosedBeforeDelegate() throws Exception { + ByteArrayOutputStream mockBuffer = Mockito.spy(buffer); + new LogstashOutputStream(mockBuffer, mockWriter).close(); + + InOrder inOrder = Mockito.inOrder(mockBuffer, mockWriter); + inOrder.verify(mockWriter).close(); + inOrder.verify(mockBuffer).close(); + + // Verify results + assertEquals("Results don't match", "", buffer.toString()); + } } diff --git a/src/test/java/jenkins/plugins/logstash/LogstashWriterTest.java b/src/test/java/jenkins/plugins/logstash/LogstashWriterTest.java index 36c54a95..b06a5983 100644 --- a/src/test/java/jenkins/plugins/logstash/LogstashWriterTest.java +++ b/src/test/java/jenkins/plugins/logstash/LogstashWriterTest.java @@ -20,17 +20,22 @@ import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; -import java.util.GregorianCalendar; +import java.util.Date; import java.util.List; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; +import org.mockito.Matchers; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; import org.powermock.api.mockito.PowerMockito; import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.powermock.core.classloader.annotations.PrepareForTest; @@ -46,19 +51,25 @@ import hudson.tasks.test.AbstractTestResultAction; import jenkins.plugins.logstash.persistence.BuildData; import jenkins.plugins.logstash.persistence.LogstashIndexerDao; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; +import org.jvnet.hudson.test.JenkinsRule; import net.sf.json.JSONObject; +import net.sf.json.JSONArray; @RunWith(PowerMockRunner.class) @PowerMockIgnore({"javax.crypto.*"}) @PrepareForTest(LogstashConfiguration.class) public class LogstashWriterTest { + @Rule public JenkinsRule j = new JenkinsRule(); + // Extension of the unit under test that avoids making calls to getInstance() to get the DAO singleton static LogstashWriter createLogstashWriter(final AbstractBuild testBuild, OutputStream error, final String url, final LogstashIndexerDao indexer, - final BuildData data) { - return new LogstashWriter(testBuild, error, null, testBuild.getCharset()) { + final BuildData data, + final LogstashPayloadProcessor processor) { + return new LogstashWriter(testBuild, error, null, testBuild.getCharset(), processor) { @Override LogstashIndexerDao getIndexerDao() { return indexer; @@ -83,6 +94,14 @@ String getJenkinsUrl() { }; } + static LogstashWriter createLogstashWriter(final AbstractBuild testBuild, + OutputStream error, + final String url, + final LogstashIndexerDao indexer, + final BuildData data) { + return createLogstashWriter(testBuild, error, url, indexer, data, null); + } + ByteArrayOutputStream errorBuffer; @Mock LogstashIndexerDao mockDao; @@ -112,7 +131,7 @@ public void before() throws Exception { when(mockBuild.getProject()).thenReturn(mockProject); when(mockBuild.getParent()).thenReturn(mockProject); when(mockBuild.getNumber()).thenReturn(123456); - when(mockBuild.getTimestamp()).thenReturn(new GregorianCalendar()); + when(mockBuild.getTime()).thenReturn(new Date()); when(mockBuild.getRootBuild()).thenReturn(mockBuild); when(mockBuild.getBuildVariables()).thenReturn(Collections.emptyMap()); when(mockBuild.getSensitiveBuildVariables()).thenReturn(Collections.emptySet()); @@ -132,10 +151,16 @@ public void before() throws Exception { when(mockProject.getName()).thenReturn("LogstashWriterTest"); when(mockProject.getFullName()).thenReturn("parent/LogstashWriterTest"); - when(mockDao.buildPayload(any(BuildData.class), anyString(), anyListOf(String.class))) - .thenReturn(JSONObject.fromObject("{\"data\":{},\"message\":[\"test\"],\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}")); - + .thenAnswer(new Answer() { + @Override + public JSONObject answer(InvocationOnMock invocation) { + Object[] args = invocation.getArguments(); + JSONObject json = JSONObject.fromObject("{\"data\":{},\"message\": null,\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); + json.element("message", args[2]); + return json; + } + }); Mockito.doNothing().when(mockDao).push(anyString()); when(mockDao.getDescription()).thenReturn("localhost:8080"); @@ -170,7 +195,7 @@ public void constructorSuccess() throws Exception { verify(mockBuild).getAction(AbstractTestResultAction.class); verify(mockBuild).getExecutor(); verify(mockBuild, times(2)).getNumber(); - verify(mockBuild).getTimestamp(); + verify(mockBuild).getTime(); verify(mockBuild, times(4)).getRootBuild(); verify(mockBuild).getBuildVariables(); verify(mockBuild).getSensitiveBuildVariables(); @@ -266,14 +291,65 @@ public void writeBuildLogSuccess() throws Exception { // Verify results // No error output assertEquals("Results don't match", "", errorBuffer.toString()); - verify(mockBuild).getLog(3); + String lines = JSONArray.fromObject(mockBuild.getLog(3)).toString(); + verify(mockBuild, times(2)).getLog(3); verify(mockBuild).getCharset(); verify(mockDao).buildPayload(eq(mockBuildData), eq("http://my-jenkins-url"), anyListOf(String.class)); - verify(mockDao).push("{\"data\":{},\"message\":[\"test\"],\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); + verify(mockDao).push("{\"data\":{},\"message\":" + lines + ",\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); verify(mockBuildData).updateResult(); } + @Test + public void writeProcessedSuccess() throws Exception { + String goodMsg = "test message"; + String ignoredMsg = "ignored input"; + String scriptString = + "if (payload) {\n" + + " if (payload['message'][0] =~ /" + ignoredMsg + "/) {\n" + + " payload = null\n" + + " } else {\n" + + " console.println('l');\n" + + " }\n" + + " lastPayload = payload\n" + + "} else {\n" + + " console.println('test build console message')\n" + + " payload = lastPayload\n" + + "}"; + + SecureGroovyScript script = new SecureGroovyScript(scriptString, true, null); + script.configuringWithNonKeyItem(); + LogstashScriptProcessor processor = new LogstashScriptProcessor(script, errorBuffer); + LogstashWriter writer = createLogstashWriter(mockBuild, errorBuffer, "http://my-jenkins-url", mockDao, mockBuildData, processor); + errorBuffer.reset(); + + // Unit under test + writer.write(goodMsg); + writer.write(ignoredMsg); + writer.write(goodMsg); + writer.close(); + + // Verify results + // buffer contains 2 lines logged by the script, then standard tear down message and finally test message at close + assertEquals("Results don't match", "l\nl\nTearing down Script Log Processor..\ntest build console message\n", errorBuffer.toString()); + + InOrder inOrder = Mockito.inOrder(mockDao); + + // first message is generated and pushed to DAO + inOrder.verify(mockDao).buildPayload(Matchers.eq(mockBuildData), Matchers.eq("http://my-jenkins-url"), Matchers.anyListOf(String.class)); + inOrder.verify(mockDao).push("{\"data\":{},\"message\":[\"" + goodMsg + "\"],\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); + // now message only generated but filtered out by script thus not pushed to DAO + inOrder.verify(mockDao, times(2)).buildPayload(Matchers.eq(mockBuildData), Matchers.eq("http://my-jenkins-url"), Matchers.anyListOf(String.class)); + // the message at close time is generated by the script so no call to DAO for that + inOrder.verify(mockDao, times(2)).push("{\"data\":{},\"message\":[\"" + goodMsg + "\"],\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); + + // when creating the mock LogstashWriter by `createLogstashWriter` + verify(mockBuild).getCharset(); + + // building payload + verify(mockBuildData, times(3)).updateResult(); + } + @Test public void writeSuccessConnectionBroken() throws Exception { Mockito.doNothing().doThrow(new IOException("BOOM!")).doNothing().when(mockDao).push(anyString()); @@ -337,11 +413,12 @@ public void writeBuildLogGetLogError() throws Exception { List expectedErrorLines = Arrays.asList( "[logstash-plugin]: Unable to serialize log data.", "java.io.IOException: Unable to read log file"); - verify(mockDao).push("{\"data\":{},\"message\":[\"test\"],\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); verify(mockDao).buildPayload(eq(mockBuildData), eq("http://my-jenkins-url"), logLinesCaptor.capture()); verify(mockBuildData).updateResult(); List actualLogLines = logLinesCaptor.getValue(); + String linesJSON = JSONArray.fromObject(actualLogLines).toString(); + verify(mockDao).push("{\"data\":{},\"message\":" + linesJSON + ",\"source\":\"jenkins\",\"source_host\":\"http://my-jenkins-url\",\"@version\":1}"); assertThat("The exception was not sent to Logstash", actualLogLines.get(0), containsString(expectedErrorLines.get(0))); assertThat("The exception was not sent to Logstash", actualLogLines.get(1), containsString(expectedErrorLines.get(1))); diff --git a/src/test/java/jenkins/plugins/logstash/persistence/BuildDataTest.java b/src/test/java/jenkins/plugins/logstash/persistence/BuildDataTest.java index 2a9b8e60..9a0151e6 100644 --- a/src/test/java/jenkins/plugins/logstash/persistence/BuildDataTest.java +++ b/src/test/java/jenkins/plugins/logstash/persistence/BuildDataTest.java @@ -9,7 +9,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; -import java.util.GregorianCalendar; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -87,7 +86,7 @@ public void before() throws Exception { when(mockBuild.getDescription()).thenReturn("Mock project for testing BuildData"); when(mockBuild.getParent()).thenReturn(mockProject); when(mockBuild.getNumber()).thenReturn(123456); - when(mockBuild.getTimestamp()).thenReturn(new GregorianCalendar()); + when(mockBuild.getTime()).thenReturn(new Date()); when(mockBuild.getRootBuild()).thenReturn(mockBuild); when(mockBuild.getBuildVariables()).thenReturn(Collections.emptyMap()); when(mockBuild.getSensitiveBuildVariables()).thenReturn(Collections.emptySet()); @@ -143,7 +142,7 @@ private void verifyMocks() throws Exception verify(mockBuild).getAction(AbstractTestResultAction.class); verify(mockBuild).getExecutor(); verify(mockBuild).getNumber(); - verify(mockBuild).getTimestamp(); + verify(mockBuild).getTime(); verify(mockBuild, times(4)).getRootBuild(); verify(mockBuild).getBuildVariables(); verify(mockBuild).getSensitiveBuildVariables();