diff --git a/core/src/main/java/hudson/model/AbstractBuild.java b/core/src/main/java/hudson/model/AbstractBuild.java index 0f49ac47f36c..642ab6c61e7d 100644 --- a/core/src/main/java/hudson/model/AbstractBuild.java +++ b/core/src/main/java/hudson/model/AbstractBuild.java @@ -1,7 +1,8 @@ /* * The MIT License * - * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi, Yahoo! Inc., CloudBees, Inc. + * Copyright (c) 2004-2018, Sun Microsystems, Inc., Kohsuke Kawaguchi, Yahoo! Inc., + * CloudBees Inc., Oleg Nenashev and other 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 @@ -530,6 +531,10 @@ protected Launcher createLauncher(@Nonnull BuildListener listener) throws IOExce final Node currentNode = getCurrentNode(); Launcher l = currentNode.createLauncher(listener); + // Produce correct logger + // TODO: Consider merging with create Launcher + l = getLogStorage().decorateLauncher(l, getBuild(), currentNode, listener); + if (project instanceof BuildableItemWithBuildWrappers) { BuildableItemWithBuildWrappers biwbw = (BuildableItemWithBuildWrappers) project; for (BuildWrapper bw : biwbw.getBuildWrappersList()) diff --git a/core/src/main/java/hudson/model/Run.java b/core/src/main/java/hudson/model/Run.java index e8c7965f30e4..255d1cd03c4b 100644 --- a/core/src/main/java/hudson/model/Run.java +++ b/core/src/main/java/hudson/model/Run.java @@ -1,11 +1,10 @@ /* * The MIT License * - * Copyright (c) 2004-2012, Sun Microsystems, Inc., Kohsuke Kawaguchi, + * Copyright (c) 2004-2018, Sun Microsystems, Inc., Kohsuke Kawaguchi, * Daniel Dyer, Red Hat, Inc., Tom Huybrechts, Romain Seguy, Yahoo! Inc., - * Darek Ostolski, CloudBees, Inc. - * - * Copyright (c) 2012, Martin Schroeder, Intel Mobile Communications GmbH + * Martin Schroeder, Intel Mobile Communications GmbH, + * Darek Ostolski, CloudBees, Inc., Oleg Nenashev and other 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 @@ -27,7 +26,6 @@ */ package hudson.model; -import com.jcraft.jzlib.GZIPInputStream; import com.thoughtworks.xstream.XStream; import hudson.AbortException; import hudson.BulkChange; @@ -37,13 +35,14 @@ import hudson.FeedAdapter; import hudson.Functions; import hudson.console.AnnotatedLargeText; -import hudson.console.ConsoleLogFilter; -import hudson.console.ConsoleNote; import hudson.console.ModelHyperlinkNote; import hudson.console.PlainTextConsoleOutputStream; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.StandardOpenOption; + +import java.io.Closeable; + +import jenkins.model.logging.Loggable; +import jenkins.model.logging.impl.BrokenAnnotatedLargeText; +import jenkins.model.logging.impl.CompatFileLogStorage; import jenkins.util.SystemProperties; import hudson.Util; import hudson.XmlFile; @@ -65,14 +64,11 @@ import hudson.util.ProcessTree; import hudson.util.XStream2; -import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; -import java.io.RandomAccessFile; import java.io.Reader; import java.io.Serializable; import java.nio.charset.Charset; @@ -106,11 +102,13 @@ import jenkins.model.BuildDiscarder; import jenkins.model.Jenkins; import jenkins.model.JenkinsLocationConfiguration; +import jenkins.model.logging.LogStorage; import jenkins.model.PeepholePermalink; import jenkins.model.RunAction2; import jenkins.model.StandardArtifactManager; import jenkins.model.lazy.BuildReference; import jenkins.model.lazy.LazyBuildMixIn; +import jenkins.model.logging.LogStorageFactory; import jenkins.security.MasterToSlaveCallable; import jenkins.util.VirtualFile; import jenkins.util.io.OnMaster; @@ -119,7 +117,6 @@ import org.acegisecurity.Authentication; import org.apache.commons.io.IOUtils; import org.apache.commons.jelly.XMLOutput; -import org.apache.commons.lang.ArrayUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.HttpResponse; @@ -144,7 +141,7 @@ */ @ExportedBean public abstract class Run ,RunT extends Run> - extends Actionable implements ExtensionPoint, Comparable, AccessControlled, PersistenceRoot, DescriptorByNameOwner, OnMaster { + extends Actionable implements ExtensionPoint, Comparable, AccessControlled, PersistenceRoot, DescriptorByNameOwner, OnMaster, Loggable { /** * The original {@link Queue.Item#getId()} has not yet been mapped onto the {@link Run} instance. @@ -289,6 +286,12 @@ private static enum State { */ private @CheckForNull ArtifactManager artifactManager; + /** + * Log storage associated with this build, if any. + * @since TODO + */ + private @CheckForNull LogStorage logStorage; + /** * Creates a new {@link Run}. * @param job Owner job @@ -345,6 +348,10 @@ public void reload() throws IOException { LOGGER.log(WARNING, "reload {0} @{1} with anomalous state {2}", new Object[] {this, hashCode(), state}); } + if (logStorage == null) { + logStorage = new CompatFileLogStorage(this); + } + // not calling onLoad upon reload. partly because we don't want to call that from Run constructor, // and partly because some existing use of onLoad isn't assuming that it can be invoked multiple times. } @@ -366,9 +373,13 @@ protected void onLoad() { ((RunAction) a).onLoad(); } } + if (artifactManager != null) { artifactManager.onLoad(this); } + if (logStorage != null) { + logStorage.onLoad(this); + } } /** @@ -519,6 +530,11 @@ public boolean isLogUpdated() { return state.compareTo(State.COMPLETED) < 0; } + @Override + public boolean isLoggingFinished() { + return !isLogUpdated(); + } + /** * Gets the {@link Executor} building this job, if it's being built. * Otherwise null. @@ -553,7 +569,8 @@ public boolean isLogUpdated() { * Gets the charset in which the log file is written. * @return never null. * @since 1.257 - */ + */ + @Override public final @Nonnull Charset getCharset() { if(charset==null) return Charset.defaultCharset(); return Charset.forName(charset); @@ -1417,12 +1434,27 @@ public Collection getBuildFingerprints() { } return Collections.emptyList(); } - + /** - * Returns the log file. - * @return The file may reference both uncompressed or compressed logs - */ - public @Nonnull File getLogFile() { + * Gets log storage. + * @return Log storage + * @since TODO + */ + @Override + @Exported + public LogStorage getLogStorage() { + if (logStorage == null) { + //TODO(oleg_nenashev) WorkflowRun does not allow capturing Logging method in a better way + // In order to prevent cases between new objects and data migration, we rely on reload() + // which acts as readResolve() for the Run object + logStorage = LogStorageFactory.locate(this); + } + return logStorage; + } + + @CheckForNull + @Override + public File getLogFileCompatLocation() { File rawF = new File(getRootDir(), "log"); if (rawF.isFile()) { return rawF; @@ -1435,39 +1467,60 @@ public Collection getBuildFingerprints() { return rawF; } + /** + * Gets Log file. + * @deprecated Not all {@link jenkins.model.logging.LogStorage} implementations + * are able to produce the log file efficiently. + * It is recommended to use {@link #getLogStorage()} to get browser with better API options. + * @return Log file. + * It is never {@code null}, but the file may not exist + */ + @Deprecated + @Nonnull + public File getLogFile() { + try { + return getLogStorage().getLogFile(); + } catch (IOException|InterruptedException e) { + LOGGER.log(Level.WARNING, "Failed to locate log file for " + this, e); + return new File(getRootDir(), "log"); + } + } + + /** + * Deletes the log in the storage. + * @return {@code true} if the log was deleted. + * {@code false} if Log deletion is not supported. + * @throws IOException Failed to delete the log. + * @throws InterruptedException Operation was interrupted + * @since TODO + */ + public boolean deleteLog() throws IOException, InterruptedException { + return getLogStorage().deleteLog(); + } + /** * Returns an input stream that reads from the log file. * It will use a gzip-compressed log file (log.gz) if that exists. * - * @throws IOException + * @throws IOException Operation error * @return An input stream from the log file. * If the log file does not exist, the error message will be returned to the output. * @since 1.349 */ public @Nonnull InputStream getLogInputStream() throws IOException { - File logFile = getLogFile(); - - if (logFile.exists() ) { - // Checking if a ".gz" file was return - try { - InputStream fis = Files.newInputStream(logFile.toPath()); - if (logFile.getName().endsWith(".gz")) { - return new GZIPInputStream(fis); - } else { - return fis; - } - } catch (InvalidPathException e) { - throw new IOException(e); - } - } - - String message = "No such file: " + logFile; - return new ByteArrayInputStream(charset != null ? message.getBytes(charset) : message.getBytes()); + try { + return getLogStorage().getLogInputStream(); + } catch (InterruptedException e) { + throw new IOException(e); + } } public @Nonnull Reader getLogReader() throws IOException { - if (charset==null) return new InputStreamReader(getLogInputStream()); - else return new InputStreamReader(getLogInputStream(),charset); + try { + return getLogStorage().getLogReader(); + } catch (InterruptedException e) { + throw new IOException(e); + } } /** @@ -1516,7 +1569,11 @@ public void writeWholeLogTo(@Nonnull OutputStream out) throws IOException, Inter * @return A {@link Run} log with annotations */ public @Nonnull AnnotatedLargeText getLogText() { - return new AnnotatedLargeText(getLogFile(),getCharset(),!isLogUpdated(),this); + try { + return getLogStorage().overallLog(); + } catch (IOException|InterruptedException e) { + return new BrokenAnnotatedLargeText(e); + } } @Override @@ -1575,6 +1632,19 @@ public void delete() throws IOException { deleteArtifacts(); } // for StandardArtifactManager, deleting the whole build dir suffices + final LogStorage logStorage = getLogStorage(); + if (!(logStorage instanceof CompatFileLogStorage)) { + try { + boolean supported = logStorage.deleteLog(); + if (!supported) { + LOGGER.log(Level.FINE, "Cannot delete logs for run {0}, log storage {1} does not support it", + new Object[] {this, logStorage}); + } + } catch (InterruptedException e) { + throw new IOException("Failed to delete logs in the log storage", e); + } + } // for standard FileLogBrowser, deleting the whole build dir suffices + synchronized (this) { // avoid holding a lock while calling plugin impls of onDeleted File tmp = new File(rootDir.getParentFile(),'.'+rootDir.getName()); @@ -1773,8 +1843,7 @@ protected final void execute(@Nonnull RunExecution job) { if(result!=null) return; // already built. - OutputStream logger = null; - StreamBuildListener listener=null; + BuildListener listener = null; runner = job; onStartBuilding(); @@ -1787,13 +1856,11 @@ protected final void execute(@Nonnull RunExecution job) { try { try { Computer computer = Computer.currentComputer(); - Charset charset = null; if (computer != null) { - charset = computer.getDefaultCharset(); + Charset charset = computer.getDefaultCharset(); this.charset = charset.name(); } - logger = createLogger(); - listener = createBuildListener(job, logger, charset); + listener = getLogStorage().createBuildListener(); listener.started(getCauses()); Authentication auth = Jenkins.getAuthentication(); @@ -1865,7 +1932,13 @@ protected final void execute(@Nonnull RunExecution job) { // too late to update the result now } listener.finished(result); - listener.closeQuietly(); + if (listener instanceof Closeable) { + try { + ((Closeable)listener).close(); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "Failed to close the build listener for " + Run.this, ex); + } + } } try { @@ -1882,46 +1955,17 @@ protected final void execute(@Nonnull RunExecution job) { } } finally { onEndBuilding(); - if (logger != null) { + //TODO(oleg_nenashev): DRY? there is Close operation above + if (listener instanceof Closeable) { try { - logger.close(); - } catch (IOException x) { - LOGGER.log(Level.WARNING, "failed to close log for " + Run.this, x); + ((Closeable)listener).close(); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "Failed to close the build listener for " + Run.this, ex); } } } } - private OutputStream createLogger() throws IOException { - // don't do buffering so that what's written to the listener - // gets reflected to the file immediately, which can then be - // served to the browser immediately - try { - return Files.newOutputStream(getLogFile().toPath(), StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (InvalidPathException e) { - throw new IOException(e); - } - } - - private StreamBuildListener createBuildListener(@Nonnull RunExecution job, OutputStream logger, Charset charset) throws IOException, InterruptedException { - RunT build = job.getBuild(); - - // Global log filters - for (ConsoleLogFilter filter : ConsoleLogFilter.all()) { - logger = filter.decorateLogger(build, logger); - } - - // Project specific log filters - if (project instanceof BuildableItemWithBuildWrappers && build instanceof AbstractBuild) { - BuildableItemWithBuildWrappers biwbw = (BuildableItemWithBuildWrappers) project; - for (BuildWrapper bw : biwbw.getBuildWrappersList()) { - logger = bw.decorateLogger((AbstractBuild) build, logger); - } - } - - return new StreamBuildListener(logger,charset); - } - /** * Makes sure that {@code lastSuccessful} and {@code lastStable} legacy links in the project’s root directory exist. * Normally you do not need to call this explicitly, since {@link #execute} does so, @@ -2038,7 +2082,11 @@ private Object readResolve() { */ @Deprecated public @Nonnull String getLog() throws IOException { - return Util.loadFile(getLogFile(),getCharset()); + try { + return getLogStorage().getLog(); + } catch (InterruptedException e) { + throw new IOException(e); + } } /** @@ -2051,54 +2099,11 @@ private Object readResolve() { * @throws IOException If there is a problem reading the log file. */ public @Nonnull List getLog(int maxLines) throws IOException { - if (maxLines == 0) { - return Collections.emptyList(); - } - - int lines = 0; - long filePointer; - final List lastLines = new ArrayList<>(Math.min(maxLines, 128)); - final List bytes = new ArrayList<>(); - - try (RandomAccessFile fileHandler = new RandomAccessFile(getLogFile(), "r")) { - long fileLength = fileHandler.length() - 1; - - for (filePointer = fileLength; filePointer != -1 && maxLines != lines; filePointer--) { - fileHandler.seek(filePointer); - byte readByte = fileHandler.readByte(); - - if (readByte == 0x0A) { - if (filePointer < fileLength) { - lines = lines + 1; - lastLines.add(convertBytesToString(bytes)); - bytes.clear(); - } - } else if (readByte != 0xD) { - bytes.add(readByte); - } - } - } - - if (lines != maxLines) { - lastLines.add(convertBytesToString(bytes)); - } - - Collections.reverse(lastLines); - - // If the log has been truncated, include that information. - // Use set (replaces the first element) rather than add so that - // the list doesn't grow beyond the specified maximum number of lines. - if (lines == maxLines) { - lastLines.set(0, "[...truncated " + Functions.humanReadableByteSize(filePointer)+ "...]"); + try { + return getLogStorage().getLog(maxLines); + } catch (InterruptedException e) { + throw new IOException(e); } - - return ConsoleNote.removeNotes(lastLines); - } - - private String convertBytesToString(List bytes) { - Collections.reverse(bytes); - Byte[] byteArray = bytes.toArray(new Byte[bytes.size()]); - return new String(ArrayUtils.toPrimitive(byteArray), getCharset()); } public void doBuildStatus( StaplerRequest req, StaplerResponse rsp ) throws IOException { @@ -2579,6 +2584,11 @@ public Object getDynamic(String token, StaplerRequest req, StaplerResponse rsp) return returnedResult; } + @Override + public LogStorage getDefaultLogStorage() { + return new CompatFileLogStorage(this); + } + public static class RedirectUp { public void doDynamic(StaplerResponse rsp) throws IOException { // Compromise to handle both browsers (auto-redirect) and programmatic access diff --git a/core/src/main/java/jenkins/model/logging/LogStorage.java b/core/src/main/java/jenkins/model/logging/LogStorage.java new file mode 100644 index 000000000000..c63b357d97f6 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/LogStorage.java @@ -0,0 +1,217 @@ +/* + * The MIT License + * + * Copyright 2016-2018 CloudBees Inc., Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging; + +import hudson.Launcher; +import hudson.console.AnnotatedLargeText; +import hudson.model.BuildListener; +import hudson.model.Node; +import hudson.model.Run; +import javax.annotation.CheckForNull; +import javax.annotation.CheckReturnValue; +import javax.annotation.Nonnull; + +import hudson.model.TaskListener; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.List; + +/** + * Defines Log storage for Jenkins. + * The log storage may store logs for {@link Run}s and other {@link Loggable} types. + * This abstract class defines a low-level API which should be overridden by implementations + * to externalize logs. + * {@link LogStorageFactory} allows locating log storages for particular components. + * + * @author Oleg Nenashev + * @author Xing Yan + * @see LogStorageFactory + * @since TODO + */ +@Restricted(Beta.class) +@ExportedBean +public abstract class LogStorage { + + protected transient T loggable; + + public LogStorage(@Nonnull T loggable) { + this.loggable = loggable; + } + + @Exported + public String getId() { + return getClass().getName(); + } + + /** + * Called when the owner is loaded from disk. + * The owner may be persisted on the disk, so the build reference should be {@code transient} (quasi-{@code final}) and restored here. + * @param loggable an owner to which this component is associated. + */ + public void onLoad(@Nonnull T loggable) { + this.loggable = loggable; + } + + @Nonnull + protected Loggable getOwner() { + if (loggable == null) { + throw new IllegalStateException("Owner has not been assigned to the object yet"); + } + return loggable; + } + + /** + * Decorates logging on the Jenkins master side for non-{@link Run} loggable items. + * @return Log filter on the master. + * {@code null} if the implementation does not support task logging + * @throws IOException initialization error or wrong {@link Loggable} type + * @throws InterruptedException one of the build listener decorators has been interrupted. + */ + @CheckForNull + public TaskListener createTaskListener() throws IOException, InterruptedException { + return null; + } + + /** + * Decorates logging on the Jenkins master side. + * This method should be always implemented, because it will be consuming the input events. + * Streams can be converted to per-line events by higher-level abstractions. + * + * @return Build Listener + * @throws IOException initialization error or wrong {@link Loggable} type + * @throws InterruptedException Was interrupted while decorating listeners + */ + @Nonnull + public abstract BuildListener createBuildListener() throws IOException, InterruptedException; + + //TODO: Remove it at all and update JEP-207? + /** + * Decorates external process launcher running on a node. + * It may be used to inject custom environment if it is required by the LogStorage implementation. + * + * This method should be invoked by {@link Run} implementations to be effective, + * and there is no guarantee that {@link Run} types excepting {@link hudson.model.AbstractBuild} invoke that. + * It is not advised to use this method to pass logic, + * {@link #createBuildListener()} and {@link hudson.model.EnvironmentContributingAction}s should be a solution. + * + * @param original Original launcher + * @param run Run, for which the decoration should be performed + * @param node Target node. May be {@code master} as well + * @param listener Task listener + * @return Decorated launcher or {@code original} launcher + * @throws IOException Was interrupted while decorating the launcher + * @throws InterruptedException Was interrupted while decorating listeners + */ + @Nonnull + public Launcher decorateLauncher(@Nonnull Launcher original, + @Nonnull Run run, @Nonnull Node node, @Nonnull TaskListener listener) + throws IOException, InterruptedException { + return original; + } + + /** + * Gets log for an object. + * @return Created log or {@link jenkins.model.logging.impl.BrokenAnnotatedLargeText} if it cannot be retrieved + * @throws IOException Operation failed + * @throws InterruptedException Operation was interrupted + */ + @Nonnull + public abstract AnnotatedLargeText overallLog() throws IOException, InterruptedException; + + /** + * Gets log as an input stream. + * @return Input stream for the log + * @throws IOException Failed to access logs + * @throws InterruptedException Operation was interrupted + */ + public abstract InputStream getLogInputStream() throws IOException, InterruptedException; + + /** + * Gets a log reader. + * @return Log reader. + * It may just wrap {@link #getLogInputStream()} or provide a more efficient implementation. + * @throws IOException Failed to access logs + * @throws InterruptedException Operation was interrupted + */ + public @Nonnull Reader getLogReader() throws IOException, InterruptedException { + return new InputStreamReader(getLogInputStream(), getOwner().getCharset()); + } + + /** + * Gets the entire log as text. + * This method is a convenience implementation for legacy API users. + * @return Entire log as a string + * @throws IOException Failed to read logs + * @deprecated Use methods like {@link #overallLog()}, {@link #getLog(int)} or {@link #getLogReader()} instead + * @throws IOException Operation failed + * @throws InterruptedException Operation was interrupted + */ + @Deprecated + public String getLog() throws IOException, InterruptedException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + overallLog().writeRawLogTo(0, baos); + return baos.toString(loggable.getCharset().name()); + } + + /** + * Get a subset of the log + * @param maxLines Maximum number of log lines to read. + * @return List of log lines. + * @throws IOException Failed to read logs + * @throws InterruptedException Operation was interrupted + */ + public abstract List getLog(int maxLines) throws IOException, InterruptedException; + + /** + * Gets log as a file. + * This is a compatibility method, which is used in {@link Run#getLogFile()}. + * Implementations may provide it, e.g. by creating temporary files if needed. + * @return Log file. If it does not exist, {@link IOException} should be thrown + * @throws IOException Log file cannot be retrieved + * @throws InterruptedException Operation was interrupted + * @deprecated The method is available for compatibility purposes only + */ + @Deprecated + @Nonnull + public abstract File getLogFile() throws IOException, InterruptedException; + + /** + * Deletes the log in the storage. + * @return {@code true} if the log was deleted. + * {@code false} if Log deletion is not supported. + * @throws IOException Failed to delete the log. + * @throws InterruptedException Operation was interrupted + */ + @CheckReturnValue + public abstract boolean deleteLog() throws IOException, InterruptedException; +} diff --git a/core/src/main/java/jenkins/model/logging/LogStorageFactory.java b/core/src/main/java/jenkins/model/logging/LogStorageFactory.java new file mode 100644 index 000000000000..ebeba2d9b2f7 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/LogStorageFactory.java @@ -0,0 +1,69 @@ +/* + * The MIT License + * + * Copyright 2016-2018 CloudBees Inc., Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging; + +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import jenkins.model.logging.impl.CompatFileLogStorage; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + +/** + * Locates {@link LogStorage}s. + * @author Oleg Nenashev + * @author Xing Yan + * @since TODO + * @see CompatFileLogStorage + */ +@Restricted(Beta.class) +public abstract class LogStorageFactory implements ExtensionPoint { + + /** + * Retrieve the logging method for the run. + * @param object Loggable object + * @return Logging method. {@code null} if the locator does not provide the + * implementation for the run. + */ + @CheckForNull + protected abstract LogStorage getLogStorage(Loggable object); + + @Nonnull + public static LogStorage locate(Loggable run) { + for (LogStorageFactory locator : all()) { + final LogStorage logStorage = locator.getLogStorage(run); + if (logStorage != null) { + return logStorage; + } + } + // Fallback + return run.getDefaultLogStorage(); + } + + public static ExtensionList all() { + return ExtensionList.lookup(LogStorageFactory.class); + } +} \ No newline at end of file diff --git a/core/src/main/java/jenkins/model/logging/Loggable.java b/core/src/main/java/jenkins/model/logging/Loggable.java new file mode 100644 index 000000000000..f1b60cd2070e --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/Loggable.java @@ -0,0 +1,94 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging; + +import jenkins.model.logging.impl.CompatFileLogStorage; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.File; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * Interface which indicates that custom logging is applicable to the object. + * @author Oleg Nenashev + * @since TODO + * @see LogStorage + */ +@Restricted(Beta.class) +public interface Loggable { + + @Nonnull + default LogStorage getLogStorage() { + return LogStorageFactory.locate(this); + } + + /** + * Determines a default logger to be used. + * @return Default logger. + */ + @Nonnull + LogStorage getDefaultLogStorage(); + + /** + * Returns {@code true} if the log file is no longer being updated. + */ + public boolean isLoggingFinished(); + + /** + * Returns charset to be used. + * New implementations are recommended to use {@code UTF-8}-only (default), + * but the method can be overridden by legacy implementations. + * @return Charset to be used. + */ + @Nonnull + default Charset getCharset() { + return StandardCharsets.UTF_8; + } + + /** + * Provides legacy File storage location for compatibility implementations. + * @return Log file or {@code null} if it is not supported. + * A non-existent file may be returned if log is missing in the compatibility location + * @see CompatFileLogStorage + */ + @CheckForNull + default File getLogFileCompatLocation() { + return null; + } + + + /** + * Get temporary directory of the Loggable object (if exists). + * This loggable directory may be used to store temporary files if needed. + * @return Temporary directory or {@code null} if not defined + */ + @CheckForNull + default File getTmpDir() { + return null; + } +} diff --git a/core/src/main/java/jenkins/model/logging/impl/AbstractFileLogStorage.java b/core/src/main/java/jenkins/model/logging/impl/AbstractFileLogStorage.java new file mode 100644 index 000000000000..a2818f2e7518 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/impl/AbstractFileLogStorage.java @@ -0,0 +1,188 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. and other Jenkins 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.model.logging.impl; + +import com.jcraft.jzlib.GZIPInputStream; +import hudson.Functions; +import hudson.console.AnnotatedLargeText; +import hudson.console.ConsoleLogFilter; +import hudson.console.ConsoleNote; +import jenkins.model.logging.Loggable; +import org.apache.commons.lang.ArrayUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.CheckForNull; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * File Log storage implementation. + * When used, logging always goes to a file on the Jenkins master side. + * + * The implementation relies on the {@link #getLogFile()} method. + * + * @author Oleg Nenashev + * @since TODO + * @see CompatFileLogStorage + */ +@Restricted(Beta.class) +public abstract class AbstractFileLogStorage extends StreamLogStorage { + + private static final Logger LOGGER = Logger.getLogger(AbstractFileLogStorage.class.getName()); + + + public AbstractFileLogStorage(Loggable loggable) { + super(loggable); + } + + @Override + public OutputStream createOutputStream() throws IOException, InterruptedException { + File logFile = getLogFile(); + return Files.newOutputStream(logFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } + + @CheckForNull + @Override + public ConsoleLogFilter getExtraConsoleLogFilter() { + return null; + } + + @Override + public AnnotatedLargeText overallLog() throws IOException, InterruptedException { + final File logFile; + try { + logFile = getLogFile(); + } catch (IOException ex) { + return new BrokenAnnotatedLargeText(ex, getOwner().getCharset()); + } + + return new AnnotatedLargeText + (logFile, getOwner().getCharset(), getOwner().isLoggingFinished(), getOwner()); + } + + @Override + public boolean deleteLog() throws IOException, InterruptedException { + File logFile = getLogFile(); + if (logFile.exists()) { + try { + Files.delete(logFile.toPath()); + } catch (Exception ex) { + throw new IOException("Failed to delete " + logFile, ex); + } + } else { + LOGGER.log(Level.FINE, "Trying to delete Log File of {0} which does not exist: {1}", + new Object[] {loggable, logFile}); + } + return true; + } + + @Override + public InputStream getLogInputStream() throws IOException, InterruptedException { + File logFile = getLogFile(); + + if (logFile.exists() ) { + // Checking if a ".gz" file was return + try { + InputStream fis = Files.newInputStream(logFile.toPath()); + if (logFile.getName().endsWith(".gz")) { + return new GZIPInputStream(fis); + } else { + return fis; + } + } catch (InvalidPathException e) { + throw new IOException(e); + } + } + + String message = "No such file: " + logFile; + return new ByteArrayInputStream(message.getBytes(getOwner().getCharset())); + } + + + @Override + public List getLog(int maxLines) throws IOException, InterruptedException { + if (maxLines == 0) { + return Collections.emptyList(); + } + int lines = 0; + long filePointer; + final List lastLines = new ArrayList<>(Math.min(maxLines, 128)); + final List bytes = new ArrayList<>(); + + try (RandomAccessFile fileHandler = new RandomAccessFile( + getLogFile(), "r")) { + long fileLength = fileHandler.length() - 1; + + for (filePointer = fileLength; filePointer != -1 && maxLines != lines; filePointer--) { + fileHandler.seek(filePointer); + byte readByte = fileHandler.readByte(); + + if (readByte == 0x0A) { + if (filePointer < fileLength) { + lines = lines + 1; + lastLines.add(convertBytesToString(bytes, getOwner().getCharset())); + bytes.clear(); + } + } else if (readByte != 0xD) { + bytes.add(readByte); + } + } + } + + if (lines != maxLines) { + lastLines.add(convertBytesToString(bytes, getOwner().getCharset())); + } + + Collections.reverse(lastLines); + + // If the log has been truncated, include that information. + // Use set (replaces the first element) rather than add so that + // the list doesn't grow beyond the specified maximum number of lines. + if (lines == maxLines) { + lastLines.set(0, "[...truncated " + Functions.humanReadableByteSize(filePointer)+ "...]"); + } + + return ConsoleNote.removeNotes(lastLines); + } + + private String convertBytesToString(List bytes, Charset charset) { + Collections.reverse(bytes); + Byte[] byteArray = bytes.toArray(new Byte[bytes.size()]); + return new String(ArrayUtils.toPrimitive(byteArray), charset); + } +} diff --git a/core/src/main/java/jenkins/model/logging/impl/BrokenAnnotatedLargeText.java b/core/src/main/java/jenkins/model/logging/impl/BrokenAnnotatedLargeText.java new file mode 100644 index 000000000000..1124d0c6b606 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/impl/BrokenAnnotatedLargeText.java @@ -0,0 +1,63 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging.impl; + +import hudson.Functions; +import hudson.console.AnnotatedLargeText; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.stapler.framework.io.ByteBuffer; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * Annotated Large Text for a case when something goes wrong. + * @param Type + */ +@Restricted(Beta.class) +public class BrokenAnnotatedLargeText extends AnnotatedLargeText { + + public BrokenAnnotatedLargeText(Throwable cause) { + this(cause, StandardCharsets.UTF_8); + } + + public BrokenAnnotatedLargeText(@Nonnull Throwable cause, @Nonnull Charset charset) { + super(makeByteBuffer(cause, charset), charset, true, null); + } + + private static ByteBuffer makeByteBuffer(Throwable x, Charset charset) { + ByteBuffer buf = new ByteBuffer(); + byte[] stack = Functions.printThrowable(x).getBytes(StandardCharsets.UTF_8); + try { + buf.write(stack, 0, stack.length); + } catch (IOException x2) { + assert false : x2; + } + return buf; + } + +} \ No newline at end of file diff --git a/core/src/main/java/jenkins/model/logging/impl/CompatFileLogStorage.java b/core/src/main/java/jenkins/model/logging/impl/CompatFileLogStorage.java new file mode 100644 index 000000000000..8bfd92053632 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/impl/CompatFileLogStorage.java @@ -0,0 +1,66 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. and other Jenkins 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.model.logging.impl; + +import hudson.AbortException; +import jenkins.model.logging.Loggable; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Legacy File Log storage implementation. + * + * This implementation relies on {@link Loggable#getLogFileCompatLocation()} + * provided by implementations. + * + * @author Oleg Nenashev + * @since TODO + */ +@Restricted(Beta.class) +public class CompatFileLogStorage extends AbstractFileLogStorage { + + private static final Logger LOGGER = Logger.getLogger( + CompatFileLogStorage.class.getName()); + + public CompatFileLogStorage(Loggable loggable) { + super(loggable); + } + + @Nonnull + @Override + public File getLogFile() throws IOException { + final File file = loggable.getLogFileCompatLocation(); + if (file == null) { + throw new AbortException("File log compatibility layer is invoked for a loggable " + + "object which returned null for getLogFileCompatLocation(): " + loggable); + } + return file; + } + +} diff --git a/core/src/main/java/jenkins/model/logging/impl/NoopLogStorage.java b/core/src/main/java/jenkins/model/logging/impl/NoopLogStorage.java new file mode 100644 index 000000000000..3d165f1bf086 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/impl/NoopLogStorage.java @@ -0,0 +1,104 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging.impl; + +import hudson.console.AnnotatedLargeText; +import hudson.model.BuildListener; +import hudson.model.TaskListener; +import jenkins.model.logging.Loggable; +import jenkins.model.logging.LogStorage; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.List; + +/** + * Default Logging Method implementation which does nothing + * @author Oleg Nenashev + * @since TODO + */ +@Restricted(Beta.class) +public class NoopLogStorage extends LogStorage { + + public NoopLogStorage(Loggable loggable) { + super(loggable); + } + private transient File noopLogFile; + + @Nonnull + @Override + public BuildListener createBuildListener() throws IOException, InterruptedException { + return new BuildListener() { + @Nonnull + @Override + public PrintStream getLogger() { + return TaskListener.NULL.getLogger(); + } + }; + } + + @Override + public AnnotatedLargeText overallLog() { + return new BrokenAnnotatedLargeText( + new UnsupportedOperationException("Browsing is not supported"), + getOwner().getCharset()); + } + + @Override + public InputStream getLogInputStream() throws IOException { + throw new IOException("Browsing is not supported"); + } + + @Override + public List getLog(int maxLines) throws IOException { + throw new IOException("Browsing is not supported"); + } + + //TODO: It may be better to have a single file for all implementations, but Charsets may be different + @Nonnull + @Override + public File getLogFile() throws IOException { + if (noopLogFile == null) { + File f = File.createTempFile("deprecated", ".log", getOwner().getTmpDir()); + f.deleteOnExit(); + try (OutputStream os = new FileOutputStream(f)) { + overallLog().writeRawLogTo(0, os); + } + noopLogFile = f; + } + return noopLogFile; + } + + @Override + public boolean deleteLog() { + return true; + } +} diff --git a/core/src/main/java/jenkins/model/logging/impl/StreamLogStorage.java b/core/src/main/java/jenkins/model/logging/impl/StreamLogStorage.java new file mode 100644 index 000000000000..a261f9db33a1 --- /dev/null +++ b/core/src/main/java/jenkins/model/logging/impl/StreamLogStorage.java @@ -0,0 +1,104 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.model.logging.impl; + +import hudson.console.ConsoleLogFilter; +import hudson.model.AbstractBuild; +import hudson.model.BuildableItemWithBuildWrappers; +import hudson.model.Job; +import hudson.model.Run; +import hudson.model.StreamBuildListener; +import hudson.tasks.BuildWrapper; +import jenkins.model.logging.Loggable; +import jenkins.model.logging.LogStorage; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.OutputStream; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Logging method which takes {@link OutputStream} as a destination. + * This implementation consults with {@link ConsoleLogFilter} extensions in Jenkins. + * @author Oleg Nenashev + * @since TODO + */ +@Restricted(Beta.class) +public abstract class StreamLogStorage extends LogStorage { + + private static final Logger LOGGER = + Logger.getLogger(StreamLogStorage.class.getName()); + + public StreamLogStorage(@Nonnull Loggable loggable) { + super(loggable); + } + + public abstract OutputStream createOutputStream() throws IOException, InterruptedException; + + /** + * Defines an additional Console Log Filter to be used with the logging method. + * This filter may be also used for log redirection and multi-reporting in very custom cases. + * @return Log filter. {@code null} if no custom implementation + */ + public ConsoleLogFilter getExtraConsoleLogFilter() { + return null; + } + + //TODO: This decoration logic should be shared with other implementations + @Override + public final StreamBuildListener createBuildListener() throws IOException, InterruptedException { + + OutputStream logger = createOutputStream(); + if (!(loggable instanceof Run)) { + throw new IOException("Loggable is not a Run instance: " + loggable.getClass()); + } + Run build = (Run)loggable; + + // Global log filter + for (ConsoleLogFilter filter : ConsoleLogFilter.all()) { + logger = filter.decorateLogger(build, logger); + } + final Job project = build.getParent(); + + // Project specific log filters + if (project instanceof BuildableItemWithBuildWrappers && build instanceof AbstractBuild) { + BuildableItemWithBuildWrappers biwbw = (BuildableItemWithBuildWrappers) project; + for (BuildWrapper bw : biwbw.getBuildWrappersList()) { + logger = bw.decorateLogger((AbstractBuild) build, logger); + } + } + + // Decorate logger by logging method of this build + final ConsoleLogFilter f = getExtraConsoleLogFilter(); + if (f != null) { + LOGGER.log(Level.INFO, "Decorated run {0} by a custom log filter {1}", + new Object[]{this, f}); + logger = f.decorateLogger(build, logger); + } + return new StreamBuildListener(logger, getOwner().getCharset()); + } +} diff --git a/test/src/test/java/hudson/model/RunTest.java b/test/src/test/java/hudson/model/RunTest.java index 7150aac13554..de320bf714ef 100644 --- a/test/src/test/java/hudson/model/RunTest.java +++ b/test/src/test/java/hudson/model/RunTest.java @@ -33,15 +33,21 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; + import jenkins.model.ArtifactManager; import jenkins.model.ArtifactManagerConfiguration; import jenkins.model.ArtifactManagerFactory; import jenkins.model.ArtifactManagerFactoryDescriptor; import jenkins.model.Jenkins; +import jenkins.model.logging.impl.CompatFileLogStorage; import jenkins.util.VirtualFile; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.*; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.TestExtension; @@ -51,6 +57,9 @@ public class RunTest { @Rule public JenkinsRule j = new JenkinsRule(); + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + @Issue("JENKINS-17935") @Test public void getDynamicInvisibleTransientAction() throws Exception { TransientBuildActionFactory.all().add(0, new TransientBuildActionFactory() { @@ -113,4 +122,17 @@ public static final class Factory extends ArtifactManagerFactory { } } + @Test + @Issue("JENKINS-52867") + public void canDeleteLogsInCompatFileLogStorage() throws Exception { + FreeStyleProject prj = j.createFreeStyleProject(); + + final FreeStyleBuild build = j.buildAndAssertSuccess(prj); + assertThat(build.getLogStorage(), instanceOf(CompatFileLogStorage.class)); + assertTrue(build.getLogFile().exists()); + + build.deleteLog(); + assertFalse("Build log has not been deleted", build.getLogFile().exists()); + } + } diff --git a/test/src/test/java/jenkins/model/logging/LogStorageFactoryTest.java b/test/src/test/java/jenkins/model/logging/LogStorageFactoryTest.java new file mode 100644 index 000000000000..2ea955ddb1ba --- /dev/null +++ b/test/src/test/java/jenkins/model/logging/LogStorageFactoryTest.java @@ -0,0 +1,134 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. and other Jenkins 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.model.logging; + +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.RunTest; +import hudson.tasks.BatchFile; +import hudson.tasks.Shell; +import jenkins.model.logging.impl.CompatFileLogStorage; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; +import org.jvnet.hudson.test.recipes.LocalData; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + + +@For(LogStorageFactory.class) +public class LogStorageFactoryTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private MockLogStorageFactory factory; + + @Before + public void setup() { + factory = j.jenkins.getExtensionList(LogStorageFactory.class) + .get(MockLogStorageFactoryImpl.class); + factory.setBaseDir(tmpDir.getRoot()); + } + + @Test + @Issue("JENKINS-38313") + public void shouldSetLogStorageWhenRequired() throws Exception { + FreeStyleProject prj = j.createFreeStyleProject(); + prj.getBuildersList().add( + hudson.remoting.Launcher.isWindows() + ? new BatchFile("echo Hello") + : new Shell("echo Hello") + ); + factory.alterLogStorageFor(prj); + + final FreeStyleBuild build = j.buildAndAssertSuccess(prj); + factory.assertWasInvokedFor(build); + assertThat(build.getLogStorage(), instanceOf(MockLogStorage.class)); + assertThat(build.getLogFile(), equalTo(factory.getLogFor(build))); + assertThat(build.getLog(), containsString("Hello")); + assertTrue(build.getLogFile().exists()); + } + + @Test + @Issue("JENKINS-38313") + public void shouldFallbackToTheDefaultFileStorage() throws Exception { + FreeStyleProject prj = j.createFreeStyleProject(); + prj.getBuildersList().add( + hudson.remoting.Launcher.isWindows() + ? new BatchFile("echo Hello") + : new Shell("echo Hello") + ); + + final FreeStyleBuild build = j.buildAndAssertSuccess(prj); + factory.assertWasInvokedFor(build); + assertThat(build.getLogStorage(), instanceOf(CompatFileLogStorage.class)); + assertThat(build.getLog(), containsString("Hello")); + assertTrue(build.getLogFile().exists()); + } + + /** + * Loads job from configuration which has no storage section. + */ + @Test + @Issue("JENKINS-38313") + @LocalData + public void backwardCompatibility() throws Exception { + FreeStyleProject prj = (FreeStyleProject)j.jenkins.getItem("parameterized"); + FreeStyleBuild build = prj.getBuildByNumber(1); + assertThat(build.getLogStorage(), instanceOf(CompatFileLogStorage.class)); + } + + @Test + @Issue("JENKINS-52867") + public void canDeleteLogsInACustomStorage() throws Exception { + FreeStyleProject prj = j.createFreeStyleProject(); + factory.alterLogStorageFor(prj); + + final FreeStyleBuild build = j.buildAndAssertSuccess(prj); + factory.assertWasInvokedFor(build); + assertThat(build.getLogStorage(), instanceOf(MockLogStorage.class)); + assertThat(build.getLogFile(), equalTo(factory.getLogFor(build))); + assertTrue(build.getLogFile().exists()); + + build.deleteLog(); + assertFalse("Build log has not been deleted", build.getLogFile().exists()); + } + + @TestExtension + public static final class MockLogStorageFactoryImpl extends MockLogStorageFactory { + + } +} diff --git a/test/src/test/java/jenkins/model/logging/MockLogStorage.java b/test/src/test/java/jenkins/model/logging/MockLogStorage.java new file mode 100644 index 000000000000..563cffa15eb2 --- /dev/null +++ b/test/src/test/java/jenkins/model/logging/MockLogStorage.java @@ -0,0 +1,48 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. and other Jenkins 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.model.logging; + +import jenkins.model.logging.impl.AbstractFileLogStorage; + +import java.io.File; + +/** + * Mock log storage for tests. + * @author Oleg Nenashev + * @since TODO + */ +public class MockLogStorage extends AbstractFileLogStorage { + + private final File logFile; + + public MockLogStorage(Loggable loggable, File logFile) { + super(loggable); + this.logFile = logFile; + } + + @Override + public File getLogFile() { + return logFile; + } +} diff --git a/test/src/test/java/jenkins/model/logging/MockLogStorageFactory.java b/test/src/test/java/jenkins/model/logging/MockLogStorageFactory.java new file mode 100644 index 000000000000..3fcff25f1c03 --- /dev/null +++ b/test/src/test/java/jenkins/model/logging/MockLogStorageFactory.java @@ -0,0 +1,81 @@ +/* + * The MIT License + * + * Copyright 2018 CloudBees, Inc. and other Jenkins 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.model.logging; + +import hudson.model.Job; +import hudson.model.Run; + +import javax.annotation.CheckForNull; +import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class MockLogStorageFactory extends LogStorageFactory { + + @CheckForNull File baseDir; + + private transient Set> jobCache = new HashSet<>(); + private transient Map logCache = new HashMap<>(); + + private transient Set invocationList = new HashSet<>(); + + public void setBaseDir(File baseDir) { + this.baseDir = baseDir; + } + + public void alterLogStorageFor(Job job) { + jobCache.add(job); + } + + @CheckForNull + public File getLogFor(Loggable loggable) { + return logCache.get(loggable); + } + + @Override + protected LogStorage getLogStorage(Loggable object) { + invocationList.add(object); + + if (object instanceof Run) { + Run run = (Run) object; + Job parent = run.getParent(); + if (jobCache.contains(parent)) { + File logDestination = new File(baseDir, parent.getFullName() + "/" + run.getNumber() + ".txt"); + logDestination.getParentFile().mkdirs(); + logCache.put(object, logDestination); + return new MockLogStorage(object, logDestination); + } + } + + return null; + } + + public void assertWasInvokedFor(Loggable object) throws AssertionError { + if (!invocationList.contains(object)) { + throw new AssertionError("Log storage factory was not invoked for " + object); + } + } +} diff --git a/test/src/test/resources/jenkins/model/logging/LogStorageFactoryTest/backwardCompatibility.zip b/test/src/test/resources/jenkins/model/logging/LogStorageFactoryTest/backwardCompatibility.zip new file mode 100644 index 000000000000..0fe0cea6ff32 Binary files /dev/null and b/test/src/test/resources/jenkins/model/logging/LogStorageFactoryTest/backwardCompatibility.zip differ