diff --git a/README.md b/README.md index 51651327..cde4472a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,17 @@ Plugin that defines Pipeline API. A component of [Pipeline Plugin](https://plugins.jenkins.io/workflow-aggregator). +# JEP-210: External log storage for Pipeline +## Implementation +This plugin provides APIs for [https://github.com/jenkinsci/jep/tree/master/jep/210](JEP-210), which allow plugins to take over Pipeline build logging. +The default logging implementation is the `@Extension LogStorageFactoryDescriptor` with the highest `ordinal` value that implements `LogStorageFactoryDescriptor.getDefaultInstance`. +You can override the default implementation by configuring a logger explicitly under "Pipeline logger" on the Jenkins system configuration page. + +## Multiple loggers +In some cases, you may want to use a logging implementation that sends logs to an external system, while also preserving logs in Jenkins for other use cases. +You can accomplish by configuring the "Multiple loggers" implementation in the "Pipeline logger" section on the Jenkins system configuration page. +This implementation allows you to select a "Primary" logger that handles reads and writes, as well as a "Secondary" logger which receives copies of all writes, similarly to the Unix `tee` command. + # Changelog * For new versions, see [GitHub Releases](https://github.com/jenkinsci/workflow-api-plugin/releases) diff --git a/pom.xml b/pom.xml index 89405fb0..c9035900 100644 --- a/pom.xml +++ b/pom.xml @@ -138,5 +138,10 @@ apache-httpcomponents-client-4-api test + + io.jenkins.configuration-as-code + test-harness + test + diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/FileLogStorageFactory.java b/src/main/java/org/jenkinsci/plugins/workflow/log/FileLogStorageFactory.java new file mode 100644 index 00000000..4149798d --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/FileLogStorageFactory.java @@ -0,0 +1,37 @@ +package org.jenkinsci.plugins.workflow.log; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.Descriptor; +import java.io.File; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.stapler.DataBoundConstructor; + +@Restricted(Beta.class) +public class FileLogStorageFactory implements LogStorageFactory { + + @DataBoundConstructor + public FileLogStorageFactory() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + return FileLogStorage.forFile(new File(b.getRootDir(), "log")); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @Extension + @Symbol("file") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "Standard file logger"; + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorage.java b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorage.java index 0b0707b8..531849d1 100644 --- a/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorage.java +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorage.java @@ -25,7 +25,6 @@ package org.jenkinsci.plugins.workflow.log; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import hudson.ExtensionList; import hudson.console.AnnotatedLargeText; import hudson.console.ConsoleAnnotationOutputStream; import hudson.model.BuildListener; @@ -39,6 +38,7 @@ import java.util.logging.Logger; import edu.umd.cs.findbugs.annotations.NonNull; import org.jenkinsci.plugins.workflow.actions.LogAction; +import org.jenkinsci.plugins.workflow.log.configuration.PipelineLoggingGlobalConfiguration; import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.jenkinsci.plugins.workflow.graph.FlowNode; import org.jenkinsci.plugins.workflow.steps.StepContext; @@ -160,8 +160,9 @@ public interface LogStorage { */ static @NonNull LogStorage of(@NonNull FlowExecutionOwner b) { try { - for (LogStorageFactory factory : ExtensionList.lookup(LogStorageFactory.class)) { - LogStorage storage = factory.forBuild(b); + PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get(); + if (config.getFactoryOrDefault() != null) { + LogStorage storage = config.getFactoryOrDefault().forBuild(b); if (storage != null) { // Pending integration with JEP-207 / JEP-212, this choice is not persisted. return storage; diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactory.java b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactory.java index 4b3f4c6d..88dcaf7a 100644 --- a/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactory.java +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactory.java @@ -24,9 +24,11 @@ package org.jenkinsci.plugins.workflow.log; -import hudson.ExtensionPoint; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.Describable; +import java.util.List; +import jenkins.model.Jenkins; import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.Beta; @@ -35,13 +37,33 @@ * Factory interface for {@link LogStorage}. */ @Restricted(Beta.class) -public interface LogStorageFactory extends ExtensionPoint { +public interface LogStorageFactory extends Describable { /** - * Checks whether we should handle a given build. + * When the current factory has been configured or is considered a default factory {@link #getDefaultFactory()}, returns the expected log storage instance to handle the build. * @param b a build about to start - * @return a mechanism for handling this build, or null to fall back to the next implementation or the default + * @return a mechanism for handling this build, see {@link LogStorage#of(FlowExecutionOwner)} */ @CheckForNull LogStorage forBuild(@NonNull FlowExecutionOwner b); + default LogStorageFactoryDescriptor getDescriptor() { + return (LogStorageFactoryDescriptor) Jenkins.get().getDescriptorOrDie(this.getClass()); + } + + static List> all() { + return Jenkins.get().getDescriptorList(LogStorageFactory.class); + } + + /** + * Returns the default {@link LogStorageFactory} based on the descriptor {@code @Extension#ordinal} order and the {@link LogStorageFactoryDescriptor#getDefaultInstance()} implmentations. + */ + static LogStorageFactory getDefaultFactory() { + for (LogStorageFactoryDescriptor descriptor : all()) { + var instance = descriptor.getDefaultInstance(); + if (instance != null) { + return instance; + } + } + return new FileLogStorageFactory(); + } } diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactoryDescriptor.java b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactoryDescriptor.java new file mode 100644 index 00000000..aabc5337 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/LogStorageFactoryDescriptor.java @@ -0,0 +1,29 @@ +package org.jenkinsci.plugins.workflow.log; + +import hudson.model.Descriptor; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +@Restricted(Beta.class) +public abstract class LogStorageFactoryDescriptor extends Descriptor { + + /** + * Indicates whether the factory supports being used in read/write mode (e.g. as a top-level logger, or as a primary for {@link org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory}) + */ + public boolean isReadWrite() { + return true; + } + /** + * Indicates whether the factory supports being used in write-only mode (as a secondary for {@link org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory}). + */ + public boolean isWriteOnly() { + return true; + } + + /** + * Allow to define the default factory instance to use if no configuration exists + */ + public LogStorageFactory getDefaultInstance() { + return null; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration.java b/src/main/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration.java new file mode 100644 index 00000000..4f4a7051 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration.java @@ -0,0 +1,84 @@ +package org.jenkinsci.plugins.workflow.log.configuration; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.ExtensionList; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; +import net.sf.json.JSONObject; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.StaplerRequest2; + +@Extension +@Symbol("pipelineLogging") +@Restricted(Beta.class) +public class PipelineLoggingGlobalConfiguration extends GlobalConfiguration { + private static final Logger LOGGER = Logger.getLogger(PipelineLoggingGlobalConfiguration.class.getName()); + private LogStorageFactory factory; + + public PipelineLoggingGlobalConfiguration() { + load(); + } + + /** + * For configuration only. Use {@link #getFactoryOrDefault()} instead. + */ + @Restricted(NoExternalUse.class) + public LogStorageFactory getFactory() { + return factory; + } + + @DataBoundSetter + public void setFactory(LogStorageFactory factory) { + this.factory = factory; + save(); + } + + @Override + public boolean configure(StaplerRequest2 req, JSONObject json) throws FormException { + this.factory = null; + return super.configure(req, json); + } + + public LogStorageFactory getFactoryOrDefault() { + if (factory == null) { + return LogStorageFactory.getDefaultFactory(); + } + return factory; + } + + public List> getLogStorageFactoryDescriptors() { + List> result = new ArrayList<>(); + result.add(null); // offer the option to use the default factory without any explicit configuration + result.addAll(getFilteredLogStorageFactoryDescriptors()); + return result; + } + + private List> getFilteredLogStorageFactoryDescriptors() { + return LogStorageFactory.all().stream() + .filter(LogStorageFactoryDescriptor::isReadWrite) + .toList(); + } + + public LogStorageFactoryDescriptor getDefaultFactoryDescriptor() { + return LogStorageFactory.getDefaultFactory().getDescriptor(); + } + + public String getDefaultFactoryPlugin() { + var pluginWrapper = Jenkins.get().getPluginManager().whichPlugin(LogStorageFactory.getDefaultFactory().getClass()); + return pluginWrapper != null ? pluginWrapper.getShortName() : "unknown"; + } + + public static PipelineLoggingGlobalConfiguration get() { + return ExtensionList.lookupSingleton(PipelineLoggingGlobalConfiguration.class); + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java new file mode 100644 index 00000000..0cd0d7f9 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java @@ -0,0 +1,69 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.BuildListener; +import hudson.model.TaskListener; +import java.io.OutputStream; +import java.io.Serial; +import java.util.List; +import org.jenkinsci.plugins.workflow.log.OutputStreamTaskListener; + +class TeeBuildListener extends OutputStreamTaskListener.Default + implements BuildListener, OutputStreamTaskListener, AutoCloseable { + + @Serial + private static final long serialVersionUID = 1L; + + private final TaskListener primary; + + private final List secondaries; + + private transient OutputStream outputStream; + + TeeBuildListener(TaskListener primary, TaskListener... secondaries) { + this.primary = primary; + this.secondaries = List.of(secondaries); + } + + @NonNull + @Override + public synchronized OutputStream getOutputStream() { + if (outputStream == null) { + outputStream = new TeeOutputStream( + OutputStreamTaskListener.getOutputStream(primary), + secondaries.stream() + .map(OutputStreamTaskListener::getOutputStream) + .toArray(OutputStream[]::new)); + } + return outputStream; + } + + @Override + public void close() throws Exception { + getLogger().close(); + Exception exception = null; + if (primary instanceof AutoCloseable) { + try { + ((AutoCloseable) primary).close(); + } catch (Exception e) { + exception = e; + } + } + for (TaskListener secondary : secondaries) { + if (secondary instanceof AutoCloseable) { + try { + ((AutoCloseable) secondary).close(); + } catch (Exception e) { + if (exception == null) { + exception = e; + } else { + exception.addSuppressed(e); + } + } + } + } + if (exception != null) { + throw exception; + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorage.java b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorage.java new file mode 100644 index 00000000..e65f4bfc --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorage.java @@ -0,0 +1,73 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.console.AnnotatedLargeText; +import hudson.model.BuildListener; +import hudson.model.TaskListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Advancaed implementation of log storage allowing a primary log storage for read and write; and multiple secondary log storages for writes. + * This behaves as a tee execution. + */ +@Restricted(Beta.class) +public class TeeLogStorage implements LogStorage { + + LogStorage primary; + List secondaries = List.of(); + + /** + * Log storage allowing a primary for read/write and multiple secondaries for write only + * @param primary primary log storage used for read and write + * @param secondaries secondary log storages used for write + */ + public TeeLogStorage(@NonNull LogStorage primary, LogStorage... secondaries) { + this.primary = primary; + if (secondaries != null) { + this.secondaries = + Arrays.stream(secondaries).filter(Objects::nonNull).toList(); + } + } + + @NonNull + @Override + public BuildListener overallListener() throws IOException, InterruptedException { + List secondaryListeners = new ArrayList<>(); + for (LogStorage secondary : secondaries) { + secondaryListeners.add(secondary.overallListener()); + } + return new TeeBuildListener(primary.overallListener(), secondaryListeners.toArray(BuildListener[]::new)); + } + + @NonNull + @Override + public TaskListener nodeListener(@NonNull FlowNode node) throws IOException, InterruptedException { + List secondaryListeners = new ArrayList<>(); + for (LogStorage secondary : secondaries) { + secondaryListeners.add(secondary.nodeListener(node)); + } + return new TeeBuildListener(primary.nodeListener(node), secondaryListeners.toArray(TaskListener[]::new)); + } + + @NonNull + @Override + public AnnotatedLargeText overallLog( + @NonNull FlowExecutionOwner.Executable build, boolean complete) { + return primary.overallLog(build, complete); + } + + @NonNull + @Override + public AnnotatedLargeText stepLog(@NonNull FlowNode node, boolean complete) { + return primary.stepLog(node, complete); + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory.java b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory.java new file mode 100644 index 00000000..e7f04760 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory.java @@ -0,0 +1,105 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.util.List; +import java.util.logging.Logger; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.kohsuke.stapler.DataBoundConstructor; + +/*** + * Allows a {@link LogStorage} to be teeable, meaning it can be configured as a primary or secondary log storage. + * See {@link TeeLogStorage}. + */ +public class TeeLogStorageFactory implements LogStorageFactory { + + private static final Logger LOGGER = Logger.getLogger(TeeLogStorageFactory.class.getName()); + + private final LogStorageFactory primary; + + private final LogStorageFactory secondary; + + @DataBoundConstructor + public TeeLogStorageFactory(LogStorageFactory primary, LogStorageFactory secondary) { + if (primary == null) { + throw new IllegalArgumentException("Primary Pipeline logger cannot be null"); + } + if (secondary == null) { + throw new IllegalArgumentException("Secondary Pipeline logger cannot be null"); + } + if (primary.getClass() == secondary.getClass()) { + throw new IllegalArgumentException( + "Primary and secondary Pipeline loggers must be distinct, but both were " + primary.getClass()); + } + this.primary = primary; + this.secondary = secondary; + } + + @NonNull + public LogStorageFactory getPrimary() { + return primary; + } + + @NonNull + public LogStorageFactory getSecondary() { + return secondary; + } + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + var primaryLogStorage = this.primary.forBuild(b); + if (primaryLogStorage == null) { + return new BrokenLogStorage(new IllegalArgumentException(String.format( + "The primary Pipeline logger of type %s returned null", + primary.getClass().getName()))); + } + var secondaryLogStorage = this.secondary.forBuild(b); + if (secondaryLogStorage == null) { + return new BrokenLogStorage(new IllegalArgumentException(String.format( + "The secondary Pipeline logger of type %s returned null", + primary.getClass().getName()))); + } + return new TeeLogStorage(primaryLogStorage, secondaryLogStorage); + } + + @Extension + @Symbol("tee") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "Multiple loggers"; + } + + @Override + public boolean isWriteOnly() { + return false; + } + + /** + * Return the selectable descriptors for the primary dropdown in the UI. + * Filter on the {@link LogStorageFactoryDescriptor#isReadWrite()} implementations + */ + public List> getPrimaryDescriptors() { + return LogStorageFactory.all().stream() + .filter(LogStorageFactoryDescriptor::isReadWrite) + .filter(d -> !(d instanceof TeeLogStorageFactory.DescriptorImpl)) + .toList(); + } + + /** + * Return the selectable descriptors for the secondary dropdown in the UI. + * Filter on the {@link LogStorageFactoryDescriptor#isWriteOnly()} implementations + */ + public List> getSecondaryDescriptors() { + return LogStorageFactory.all().stream() + .filter(LogStorageFactoryDescriptor::isWriteOnly) + .toList(); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStream.java b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStream.java new file mode 100644 index 00000000..6994a1db --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStream.java @@ -0,0 +1,64 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +class TeeOutputStream extends OutputStream { + + final OutputStream primary; + final List secondaries; + + TeeOutputStream(OutputStream primary, OutputStream[] secondaries) { + this.primary = primary; + this.secondaries = List.of(secondaries); + } + + @Override + public void write(int b) throws IOException { + handleAction(outputStream -> outputStream.write(b)); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + handleAction(outputStream -> outputStream.write(b, off, len)); + } + + @Override + public void flush() throws IOException { + handleAction(OutputStream::flush); + } + + @Override + public void close() throws IOException { + handleAction(OutputStream::close); + } + + @FunctionalInterface + private interface ActionFunction { + void apply(T t) throws IOException; + } + + private void handleAction(ActionFunction function) throws IOException { + IOException exception = null; + try { + function.apply(primary); + } catch (IOException e) { + exception = e; + } + for (OutputStream secondary : secondaries) { + try { + function.apply(secondary); + } catch (IOException e) { + if (exception == null) { + exception = e; + } else { + exception.addSuppressed(e); + } + } + } + if (exception != null) { + throw exception; + } + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly new file mode 100644 index 00000000..cace6a24 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly @@ -0,0 +1,12 @@ + + + + + ${%description(descriptor.getDefaultFactoryDescriptor().getDisplayName(), descriptor.getDefaultFactoryPlugin())} + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties new file mode 100644 index 00000000..321c7e63 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties @@ -0,0 +1 @@ +description=Specify the Pipeline logger. The default logger is "{0}" (from {1}). diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly new file mode 100644 index 00000000..bee84b7a --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly @@ -0,0 +1,16 @@ + + + + + ${%description} + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties new file mode 100644 index 00000000..76761add --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties @@ -0,0 +1 @@ +description=Specify the primary and secondary Pipeline logger. diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html new file mode 100644 index 00000000..e67da572 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html @@ -0,0 +1,3 @@ +

+ The primary logger is used to read and write Pipeline build logs. +

\ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html new file mode 100644 index 00000000..c0c1cf35 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html @@ -0,0 +1,3 @@ +

+ If a secondary logger is selected, Pipeline build logs are duplicated as they are written and sent to this logger. This logger is never used to read logs. +

\ No newline at end of file diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java b/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java index a8cabbb8..a235eb71 100644 --- a/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java @@ -230,9 +230,10 @@ private static final class GC extends MasterToSlaveCallable { */ @Test public void mangledLines() throws Exception { Random r = new Random(); - BiFunction thread = (c, l) -> new Thread(() -> { + BiFunction thread = (alphabet, l) -> new Thread(() -> { + var len = alphabet.length(); for (int i = 0; i < 1000; i++) { - l.getLogger().print(c); + l.getLogger().print(alphabet.charAt(i % len)); if (r.nextDouble() < 0.1) { l.getLogger().println(); } @@ -247,9 +248,9 @@ private static final class GC extends MasterToSlaveCallable { }); List threads = new ArrayList<>(); LogStorage ls = createStorage(); - threads.add(thread.apply('.', ls.overallListener())); - threads.add(thread.apply('1', ls.nodeListener(new MockNode("1")))); - threads.add(thread.apply('2', ls.nodeListener(new MockNode("2")))); + threads.add(thread.apply("0123456789", ls.overallListener())); + threads.add(thread.apply("abcdefghijklmnopqrstuvwxyz", ls.nodeListener(new MockNode("1")))); + threads.add(thread.apply("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ls.nodeListener(new MockNode("2")))); threads.forEach(Thread::start); threads.forEach(t -> { try { @@ -271,6 +272,7 @@ private static final class GC extends MasterToSlaveCallable { // assertLength("2", pos); // assertStepLog("2", pos, "", true); text("2").writeRawLogTo(0, new NullOutputStream()); + close(ls.overallListener()); } @SuppressWarnings("deprecation") diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java new file mode 100644 index 00000000..1838192a --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java @@ -0,0 +1,174 @@ +package org.jenkinsci.plugins.workflow.log.configuration; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jenkins.plugins.casc.ConfiguratorException; +import io.jenkins.plugins.casc.misc.ConfiguredWithCode; +import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; +import java.io.File; +import org.htmlunit.html.HtmlForm; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.FileLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2; +import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage; +import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorage; +import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.DataBoundConstructor; + +public class PipelineLoggingGlobalConfigurationJCasCTest { + + @Rule + public JenkinsConfiguredWithCodeRule r = new JenkinsConfiguredWithCodeRule(); + + @Test + public void default_factory() throws Throwable { + PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get(); + assertThat(config.getFactory(), nullValue()); + + // check build happens with the default file log storage + WorkflowJob workflowJob = r.createProject(WorkflowJob.class); + workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true)); + + r.buildAndAssertSuccess(workflowJob); + assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(FileLogStorage.class)); + + checkNoPipelineLoggingCasCConfiguration(); + } + + @Test + public void custom_default_factory() throws Throwable { + PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get(); + assertThat(config.getFactory(), nullValue()); + + // check build happens with the default file log storage + WorkflowJob workflowJob = r.createProject(WorkflowJob.class); + workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true)); + + r.buildAndAssertSuccess(workflowJob); + assertThat( + LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), + instanceOf(RemoteCustomFileLogStorage.class)); + + checkNoPipelineLoggingCasCConfiguration(); + } + + @Test + public void custom_default_factory_ui() throws Throwable { + HtmlForm form = form = r.createWebClient().goTo("configure").getFormByName("config"); + // not sure if there's a simpler way to get the select, as no `name` or `id` attribute is available + var selectedText = + form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::select/option[@selected]/text()"); + assertThat(selectedText, nullValue()); + var description = form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::div[@colspan]/text()"); + assertThat(description.toString(), containsString("My custom log")); + checkNoPipelineLoggingCasCConfiguration(); + } + + @Test + @ConfiguredWithCode("jcasc_smokes.yaml") + public void smokes() throws Throwable { + PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get(); + assertThat(config.getFactory(), instanceOf(TeeLogStorageFactory.class)); + var factory = (TeeLogStorageFactory) config.getFactory(); + assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class)); + assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class)); + + WorkflowJob workflowJob = r.createProject(WorkflowJob.class); + workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true)); + + r.buildAndAssertSuccess(workflowJob); + assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(TeeLogStorage.class)); + + String content = r.exportToString(true); + assertThat(content, containsString("pipelineLogging")); + assertThat(content, containsString("tee")); + assertThat(content, containsString("logMock1")); + assertThat(content, containsString("logMock2")); + } + + @Test + @ConfiguredWithCode( + value = "jcasc_primary-only.yaml", + expected = ConfiguratorException.class, + message = + "Arguments: [org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1, null].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory") + public void primary_only() throws Throwable {} + + @Test + @ConfiguredWithCode( + value = "jcasc_secondary-only.yaml", + expected = ConfiguratorException.class, + message = + "Arguments: [null, org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory") + public void secondary_only() throws Throwable {} + + @Test + @ConfiguredWithCode( + value = "jcasc_empty.yaml", + expected = ConfiguratorException.class, + message = + "Arguments: [null, null].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory") + public void empty() throws Throwable {} + + @Test + @ConfiguredWithCode( + value = "jcasc_duplicate.yaml", + expected = ConfiguratorException.class, + message = + "Arguments: [org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1, org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory") + public void duplicate() throws Throwable {} + + private void checkNoPipelineLoggingCasCConfiguration() throws Exception { + r.configRoundtrip(); + // check exported CasC + String content = r.exportToString(true); + assertThat(content, not(containsString("pipelineLogging"))); + } + + public static class LogStorageFactoryCustom implements LogStorageFactory { + @DataBoundConstructor + public LogStorageFactoryCustom() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + File file = new File(b.getRootDir(), "custom-log"); + return RemoteCustomFileLogStorage.forFile(file); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @TestExtension({"custom_default_factory", "custom_default_factory_ui"}) + @Symbol("logCustom") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "My custom log"; + } + + @Override + public LogStorageFactory getDefaultInstance() { + return new LogStorageFactoryCustom(); + } + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java new file mode 100644 index 00000000..4cd836fb --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java @@ -0,0 +1,32 @@ +package org.jenkinsci.plugins.workflow.log.configuration; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; + +import io.jenkins.plugins.casc.misc.junit.jupiter.AbstractRoundTripTest; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2; +import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory; +import org.jvnet.hudson.test.JenkinsRule; + +public class PipelineLoggingGlobalConfigurationJCascRoundTripTest extends AbstractRoundTripTest { + + @Override + protected String configResource() { + return "jcasc_smokes.yaml"; + } + + @Override + protected void assertConfiguredAsExpected(JenkinsRule j, String configContent) { + PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get(); + assertThat(config.getFactory(), instanceOf(TeeLogStorageFactory.class)); + var factory = (TeeLogStorageFactory) config.getFactory(); + assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class)); + assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class)); + } + + @Override + protected String stringInLogExpected() { + return "pipelineLogging"; + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java new file mode 100644 index 00000000..104e7221 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java @@ -0,0 +1,145 @@ +package org.jenkinsci.plugins.workflow.log.configuration; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThrows; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.File; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.FileLogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1; +import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2; +import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage; +import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsSessionRule; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.DataBoundConstructor; + +public class PipelineLoggingGlobalConfigurationTest { + + @Rule + public JenkinsSessionRule sessions = new JenkinsSessionRule(); + + @Test + public void default_factory() throws Throwable { + sessions.then(r -> { + assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue()); + assertThat( + PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(), + instanceOf(FileLogStorageFactory.class)); + r.configRoundtrip(); + }); + sessions.then(r -> { + assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue()); + assertThat( + PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(), + instanceOf(FileLogStorageFactory.class)); + }); + } + + @Test + public void custom_default_factory() throws Throwable { + sessions.then(r -> { + assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue()); + assertThat( + PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(), + instanceOf(LogStorageFactoryCustom.class)); + r.configRoundtrip(); + }); + sessions.then(r -> { + assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue()); + assertThat( + PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(), + instanceOf(LogStorageFactoryCustom.class)); + }); + } + + @Test + public void teeLogStorageFactory() throws Throwable { + sessions.then(r -> { + TeeLogStorageFactory factory = + new TeeLogStorageFactory(new LogStorageFactoryMock1(), new LogStorageFactoryMock2()); + PipelineLoggingGlobalConfiguration.get().setFactory(factory); + r.configRoundtrip(); + }); + sessions.then(r -> { + var configuration = PipelineLoggingGlobalConfiguration.get(); + assertThat(configuration.getFactory(), instanceOf(TeeLogStorageFactory.class)); + var factory = (TeeLogStorageFactory) configuration.getFactory(); + assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class)); + assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class)); + }); + } + + @Test + public void teeLogStorageFactory_primary_null() throws Throwable { + sessions.then(r -> { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + new TeeLogStorageFactory(null, new LogStorageFactoryMock2()); + }); + assertThat(exception.getMessage(), is("Primary Pipeline logger cannot be null")); + r.configRoundtrip(); + }); + sessions.then(r -> { + var configuration = PipelineLoggingGlobalConfiguration.get(); + assertThat(configuration.getFactory(), nullValue()); + assertThat(configuration.getFactoryOrDefault(), instanceOf(FileLogStorageFactory.class)); + }); + } + + @Test + public void teeLogStorageFactory_secondary_null() throws Throwable { + sessions.then(r -> { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + new TeeLogStorageFactory(new LogStorageFactoryMock1(), null); + }); + assertThat(exception.getMessage(), is("Secondary Pipeline logger cannot be null")); + r.configRoundtrip(); + }); + sessions.then(r -> { + var configuration = PipelineLoggingGlobalConfiguration.get(); + assertThat(configuration.getFactory(), nullValue()); + assertThat(configuration.getFactoryOrDefault(), instanceOf(FileLogStorageFactory.class)); + }); + } + + public static class LogStorageFactoryCustom implements LogStorageFactory { + @DataBoundConstructor + public LogStorageFactoryCustom() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + File file = new File(b.getRootDir(), "custom-log"); + return RemoteCustomFileLogStorage.forFile(file); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @TestExtension("custom_default_factory") + @Symbol("logCustom") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "My custom log"; + } + + @Override + public LogStorageFactory getDefaultInstance() { + return new LogStorageFactoryCustom(); + } + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java new file mode 100644 index 00000000..90fbb00e --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java @@ -0,0 +1,40 @@ +package org.jenkinsci.plugins.workflow.log.configuration.mock; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.io.File; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.FileLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage; +import org.kohsuke.stapler.DataBoundConstructor; + +public class LogStorageFactoryMock1 implements LogStorageFactory { + + @DataBoundConstructor + public LogStorageFactoryMock1() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + File file = new File(b.getRootDir(), "log-mock1"); + return FileLogStorage.forFile(file); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @Extension(ordinal = -2) + @Symbol("logMock1") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "A mock Pipeline logger"; + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java new file mode 100644 index 00000000..ddd88b24 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java @@ -0,0 +1,39 @@ +package org.jenkinsci.plugins.workflow.log.configuration.mock; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.io.File; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.FileLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.kohsuke.stapler.DataBoundConstructor; + +public class LogStorageFactoryMock2 implements LogStorageFactory { + + @DataBoundConstructor + public LogStorageFactoryMock2() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + File file = new File(b.getRootDir(), "log-mock2"); + return FileLogStorage.forFile(file); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @Extension(ordinal = -1) + @Symbol("logMock2") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "Another mock Pipeline logger"; + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java new file mode 100644 index 00000000..c260ea37 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java @@ -0,0 +1,184 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.console.AnnotatedLargeText; +import hudson.console.LineTransformationOutputStream; +import hudson.model.BuildListener; +import hudson.model.TaskListener; +import hudson.remoting.RemoteOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.OutputStreamTaskListener; +import org.jenkinsci.remoting.SerializableOnlyOverRemoting; + +/** + * LogStorage acting as a FileLogStorage without the index. + * When serialized over remoting, transorms all the characters to uppercase. + */ +public class RemoteCustomFileLogStorage implements LogStorage { + private static final Logger LOGGER = Logger.getLogger(RemoteCustomFileLogStorage.class.getName()); + + private final File log; + private OutputStream out; + private OutputStreamSupplier supplier; + + private static final Map openStorages = + Collections.synchronizedMap(new HashMap<>()); + + public static synchronized LogStorage forFile(File log) { + return forFile(log, null); + } + + public static synchronized LogStorage forFile(File log, OutputStreamSupplier supplier) { + return openStorages.computeIfAbsent(log, key -> new RemoteCustomFileLogStorage(key, supplier)); + } + + private RemoteCustomFileLogStorage(File log) { + this(log, null); + } + + @FunctionalInterface + public interface OutputStreamSupplier{ + OutputStream apply() throws IOException; + } + + private RemoteCustomFileLogStorage(File log, OutputStreamSupplier supplier) { + this.log = log; + this.supplier = supplier; + } + + private synchronized void open() throws IOException { + if (this.supplier == null) { + this.out = new FileOutputStream(log, true); + return; + } + this.out = supplier.apply(); + } + + @Override + @NonNull + public BuildListener overallListener() throws IOException, InterruptedException { + return new MyListener(new Writer(null)); + } + + @Override + @NonNull + public TaskListener nodeListener(FlowNode node) throws IOException, InterruptedException { + // TODO: Does not actually handle step logs differently. + return new MyListener(new Writer(node.getId())); + } + + private static final class MyListener extends OutputStreamTaskListener.Default implements BuildListener, Closeable { + private static final long serialVersionUID = 1; + private final OutputStream listenerOut; + + public MyListener(OutputStream listenerOut) { + this.listenerOut = listenerOut; + } + + @Override + @NonNull + public OutputStream getOutputStream() { + return listenerOut; + } + + @Override + public void close() throws IOException { + getLogger().close(); + } + + private Object writeReplace() throws IOException { + return new MyListener(new UppercaseWriter(listenerOut)); + } + } + + private final class Writer extends OutputStream implements SerializableOnlyOverRemoting { + private final String node; + + public Writer(String node) throws IOException { + this.node = node; + open(); + } + + @Override + public void write(int b) throws IOException { + synchronized (RemoteCustomFileLogStorage.this) { + out.write(b); + } + } + + @Override + public void write(@NonNull byte[] b, int off, int len) throws IOException { + synchronized (RemoteCustomFileLogStorage.this) { + out.write(b, off, len); + } + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + if (node == null) { + openStorages.remove(log); + out.close(); + } + } + } + + private static final class UppercaseWriter extends LineTransformationOutputStream + implements SerializableOnlyOverRemoting { + private static final long serialVersionUID = 1L; + private final RemoteOutputStream out; + + public UppercaseWriter(OutputStream out) { + this.out = new RemoteOutputStream(out); + } + + @Override + protected void eol(byte[] b, int len) throws IOException { + String line = new String(b, 0, len, StandardCharsets.UTF_8); + var uppercaseLine = line.toUpperCase(Locale.ENGLISH); + var uppercaseBytes = uppercaseLine.getBytes(StandardCharsets.UTF_8); + out.write(uppercaseBytes); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + out.close(); + } + } + + @NonNull + @Override + public AnnotatedLargeText overallLog( + @NonNull FlowExecutionOwner.Executable build, boolean complete) { + return new AnnotatedLargeText<>(log, StandardCharsets.UTF_8, complete, build); + } + + @NonNull + @Override + public AnnotatedLargeText stepLog(@NonNull FlowNode node, boolean complete) { + // TODO: Does not actually handle step logs differently. + return new AnnotatedLargeText<>(log, StandardCharsets.UTF_8, complete, node); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java new file mode 100644 index 00000000..20052a44 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java @@ -0,0 +1,38 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.io.File; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; +import org.jenkinsci.plugins.workflow.log.BrokenLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor; +import org.kohsuke.stapler.DataBoundConstructor; + +public class RemoteCustomFileLogStorageFactory implements LogStorageFactory { + + @DataBoundConstructor + public RemoteCustomFileLogStorageFactory() {} + + @Override + public LogStorage forBuild(@NonNull FlowExecutionOwner b) { + try { + File file = new File(b.getRootDir(), "custom-log"); + return RemoteCustomFileLogStorage.forFile(file); + } catch (Exception x) { + return new BrokenLogStorage(x); + } + } + + @Extension + @Symbol("customLog") + public static final class DescriptorImpl extends LogStorageFactoryDescriptor { + @NonNull + @Override + public String getDisplayName() { + return "Remote custom file logger"; + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java new file mode 100644 index 00000000..b19e9900 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java @@ -0,0 +1,242 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import org.jenkinsci.plugins.workflow.log.FileLogStorage; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; + +/** + * Tests related to potential exceptions when using TeeLogStorage. + */ +public class TeeBuildListenerTest { + @ClassRule + public static JenkinsRule r = new JenkinsRule(); + + @ClassRule + public static LoggerRule logging = new LoggerRule(); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private File fileLogStorageFileA; + private File fileLogStorageFileB; + private File remoteCustomFileLogStorageFile; + private static final String CONTENT = "Hello World"; + + @Before + public void before() throws Exception { + fileLogStorageFileA = tmp.newFile(); + fileLogStorageFileB = tmp.newFile(); + remoteCustomFileLogStorageFile = tmp.newFile(); + } + + /** + * Test {@link TeeOutputStream#write(int)} + */ + @Test + public void primary_fails_write_char() throws Exception { + char content = 'a'; + var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void write(int b) throws IOException { + throw new IOException(); + } + }); + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().write(content); + } catch (IOException e) { + fail(); + } finally { + assertCustomFileEmpty(String.valueOf(content)); + } + } + + /** + *Test {@link TeeOutputStream#write(byte[], int, int)} + */ + @Test + public void primary_fails_write_string() throws Exception { + var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException(); + } + }); + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + fail(); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + /** + * Test {@link TeeOutputStream#flush()} + */ + @Test + public void primary_fails_flush() throws Exception { + var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void flush() throws IOException { + throw new IOException("Exception for test"); + } + }); + + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + assertThat(e.getMessage(), is("Exception for test")); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + /** + * Test {@link TeeOutputStream#close()} + */ + @Test + @Ignore + public void primary_fails_close() throws Exception { + var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void close() throws IOException { + throw new IOException("Exception for test"); + } + }); + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + assertThat(e.getMessage(), is("Exception for test")); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + /** + * Test {@link TeeOutputStream#write(int)} + */ + @Test + public void secondary_fails_write_char() throws Exception { + char content = 'a'; + var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void write(int b) throws IOException { + throw new IOException(); + } + }); + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().write(content); + } catch (IOException e) { + fail(); + } finally { + assertCustomFileEmpty(String.valueOf(content)); + } + } + + /** + *Test {@link TeeOutputStream#write(byte[], int, int)} + */ + @Test + public void secondary_fails_write_string() throws Exception { + var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException(); + } + }); + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + fail(); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + /** + * Test {@link TeeOutputStream#flush()} + */ + @Test + public void secondary_fails_flush() throws Exception { + var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void flush() throws IOException { + throw new IOException("Exception for test"); + } + }); + + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + assertThat(e.getMessage(), is("Exception for test")); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + /** + * Test {@link TeeOutputStream#close()} + */ + @Test + @Ignore + public void secondary_fails_close() throws Exception { + var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) { + @Override + public void close() throws IOException { + throw new IOException("Exception for test"); + } + }); + + try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) { + overall.getLogger().print(CONTENT); + } catch (IOException e) { + assertThat(e.getMessage(), is("Exception for test")); + } finally { + assertCustomFileEmpty(CONTENT); + } + } + + private TeeLogStorage primaryFails(RemoteCustomFileLogStorage.OutputStreamSupplier failingOutputStream) { + return new TeeLogStorage( + RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile, failingOutputStream), + FileLogStorage.forFile(fileLogStorageFileA), + FileLogStorage.forFile(fileLogStorageFileB)); + } + + private TeeLogStorage secondaryFails(RemoteCustomFileLogStorage.OutputStreamSupplier failingOutputStream) { + return new TeeLogStorage( + FileLogStorage.forFile(fileLogStorageFileA), + RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile, failingOutputStream), + FileLogStorage.forFile(fileLogStorageFileB)); + } + + private void assertCustomFileEmpty(String content) throws IOException { + var remoteCustomFileLogStorageContent = getContent(remoteCustomFileLogStorageFile); + var fileLogStorageFileAContent = getContent(fileLogStorageFileA); + var fileLogStorageFileBContent = getContent(fileLogStorageFileB); + + assertThat(remoteCustomFileLogStorageContent, emptyString()); + assertThat(fileLogStorageFileAContent, is(content)); + assertThat(fileLogStorageFileBContent, is(content)); + } + + private String getContent(File file) throws IOException { + return Files.readString(file.toPath()); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java new file mode 100644 index 00000000..22adbbbf --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java @@ -0,0 +1,49 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.io.FileMatchers.anExistingFile; + +import java.io.File; +import java.nio.file.Files; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.jenkinsci.plugins.workflow.log.FileLogStorageFactory; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.configuration.PipelineLoggingGlobalConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +public class TeeLogStoragePipelineTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void smokes() throws Exception { + var storageFactory = + new TeeLogStorageFactory(new FileLogStorageFactory(), new RemoteCustomFileLogStorageFactory()); + var config = PipelineLoggingGlobalConfiguration.get(); + config.setFactory(storageFactory); + + WorkflowJob workflowJob = j.createProject(WorkflowJob.class); + workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true)); + + WorkflowRun b = j.buildAndAssertSuccess(workflowJob); + assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(TeeLogStorage.class)); + + File logIndex = new File(b.getRootDir(), "log-index"); + File log = new File(b.getRootDir(), "log"); + File customLog = new File(b.getRootDir(), "custom-log"); + + assertThat(logIndex, anExistingFile()); + assertThat(log, anExistingFile()); + assertThat(customLog, anExistingFile()); + + assertThat(Files.readString(log.toPath()), containsString("Hello World")); + assertThat(Files.readString(customLog.toPath()), containsString("Hello World")); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java new file mode 100644 index 00000000..707a3a2e --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java @@ -0,0 +1,107 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.hamcrest.Matchers.is; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jenkinsci.plugins.workflow.log.FileLogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorage; +import org.jenkinsci.plugins.workflow.log.LogStorageTestBase; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +public class TeeLogStorageTest extends LogStorageTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private File primaryFile; + private File fileLogStorageFile; + private File remoteCustomFileLogStorageFile; + /** + * For the remoteCustomFileLogStorage, the logs are tranfromed in uppercase, meaning the case should be different + */ + private boolean remoteCase = false; + /** + * For the mangled case we simply check the sequences are properly in order, we check all lowercase alphabet, uppercase alphabet and digits are in the correct seauence + */ + private boolean mangledCase = false; + + @Before + public void before() throws Exception { + primaryFile = tmp.newFile(); + fileLogStorageFile = tmp.newFile(); + remoteCustomFileLogStorageFile = tmp.newFile(); + } + + @Override + protected LogStorage createStorage() { + return new TeeLogStorage( + FileLogStorage.forFile(primaryFile), + FileLogStorage.forFile(fileLogStorageFile), + RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile)); + } + + @Override + public void mangledLines() throws Exception { + mangledCase = true; + super.mangledLines(); + } + + @Override + public void remoting() throws Exception { + remoteCase = true; + super.remoting(); + } + + @After + public void additional_checks() throws IOException { + var primaryContent = getContent(primaryFile); + var fileLogStorageContent = getContent(fileLogStorageFile); + var remoteCustomFileLogStorageContent = getContent(remoteCustomFileLogStorageFile); + + if (mangledCase) { + checkMangledLines(primaryContent, fileLogStorageContent, remoteCustomFileLogStorageContent); + return; + } + + assertThat(fileLogStorageContent, is(primaryContent)); + if (remoteCase) { + assertThat(remoteCustomFileLogStorageContent, equalToIgnoringCase(primaryContent)); + } else { + assertThat(remoteCustomFileLogStorageContent, is(primaryContent)); + } + } + + private String getContent(File file) throws IOException { + return Files.readString(file.toPath()); + } + + private void checkMangledLines( + String primaryContent, String fileLogStorageContent, String remoteCustomFileLogStorageContent) { + for (Pattern pattern : List.of(Pattern.compile("[a-z]"), Pattern.compile("[A-Z]"), Pattern.compile("[0-9]"))) { + var primaryExtract = extractCharacters(pattern, primaryContent); + var fileLogStorageExtract = extractCharacters(pattern, fileLogStorageContent); + var remoteCustomFileLogStorageExtract = extractCharacters(pattern, remoteCustomFileLogStorageContent); + assertThat(primaryExtract, is(fileLogStorageExtract)); + assertThat(primaryExtract, is(remoteCustomFileLogStorageExtract)); + } + } + + private String extractCharacters(Pattern pattern, String content) { + Matcher matcher = pattern.matcher(content); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + sb.append(matcher.group()); + } + return sb.toString(); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java new file mode 100644 index 00000000..434d05d9 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java @@ -0,0 +1,113 @@ +package org.jenkinsci.plugins.workflow.log.tee; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.logging.Logger; +import org.junit.Test; + +/** + * Tests related to potential exceptions when using TeeLogStorage. + */ +public class TeeOutputStreamTest { + + private static final Logger LOGGER = Logger.getLogger(TeeOutputStreamTest.class.getName()); + + private File fileLogStorageFileA; + private File fileLogStorageFileB; + private File remoteCustomFileLogStorageFile; + private static final String CONTENT = "Hello World"; + + /** + * Test {@link TeeOutputStream#write(int)} + */ + @Test + public void fails_write_char() throws Exception { + var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) { + @Override + public void write(int b) throws IOException { + throw new IOException("Exception for test"); + } + }); + try { + out.write(0); + fail(); + } catch (IOException e) { + assertException(e); + } + } + + /** + *Test {@link TeeOutputStream#write(byte[], int, int)} + */ + @Test + public void fails_write_string() throws Exception { + var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) { + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException("Exception for test"); + } + }); + try { + out.write(new byte[] {0}, 0, 1); + fail(); + } catch (IOException e) { + assertException(e); + } + } + + /** + * Test {@link TeeOutputStream#flush()} + */ + @Test + public void fails_flush() throws Exception { + var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) { + @Override + public void flush() throws IOException { + throw new IOException("Exception for test"); + } + }); + try { + out.flush(); + fail(); + } catch (IOException e) { + assertException(e); + } + } + + /** + * Test {@link TeeOutputStream#close()} + */ + @Test + public void fails_close() throws Exception { + var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) { + @Override + public void close() throws IOException { + throw new IOException("Exception for test"); + } + }); + try { + out.close(); + fail(); + } catch (IOException e) { + assertException(e); + } + } + + private TeeOutputStream multipleFails(OutputStream failingOutputStream) { + return new TeeOutputStream( + failingOutputStream, + new OutputStream[] {failingOutputStream, OutputStream.nullOutputStream()}); + } + + private void assertException(IOException e) { + assertThat(e.getMessage(), is("Exception for test")); + assertThat(e.getSuppressed().length, is(1)); + assertThat(e.getSuppressed()[0].getMessage(), is("Exception for test")); + } +} diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml new file mode 100644 index 00000000..cd445662 --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml @@ -0,0 +1,8 @@ +unclassified: + pipelineLogging: + factory: + tee: + primary: + logMock1: {} + secondary: + logMock1 diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml new file mode 100644 index 00000000..bdb50dcd --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml @@ -0,0 +1,4 @@ +unclassified: + pipelineLogging: + factory: + tee diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml new file mode 100644 index 00000000..e12aeafc --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml @@ -0,0 +1,5 @@ +unclassified: + pipelineLogging: + factory: + tee: + primary: logMock1 diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml new file mode 100644 index 00000000..2ec8bc55 --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml @@ -0,0 +1,5 @@ +unclassified: + pipelineLogging: + factory: + tee: + secondary: logMock2 diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml new file mode 100644 index 00000000..6a417351 --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml @@ -0,0 +1,8 @@ +unclassified: + pipelineLogging: + factory: + tee: + primary: + logMock1: {} + secondary: + logMock2