diff --git a/README.md b/README.md index d9b867323..2cda8d425 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ User documentation: * [Remoting 3 Compatibility Notes](docs/remoting-3-compatibility.md) * [Remoting Protocols](docs/protocols.md) - Overview of protocols integrated with Jenkins * [Remoting Configuration](docs/configuration.md) - Configuring remoting agents +* [Logging](docs/logging.md) - Logging +* [Work Directory](docs/workDir.md) - Remoting work directory (new in Remoting `TODO`) * [Jenkins Specifics](docs/jenkins-specifics.md) - Notes on using remoting in Jenkins * [Troubleshooting](docs/troubleshooting.md) - Investigating and solving common remoting issues diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 000000000..8155ed489 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,46 @@ +Logging +=== + +In Remoting logging is powered by the standard `java.util.logging` engine. +The default behavior depends on the [Work Directory](workDir.md) mode. + +### Configuration + +In order to configure logging it is possible to use an external property file, path to which can be defined using the `-loggingConfig` CLI option or the `java.util.logging.config.file` system property. + +If logging is configured via `-loggingConfig`, some messages printed before the logging system initialization may be missing in startup logs configured by this option. + +See details about the property file format +in [Oracle documentation](https://docs.oracle.com/cd/E19717-01/819-7753/6n9m71435/index.html) +and [this guide](http://tutorials.jenkov.com/java-logging/configuration.html). +Note that `ConsoleHandler` won't be enabled by default if this option is specified. + +### Default behavior with work directory + +With work directory Remoting automatically writes logs to the disk. +This is a main difference from the legacy mode without workDir. + +Logging destinations: + +* STDOUT and STDERR + * Logs include `java.util.logging` and messages printed to _STDOUT/STDERR_ directly. +* Files - `${workDir}/${internalDir}/logs` directory + * File base name - `remoting.log` + * Logs are being automatically rotated. + By default, Remoting keeps 5 10MB files + * Default logging level - `INFO` + * If the legacy `-agentLog` or `-slaveLog` option is enabled, this file logging will be disabled. + +If `-agentLog` or `-slaveLog` are not specified, `${workDir}/${internalDir}/logs` directory will be created during the work directory initialization (if required). + + + +### Default behavior without work directory (legacy mode) + +* By default, all logs within the system are being sent to _STDOUT/STDERR_ using `java.util.logging`. +* If `-agentLog` or `-slaveLog` option is specified, the log will be also forwarded to the specified file + * The existing file will be overridden on startup + * Remoting does not perform automatic log rotation of this log file + +Particular Jenkins components use external features to provide better logging in the legacy mode. +E.g. Windows agent services generate logs using features provided by [Windows Service Wrapper (WinSW)](https://github.com/kohsuke/winsw/). \ No newline at end of file diff --git a/docs/workDir.md b/docs/workDir.md new file mode 100644 index 000000000..fa086e54b --- /dev/null +++ b/docs/workDir.md @@ -0,0 +1,45 @@ +Remoting Work directory +=== + +In Remoting work directory is a storage + +Remoting work directory is available starting from Remoting `TODO`. +Before this version there was no working directory concept in the library itself; +all operations were managed by library users (e.g. Jenkins agent workspaces). + +### Before Remoting TODO + +* There is no work directory management in Remoting itself +* Logs are not being persisted to the disk unless `-slaveLog` option is specified +* JAR Cache is being stored in `${user.home}/.jenkins` unless `-jarCache` option is specified + +### After Remoting TODO + +Due to compatibility reasons, Remoting retains the legacy behavior by default. +Work directory can be enabled using the `-workDir` option in CLI or via the `TODO` [system property](configuration.md). + +Once the option is enabled, Remoting starts using the following structure: + +``` +${WORKDIR} + |_ ${INTERNAL_DIR} - defined by '-internalDir', 'remoting' by default + |_ jarCache - JAR Cache + |_ logs - Remoting logs + |_ ... - Other directories contributed by library users +``` + +Structure of the `logs` directory depends on the logging settings. +See [this page](logging.md) for more information. + +### Migrating to work directories in Jenkins + +:exclamation: Remoting does not perform migration from the previous structure, +because it cannot identify potential external users of the data. + +Once the `-workDir` flag is enabled in Remoting, admins are expected to do the following: + +1. Remove the `${user.home}/.jenkins` directory if there is no other Remoting instances running under the same user. +2. Consider upgrading configurations of agents in order to enable Work Directories + * SSH agents can be configured in agent settings. + * JNLP agents should be started with the `-workDir` parameter. + * See [JENKINS-TODO](TODO) for more information about changes in Jenkins plugins, which enable work directories by default. \ No newline at end of file diff --git a/src/main/java/hudson/remoting/Engine.java b/src/main/java/hudson/remoting/Engine.java index 15484e12d..65734d7e0 100644 --- a/src/main/java/hudson/remoting/Engine.java +++ b/src/main/java/hudson/remoting/Engine.java @@ -29,9 +29,12 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.PrintStream; import java.net.Socket; import java.net.URL; +import java.nio.file.Path; import java.security.AccessController; import java.security.KeyManagementException; import java.security.KeyStore; @@ -68,6 +71,7 @@ import org.jenkinsci.remoting.engine.JnlpConnectionStateListener; import org.jenkinsci.remoting.engine.JnlpProtocolHandler; import org.jenkinsci.remoting.engine.JnlpProtocolHandlerFactory; +import org.jenkinsci.remoting.engine.WorkDirManager; import org.jenkinsci.remoting.protocol.IOHub; import org.jenkinsci.remoting.protocol.cert.BlindTrustX509ExtendedTrustManager; import org.jenkinsci.remoting.protocol.cert.DelegatingX509ExtendedTrustManager; @@ -150,7 +154,62 @@ public void run() { */ private boolean keepAlive = true; - private JarCache jarCache = new FileSystemJarCache(new File(System.getProperty("user.home"),".jenkins/cache/jars"),true); + + /** + * Default JAR cache location for disabled workspace Manager. + */ + private static final File DEFAULT_NOWS_JAR_CACHE_LOCATION = + new File(System.getProperty("user.home"),".jenkins/cache/jars"); + + @CheckForNull + private JarCache jarCache = null; + + /** + * Specifies a destination for the agent log. + * If specified, this option overrides the default destination within {@link #workDir}. + * If both this options and {@link #workDir} is not set, the log will not be generated. + * @since TODO + */ + @CheckForNull + private Path agentLog; + + /** + * Specified location of the property file with JUL settings. + * @since TODO + */ + @CheckForNull + private Path loggingConfigFilePath = null; + + /** + * Specifies a default working directory of the remoting instance. + * If specified, this directory will be used to store logs, JAR cache, etc. + *

+ * In order to retain compatibility, the option is disabled by default. + *

+ * Jenkins specifics: This working directory is expected to be equal to the agent root specified in Jenkins configuration. + * @since TODO + */ + @CheckForNull + public Path workDir = null; + + /** + * Specifies a directory within {@link #workDir}, which stores all the remoting-internal files. + *

+ * This option is not expected to be used frequently, but it allows remoting users to specify a custom + * storage directory if the default {@code remoting} directory is consumed by other stuff. + * @since TODO + */ + @Nonnull + public String internalDir = WorkDirManager.DirType.INTERNAL_DIR.getDefaultLocation(); + + /** + * Fail the initialization if the workDir or internalDir are missing. + * This option presumes that the workspace structure gets initialized previously in order to ensure that we do not start up with a borked instance + * (e.g. if a filesystem mount gets disconnected). + * @since TODO + */ + @Nonnull + public boolean failIfWorkDirIsMissing = WorkDirManager.DEFAULT_FAIL_IF_WORKDIR_IS_MISSING; private DelegatingX509ExtendedTrustManager agentTrustManager = new DelegatingX509ExtendedTrustManager(new BlindTrustX509ExtendedTrustManager()); @@ -165,12 +224,68 @@ public Engine(EngineListener listener, List hudsonUrls, String secretKey, S } /** - * Configures JAR caching for better performance. + * Starts the engine. + * The procedure initializes the working directory and all the required environment + * @throws IOException Initialization error + * @since TODO + */ + public synchronized void startEngine() throws IOException { + + @CheckForNull File jarCacheDirectory = null; + + // Prepare the working directory if required + if (workDir != null) { + final WorkDirManager workDirManager = WorkDirManager.getInstance(); + if (jarCache != null) { + // Somebody has already specificed Jar Cache, hence we do not need it in the workspace. + workDirManager.disable(WorkDirManager.DirType.JAR_CACHE_DIR); + } + + if (loggingConfigFilePath != null) { + workDirManager.setLoggingConfig(loggingConfigFilePath.toFile()); + } + + final Path path = workDirManager.initializeWorkDir(workDir.toFile(), internalDir, failIfWorkDirIsMissing); + jarCacheDirectory = workDirManager.getLocation(WorkDirManager.DirType.JAR_CACHE_DIR); + workDirManager.setupLogging(path, agentLog); + } else if (jarCache != null) { + LOGGER.log(Level.WARNING, "No Working Directory. Using the legacy JAR Cache location: {0}", DEFAULT_NOWS_JAR_CACHE_LOCATION); + jarCacheDirectory = DEFAULT_NOWS_JAR_CACHE_LOCATION; + } + + if (jarCache == null){ + if (jarCacheDirectory == null) { + // Should never happen in the current code + throw new IOException("Cannot find the JAR Cache location"); + } + LOGGER.log(Level.FINE, "Using standard File System JAR Cache. Root Directory is {0}", jarCacheDirectory); + jarCache = new FileSystemJarCache(jarCacheDirectory, true); + } else { + LOGGER.log(Level.INFO, "Using custom JAR Cache: {0}", jarCache); + } + + // Start the engine thread + this.start(); + } + + /** + * Configures custom JAR Cache location. + * Starting from TODO, this option disables JAR Caching in the working directory. + * @param jarCache JAR Cache to be used * @since 2.24 */ - public void setJarCache(JarCache jarCache) { + public void setJarCache(@Nonnull JarCache jarCache) { this.jarCache = jarCache; } + + /** + * Sets path to the property file with JUL settings. + * @param filePath JAR Cache to be used + * @since TODO + */ + public void setLoggingConfigFile(@Nonnull Path filePath) { + this.loggingConfigFilePath = filePath; + } /** * Provides Jenkins URL if available. @@ -198,6 +313,44 @@ public void setNoReconnect(boolean noReconnect) { this.noReconnect = noReconnect; } + /** + * Sets the destination for agent logs. + * @param agentLog Path to the agent log. + * If {@code null}, the engine will pick the default behavior depending on the {@link #workDir} value + * @since TODO + */ + public void setAgentLog(@CheckForNull Path agentLog) { + this.agentLog = agentLog; + } + + /** + * Specified a path to the work directory. + * @param workDir Path to the working directory of the remoting instance. + * {@code null} Disables the working directory. + * @since TODO + */ + public void setWorkDir(@CheckForNull Path workDir) { + this.workDir = workDir; + } + + /** + * Specifies name of the internal data directory within {@link #workDir}. + * @param internalDir Directory name + * @since TODO + */ + public void setInternalDir(@Nonnull String internalDir) { + this.internalDir = internalDir; + } + + /** + * Sets up behavior if the workDir or internalDir are missing during the startup. + * This option presumes that the workspace structure gets initialized previously in order to ensure that we do not start up with a borked instance + * (e.g. if a filesystem mount gets disconnected). + * @param failIfWorkDirIsMissing Flag + * @since TODO + */ + public void setFailIfWorkDirIsMissing(boolean failIfWorkDirIsMissing) { this.failIfWorkDirIsMissing = failIfWorkDirIsMissing; } + /** * Returns {@code true} if and only if the socket to the master will have {@link Socket#setKeepAlive(boolean)} set. * @@ -239,6 +392,7 @@ public void removeListener(EngineListener el) { @Override public void run() { + // Create the engine try { IOHub hub = IOHub.create(executor); try { diff --git a/src/main/java/hudson/remoting/FileSystemJarCache.java b/src/main/java/hudson/remoting/FileSystemJarCache.java index 09471d5c6..173697062 100644 --- a/src/main/java/hudson/remoting/FileSystemJarCache.java +++ b/src/main/java/hudson/remoting/FileSystemJarCache.java @@ -37,11 +37,15 @@ public class FileSystemJarCache extends JarCacheSupport { private final Map checksumsByPath = new HashMap<>(); /** + * @param rootDir + * Root directory. * @param touch * True to touch the cached jar file that's used. This enables external LRU based cache * eviction at the expense of increased I/O. + * @throws IllegalArgumentException + * Root directory is {@code null} or not writable. */ - public FileSystemJarCache(File rootDir, boolean touch) { + public FileSystemJarCache(@Nonnull File rootDir, boolean touch) { this.rootDir = rootDir; this.touch = touch; if (rootDir==null) @@ -54,6 +58,11 @@ public FileSystemJarCache(File rootDir, boolean touch) { } } + @Override + public String toString() { + return String.format("FileSystem JAR Cache: path=%s, touch=%s", rootDir, Boolean.toString(touch)); + } + @Override protected URL lookInCache(Channel channel, long sum1, long sum2) throws IOException, InterruptedException { File jar = map(sum1, sum2); diff --git a/src/main/java/hudson/remoting/Launcher.java b/src/main/java/hudson/remoting/Launcher.java index a63bb8b45..753204b2c 100644 --- a/src/main/java/hudson/remoting/Launcher.java +++ b/src/main/java/hudson/remoting/Launcher.java @@ -27,14 +27,19 @@ import hudson.remoting.Channel.Mode; import java.io.FileInputStream; import java.io.UnsupportedEncodingException; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchProviderException; import java.security.PrivilegedActionException; import java.security.cert.CertificateFactory; import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManagerFactory; + +import org.jenkinsci.remoting.engine.WorkDirManager; import org.jenkinsci.remoting.util.IOUtils; import org.w3c.dom.Document; import org.w3c.dom.NodeList; @@ -95,7 +100,7 @@ import javax.crypto.spec.SecretKeySpec; /** - * Entry point for running a {@link Channel}. This is the main method of the slave JVM. + * Entry point for running a {@link Channel}. This is the main method of the agent JVM. * *

* This class also defines several methods for @@ -111,7 +116,13 @@ public class Launcher { @Option(name="-ping") public boolean ping = true; - @Option(name="-slaveLog", usage="create local slave error log") + /** + * Specifies a destination for error logs. + * If specified, this option overrides the default destination within {@link #workDir}. + * If both this options and {@link #workDir} is not set, the log will not be generated. + */ + @Option(name="-agentLog", aliases = {"-slaveLog"}, usage="Local agent error log destination (overrides workDir)") + @CheckForNull public File slaveLog = null; @Option(name="-text",usage="encode communication with the master with base64. " + @@ -165,6 +176,14 @@ public void addClasspath(String pathList) throws Exception { @Option(name="-jar-cache",metaVar="DIR",usage="Cache directory that stores jar files sent from the master") public File jarCache = new File(System.getProperty("user.home"),".jenkins/cache/jars"); + /** + * Specified location of the property file with JUL settings. + * @since TODO + */ + @CheckForNull + @Option(name="-loggingConfig",usage="Path to the property file with java.util.logging settings") + public File loggingConfigFilePath = null; + @Option(name = "-cert", usage = "Specify additional X.509 encoded PEM certificates to trust when connecting to Jenkins " + "root URLs. If starting with @ then the remainder is assumed to be the name of the " + @@ -210,6 +229,45 @@ public boolean verify(String s, SSLSession sslSession) { usage = "Disable TCP socket keep alive on connection to the master.") public boolean noKeepAlive = false; + /** + * Specifies a default working directory of the remoting instance. + * If specified, this directory will be used to store logs, JAR cache, etc. + *

+ * In order to retain compatibility, the option is disabled by default. + *

+ * Jenkins specifics: This working directory is expected to be equal to the agent root specified in Jenkins configuration. + * @since TODO + */ + @Option(name = "-workDir", + usage = "Declares the working directory of the remoting instance (stores cache and logs by default)") + @CheckForNull + public File workDir = null; + + /** + * Specifies a directory within {@link #workDir}, which stores all the remoting-internal files. + *

+ * This option is not expected to be used frequently, but it allows remoting users to specify a custom + * storage directory if the default {@code remoting} directory is consumed by other stuff. + * @since TODO + */ + @Option(name = "-internalDir", + usage = "Specifies a name of the internal files within a working directory ('remoting' by default)", + depends = "-workDir") + @Nonnull + public String internalDir = WorkDirManager.DirType.INTERNAL_DIR.getDefaultLocation(); + + /** + * Fail the initialization if the workDir or internalDir are missing. + * This option presumes that the workspace structure gets initialized previously in order to ensure that we do not start up with a borked instance + * (e.g. if a filesystem mount gets disconnected). + * @since TODO + */ + @Option(name = "-failIfWorkDirIsMissing", + usage = "Fails the initialization if the requested workDir or internalDir are missing ('false' by default)", + depends = "-workDir") + @Nonnull + public boolean failIfWorkDirIsMissing = WorkDirManager.DEFAULT_FAIL_IF_WORKDIR_IS_MISSING; + public static void main(String... args) throws Exception { Launcher launcher = new Launcher(); CmdLineParser parser = new CmdLineParser(launcher); @@ -226,9 +284,18 @@ public static void main(String... args) throws Exception { @edu.umd.cs.findbugs.annotations.SuppressWarnings("DM_DEFAULT_ENCODING") // log file, just like console output, should be in platform default encoding public void run() throws Exception { - if (slaveLog!=null) { - System.setErr(new PrintStream(new TeeOutputStream(System.err,new FileOutputStream(slaveLog)))); + + // Create and verify working directory and logging + // TODO: The pass-through for the JNLP mode has been added in JENKINS-39817. But we still need to keep this parameter in + // consideration for other modes (TcpServer, TcpClient, etc.) to retain the legacy behavior. + // On the other hand, in such case there is no need to invoke WorkDirManager and handle the double initialization logic + final WorkDirManager workDirManager = WorkDirManager.getInstance(); + final Path internalDirPath = workDirManager.initializeWorkDir(workDir, internalDir, failIfWorkDirIsMissing); + if (slaveLog != null) { + workDirManager.disable(WorkDirManager.DirType.LOGS_DIR); } + workDirManager.setupLogging(internalDirPath, slaveLog != null ? slaveLog.toPath() : null); + if(auth!=null) { final int idx = auth.indexOf(':'); if(idx<0) throw new CmdLineException(null, "No ':' in the -auth option"); @@ -256,6 +323,23 @@ public void run() throws Exception { if (this.noKeepAlive) { jnlpArgs.add("-noKeepAlive"); } + if (slaveLog != null) { + jnlpArgs.add("-agentLog"); + jnlpArgs.add(slaveLog.getPath()); + } + if (loggingConfigFilePath != null) { + jnlpArgs.add("-loggingConfig"); + jnlpArgs.add(loggingConfigFilePath.getAbsolutePath()); + } + if (this.workDir != null) { + jnlpArgs.add("-workDir"); + jnlpArgs.add(workDir.getAbsolutePath()); + jnlpArgs.add("-internalDir"); + jnlpArgs.add(internalDir); + if (failIfWorkDirIsMissing) { + jnlpArgs.add("-failIfWorkDirIsMissing"); + } + } if (candidateCertificates != null && !candidateCertificates.isEmpty()) { for (String c: candidateCertificates) { jnlpArgs.add("-cert"); diff --git a/src/main/java/hudson/remoting/TeeOutputStream.java b/src/main/java/hudson/remoting/TeeOutputStream.java index 56051fce0..6967324e4 100644 --- a/src/main/java/hudson/remoting/TeeOutputStream.java +++ b/src/main/java/hudson/remoting/TeeOutputStream.java @@ -16,6 +16,9 @@ */ package hudson.remoting; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + import java.io.FilterOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -27,7 +30,8 @@ * * @version $Id: TeeOutputStream.java 610010 2008-01-08 14:50:59Z niallp $ */ -class TeeOutputStream extends FilterOutputStream { +@Restricted(NoExternalUse.class) +public class TeeOutputStream extends FilterOutputStream { /** the second OutputStream to write to */ protected OutputStream branch; diff --git a/src/main/java/hudson/remoting/jnlp/Main.java b/src/main/java/hudson/remoting/jnlp/Main.java index 60e2ce480..d262369bf 100644 --- a/src/main/java/hudson/remoting/jnlp/Main.java +++ b/src/main/java/hudson/remoting/jnlp/Main.java @@ -32,6 +32,8 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; + +import org.jenkinsci.remoting.engine.WorkDirManager; import org.jenkinsci.remoting.util.IOUtils; import org.kohsuke.args4j.Option; import org.kohsuke.args4j.CmdLineParser; @@ -49,6 +51,10 @@ import hudson.remoting.Engine; import hudson.remoting.EngineListener; +import java.nio.file.Path; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; /** * Entry point to JNLP slave agent. @@ -96,6 +102,63 @@ public class Main { "certificate file to read.") public List candidateCertificates; + /** + * Specifies a destination for error logs. + * If specified, this option overrides the default destination within {@link #workDir}. + * If both this options and {@link #workDir} is not set, the log will not be generated. + * @since TODO + */ + @Option(name="-agentLog", usage="Local agent error log destination (overrides workDir)") + @CheckForNull + public File agentLog = null; + + /** + * Specified location of the property file with JUL settings. + * @since TODO + */ + @CheckForNull + @Option(name="-loggingConfig",usage="Path to the property file with java.util.logging settings") + public File loggingConfigFile = null; + + /** + * Specifies a default working directory of the remoting instance. + * If specified, this directory will be used to store logs, JAR cache, etc. + *

+ * In order to retain compatibility, the option is disabled by default. + *

+ * Jenkins specifics: This working directory is expected to be equal to the agent root specified in Jenkins configuration. + * @since TODO + */ + @Option(name = "-workDir", + usage = "Declares the working directory of the remoting instance (stores cache and logs by default)") + @CheckForNull + public File workDir = null; + + /** + * Specifies a directory within {@link #workDir}, which stores all the remoting-internal files. + *

+ * This option is not expected to be used frequently, but it allows remoting users to specify a custom + * storage directory if the default {@code remoting} directory is consumed by other stuff. + * @since TODO + */ + @Option(name = "-internalDir", + usage = "Specifies a name of the internal files within a working directory ('remoting' by default)", + depends = "-workDir") + @Nonnull + public String internalDir = WorkDirManager.DirType.INTERNAL_DIR.getDefaultLocation(); + + /** + * Fail the initialization if the workDir or internalDir are missing. + * This option presumes that the workspace structure gets initialized previously in order to ensure that we do not start up with a borked instance + * (e.g. if a filesystem mount gets disconnected). + * @since TODO + */ + @Option(name = "-failIfWorkDirIsMissing", + usage = "Fails the initialization if the requested workDir or internalDir are missing ('false' by default)", + depends = "-workDir") + @Nonnull + public boolean failIfWorkDirIsMissing = WorkDirManager.DEFAULT_FAIL_IF_WORKDIR_IS_MISSING; + /** * @since 2.24 */ @@ -151,7 +214,7 @@ public static void _main(String[] args) throws IOException, InterruptedException public void main() throws IOException, InterruptedException { Engine engine = createEngine(); - engine.start(); + engine.startEngine(); try { engine.join(); LOGGER.fine("Engine has died"); @@ -179,6 +242,15 @@ public Engine createEngine() { engine.setJarCache(new FileSystemJarCache(jarCache,true)); engine.setNoReconnect(noReconnect); engine.setKeepAlive(!noKeepAlive); + + // TODO: ideally logging should be initialized before the "Setting up slave" entry + if (agentLog != null) { + engine.setAgentLog(agentLog.toPath()); + } + if (loggingConfigFile != null) { + engine.setLoggingConfigFile(loggingConfigFile.toPath()); + } + if (candidateCertificates != null && !candidateCertificates.isEmpty()) { CertificateFactory factory; try { @@ -246,6 +318,14 @@ public Engine createEngine() { } engine.setCandidateCertificates(certificates); } + + // Working directory settings + if (workDir != null) { + engine.setWorkDir(workDir.toPath()); + } + engine.setInternalDir(internalDir); + engine.setFailIfWorkDirIsMissing(failIfWorkDirIsMissing); + return engine; } diff --git a/src/main/java/org/jenkinsci/remoting/engine/WorkDirManager.java b/src/main/java/org/jenkinsci/remoting/engine/WorkDirManager.java new file mode 100644 index 000000000..2f3266afa --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/WorkDirManager.java @@ -0,0 +1,384 @@ +/* + * The MIT License + * + * Copyright (c) 2016 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package org.jenkinsci.remoting.engine; + +import hudson.remoting.TeeOutputStream; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.logging.ConsoleHandler; +import java.util.logging.FileHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + * Performs working directory management in remoting. + * Using this manager remoting can initialize its working directory and put the data there. + * The structure of the directory is described in {@link DirType}. + * @author Oleg Nenashev + * @since TODO + */ +@Restricted(NoExternalUse.class) +public class WorkDirManager { + + private static final Logger LOGGER = Logger.getLogger(WorkDirManager.class.getName()); + + private static WorkDirManager INSTANCE = new WorkDirManager(); + + /** + * Default value for the behavior when the requested working directory is missing. + * The default value is {@code false}, because otherwise agents would fail on the first startup. + */ + public static final boolean DEFAULT_FAIL_IF_WORKDIR_IS_MISSING = false; + + /** + * Regular expression, which declares restrictions of the remoting internal directory symbols + */ + public static final String SUPPORTED_INTERNAL_DIR_NAME_MASK = "[a-zA-Z0-9._-]*"; + + /** + * Name of the standard System Property, which points to the {@code java.util.logging} config file. + */ + public static final String JUL_CONFIG_FILE_SYSTEM_PROPERTY_NAME = "java.util.logging.config.file"; + + // Status data + boolean loggingInitialized; + + @CheckForNull + private File loggingConfigFile = null; + + /** + * Provides a list of directories, which should not be initialized in the Work Directory. + */ + private final Set disabledDirectories = new HashSet<>(); + + /** + * Cache of initialized directory locations. + */ + private final Map directories = new HashMap<>(); + + + private WorkDirManager() { + // Cannot be instantiated outside + } + + /** + * Retrieves the instance of the {@link WorkDirManager}. + * Currently the implementation is hardcoded, but it may change in the future. + * @return Workspace manager + */ + @Nonnull + public static WorkDirManager getInstance() { + return INSTANCE; + } + + /*package*/ static void reset() { + INSTANCE = new WorkDirManager(); + LogManager.getLogManager().reset(); + } + + public void disable(@Nonnull DirType dir) { + disabledDirectories.add(dir); + } + + @CheckForNull + public File getLocation(@Nonnull DirType type) { + return directories.get(type); + } + + /** + * Sets path to the Logging JUL property file with logging settings. + * @param configFile config file + */ + public void setLoggingConfig(@Nonnull File configFile) { + this.loggingConfigFile = configFile; + } + + /** + * Gets path to the property file with JUL settings. + * This method checks the value passed from {@link #setLoggingConfig(java.io.File)} first. + * If the value is not specified, it also checks the standard {@code java.util.logging.config.file} System property. + * @return Path to the logging config file. + * {@code null} if it cannot be found. + */ + @CheckForNull + public File getLoggingConfigFile() { + if (loggingConfigFile != null) { + return loggingConfigFile; + } + + String property = System.getProperty(JUL_CONFIG_FILE_SYSTEM_PROPERTY_NAME); + if (property != null && !property.trim().isEmpty()) { + return new File(property); + } + + return null; + } + + //TODO: New interfaces should ideally use Path instead of File + /** + * Initializes the working directory for the agent. + * Within the working directory the method also initializes a working directory for internal needs (like logging) + * @param workDir Working directory + * @param internalDir Name of the remoting internal data directory within the working directory. + * The range of the supported symbols is restricted to {@link #SUPPORTED_INTERNAL_DIR_NAME_MASK}. + * @param failIfMissing Fail the initialization if the workDir or internalDir are missing. + * This option presumes that the workspace structure gets initialized previously in order to ensure that we do not start up with a borked instance + * (e.g. if a mount gets disconnected). + * @return Initialized directory for internal files within workDir or {@code null} if it is disabled + * @throws IOException Workspace allocation issue (e.g. the specified directory is not writable). + * In such case Remoting should not start up at all. + */ + @CheckForNull + public Path initializeWorkDir(final @CheckForNull File workDir, final @Nonnull String internalDir, final boolean failIfMissing) throws IOException { + + if (!internalDir.matches(SUPPORTED_INTERNAL_DIR_NAME_MASK)) { + throw new IOException(String.format("Name of %s ('%s') is not compliant with the required format: %s", + DirType.INTERNAL_DIR, internalDir, SUPPORTED_INTERNAL_DIR_NAME_MASK)); + } + + if (workDir == null) { + // TODO: this message likely suppresses the connection setup on CI + // LOGGER.log(Level.WARNING, "Agent working directory is not specified. Some functionality introduced in Remoting 3 may be disabled"); + return null; + } else { + // Verify working directory + verifyDirectory(workDir, DirType.WORK_DIR, failIfMissing); + directories.put(DirType.WORK_DIR, workDir); + + // Create a subdirectory for remoting operations + final File internalDirFile = new File(workDir, internalDir); + verifyDirectory(internalDirFile, DirType.INTERNAL_DIR, failIfMissing); + directories.put(DirType.INTERNAL_DIR, internalDirFile); + + // Create the directory on-demand + final Path internalDirPath = internalDirFile.toPath(); + Files.createDirectories(internalDirPath); + LOGGER.log(Level.INFO, "Using {0} as a remoting work directory", internalDirPath); + + // Create components of the internal directory + createInternalDirIfRequired(internalDirFile, DirType.JAR_CACHE_DIR); + createInternalDirIfRequired(internalDirFile, DirType.LOGS_DIR); + + return internalDirPath; + } + } + + private void createInternalDirIfRequired(File internalDir, DirType type) + throws IOException { + if (!disabledDirectories.contains(type)) { + final File directory = new File(internalDir, type.getDefaultLocation()); + verifyDirectory(directory, type, false); + Files.createDirectories(directory.toPath()); + directories.put(type, directory); + } else { + LOGGER.log(Level.FINE, "Skipping the disabled directory: {0}", type.getName()); + } + } + + /** + * Verifies that the directory is compliant with the specified requirements. + * The directory is expected to have {@code RWX} permissions if exists. + * @param dir Directory + * @param type Type of the working directory component to be verified + * @param failIfMissing Fail if the directory is missing + * @throws IOException Verification failure + */ + private static void verifyDirectory(@Nonnull File dir, @Nonnull DirType type, boolean failIfMissing) throws IOException { + if (dir.exists()) { + if (!dir.isDirectory()) { + throw new IOException("The specified " + type + " path points to a non-directory file: " + dir.getPath()); + } + if (!dir.canWrite() || !dir.canRead() || !dir.canExecute()) { + throw new IOException("The specified " + type + " should be fully accessible to the remoting executable (RWX): " + dir.getPath()); + } + } else if (failIfMissing) { + throw new IOException("The " + type + " is missing, but it is expected to exist: " + dir.getPath()); + } + } + + /** + * Sets up logging subsystem in the working directory. + * If the logging system is already initialized, do nothing + * @param internalDirPath Path to the internal remoting directory within the WorkDir. + * If this value and {@code overrideLogPath} are null, the logging subsystem woill not + * be initialized at all + * @param overrideLogPath Overrides the common location of the remoting log. + * If specified, logging system will be initialized in the legacy way. + * If {@code null}, the behavior is defined by {@code internalDirPath}. + * @throws IOException Initialization error + */ + public void setupLogging(@CheckForNull Path internalDirPath, @CheckForNull Path overrideLogPath) throws IOException { + if (loggingInitialized) { + // Do nothing, in Remoting initialization there may be two calls due to the + // legacy -slaveLog behavior implementation. + LOGGER.log(Level.CONFIG, "Logging susystem has been already initialized"); + return; + } + + final File configFile = getLoggingConfigFile(); + if (configFile != null) { + // TODO: There is a risk of second configuration call in the case of java.util.logging.config.file, but it's safe + LOGGER.log(Level.FINE, "Reading Logging configuration from file: {0}", configFile); + try(FileInputStream fis = new FileInputStream(configFile)) { + LogManager.getLogManager().readConfiguration(fis); + } + } + + if (overrideLogPath != null) { // Legacy behavior + System.out.println("Using " + overrideLogPath + " as an agent Error log destination. 'Out' log won't be generated"); + System.out.flush(); // Just in case the channel + System.err.flush(); + System.setErr(new PrintStream(new TeeOutputStream(System.err, new FileOutputStream(overrideLogPath.toFile())))); + this.loggingInitialized = true; + } else if (internalDirPath != null) { // New behavior + System.out.println("Both error and output logs will be printed to " + internalDirPath); + System.out.flush(); + System.err.flush(); + + // Also redirect JUL to files if custom logging is not specified + final File internalDirFile = internalDirPath.toFile(); + createInternalDirIfRequired(internalDirFile, DirType.LOGS_DIR); + final File logsDir = getLocation(DirType.LOGS_DIR); + + // TODO: Forward these logs? Likely no, we do not expect something to get there + //System.setErr(new PrintStream(new TeeOutputStream(System.err, + // new FileOutputStream(new File(logsDir, "remoting.err.log"))))); + //System.setOut(new PrintStream(new TeeOutputStream(System.out, + // new FileOutputStream(new File(logsDir, "remoting.out.log"))))); + + if (configFile == null) { + final Logger rootLogger = Logger.getLogger(""); + final File julLog = new File(logsDir, "remoting.log"); + final FileHandler logHandler = new FileHandler(julLog.getAbsolutePath(), + 10*1024*1024, 5, false); + logHandler.setFormatter(new SimpleFormatter()); + logHandler.setLevel(Level.INFO); + rootLogger.addHandler(logHandler); + + // TODO: Uncomment if there is TeeOutputStream added + // Remove console handler since the logs are going to the file now + // ConsoleHandler consoleHandler = findConsoleHandler(rootLogger); + // if (consoleHandler != null) { + // rootLogger.removeHandler(consoleHandler); + // } + } + + this.loggingInitialized = true; + } else { + // TODO: This message is suspected to break the CI + // System.err.println("WARNING: Log location is not specified (neither -workDir nor -slaveLog/-agentLog set)"); + } + } + + @CheckForNull + private static ConsoleHandler findConsoleHandler(Logger logger) { + for (Handler h : logger.getHandlers()) { + if (h instanceof ConsoleHandler) { + return (ConsoleHandler)h; + } + } + return null; + } + + /** + * Defines components of the Working directory. + * @since TODO + */ + @Restricted(NoExternalUse.class) + public enum DirType { + /** + * Top-level entry of the working directory. + */ + WORK_DIR("working directory", "", null), + /** + * Directory, which stores internal data of the Remoting layer itself. + * This directory is located within {@link #WORK_DIR}. + */ + INTERNAL_DIR("remoting internal directory", "remoting", WORK_DIR), + + /** + * Directory, which stores the JAR Cache. + */ + JAR_CACHE_DIR("JAR Cache directory", "jarCache", INTERNAL_DIR), + + /** + * Directory, which stores logs. + */ + LOGS_DIR("Log directory", "logs", INTERNAL_DIR); + + @Nonnull + private final String name; + + @Nonnull + private final String defaultLocation; + + @CheckForNull + private final DirType parentDir; + + DirType(String name, String defaultLocation, @CheckForNull DirType parentDir) { + this.name = name; + this.defaultLocation = defaultLocation; + this.parentDir = parentDir; + } + + @Override + public String toString() { + return name; + } + + @Nonnull + public String getDefaultLocation() { + return defaultLocation; + } + + @Nonnull + public String getName() { + return name; + } + + @CheckForNull + public DirType getParentDir() { + return parentDir; + } + } +} diff --git a/src/test/java/org/jenkinsci/remoting/engine/WorkDirManagerTest.java b/src/test/java/org/jenkinsci/remoting/engine/WorkDirManagerTest.java new file mode 100644 index 000000000..30d979a3f --- /dev/null +++ b/src/test/java/org/jenkinsci/remoting/engine/WorkDirManagerTest.java @@ -0,0 +1,401 @@ +/* + * + * * The MIT License + * * + * * Copyright (c) 2016-2017 CloudBees, Inc. + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ + +package org.jenkinsci.remoting.engine; + +import org.apache.commons.io.FileUtils; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nonnull; +import org.apache.commons.io.IOUtils; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.jenkinsci.remoting.engine.WorkDirManager.DirType; +import org.junit.After; +import org.junit.Before; +import org.jvnet.hudson.test.Bug; + +/** + * Tests of {@link WorkDirManager} + * @author Oleg Nenashev. + * @since TODO + */ +public class WorkDirManagerTest { + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + @Before + public void resetWorkDirManagerBeforeTest() { + WorkDirManager.reset(); + } + + @After + public void resetWorkDirManagerAfterTest() { + // Resets logging settings + WorkDirManager.reset(); + } + + @Test + public void shouldInitializeCorrectlyForExistingDirectory() throws Exception { + final File dir = tmpDir.newFolder("foo"); + + // Probe files to confirm the directory does not get wiped + final File probeFileInWorkDir = new File(dir, "probe.txt"); + FileUtils.write(probeFileInWorkDir, "Hello!"); + final File remotingDir = new File(dir, DirType.INTERNAL_DIR.getDefaultLocation()); + Files.createDirectory(remotingDir.toPath()); + final File probeFileInInternalDir = new File(remotingDir, "/probe.txt"); + FileUtils.write(probeFileInInternalDir, "Hello!"); + + // Initialize and check the results + final Path createdDir = WorkDirManager.getInstance().initializeWorkDir(dir, DirType.INTERNAL_DIR.getDefaultLocation(), false); + assertThat("The initialized " + DirType.INTERNAL_DIR + " differs from the expected one", createdDir.toFile(), equalTo(remotingDir)); + + // Ensure that the files have not been wiped + Assert.assertTrue("Probe file in the " + DirType.WORK_DIR + " has been wiped", probeFileInWorkDir.exists()); + Assert.assertTrue("Probe file in the " + DirType.INTERNAL_DIR + " has been wiped", probeFileInInternalDir.exists()); + + // Ensure that sub directories are in place + assertExists(DirType.JAR_CACHE_DIR); + assertExists(DirType.LOGS_DIR); + } + + @Test + public void shouldPerformMkdirsIfRequired() throws Exception { + final File tmpDirFile = tmpDir.newFolder("foo"); + final File workDir = new File(tmpDirFile, "just/a/long/non/existent/path"); + Assert.assertFalse("The " + DirType.INTERNAL_DIR + " should not exist in the test", workDir.exists()); + + // Probe files to confirm the directory does not get wiped; + final File remotingDir = new File(workDir, DirType.INTERNAL_DIR.getDefaultLocation()); + + // Initialize and check the results + final Path createdDir = WorkDirManager.getInstance().initializeWorkDir(workDir, DirType.INTERNAL_DIR.getDefaultLocation(), false); + assertThat("The initialized " + DirType.INTERNAL_DIR + " differs from the expected one", createdDir.toFile(), equalTo(remotingDir)); + Assert.assertTrue("Remoting " + DirType.INTERNAL_DIR + " should have been initialized", remotingDir.exists()); + } + + @Test + public void shouldProperlyCreateDirectoriesForCustomInternalDirs() throws Exception { + final String internalDirectoryName = "myRemotingLogs"; + final File tmpDirFile = tmpDir.newFolder("foo"); + final File workDir = new File(tmpDirFile, "just/another/path"); + Assert.assertFalse("The " + DirType.WORK_DIR + " should not exist in the test", workDir.exists()); + + // Probe files to confirm the directory does not get wiped; + final File remotingDir = new File(workDir, internalDirectoryName); + + // Initialize and check the results + final Path createdDir = WorkDirManager.getInstance().initializeWorkDir(workDir, internalDirectoryName, false); + assertThat("The initialized " + DirType.INTERNAL_DIR + " differs from the expected one", createdDir.toFile(), equalTo(remotingDir)); + Assert.assertTrue("Remoting " + DirType.INTERNAL_DIR + " should have been initialized", remotingDir.exists()); + } + + @Test + public void shouldFailIfWorkDirIsAFile() throws IOException { + File foo = tmpDir.newFile("foo"); + try { + WorkDirManager.getInstance().initializeWorkDir(foo, DirType.INTERNAL_DIR.getDefaultLocation(), false); + } catch (IOException ex) { + assertThat("Wrong exception message", + ex.getMessage(), containsString("The specified " + DirType.WORK_DIR + " path points to a non-directory file")); + return; + } + Assert.fail("The " + DirType.WORK_DIR + " has been initialized, but it should fail due to the conflicting file"); + } + + @Test + public void shouldFailIfWorkDirIsNotExecutable() throws IOException { + verifyDirectoryFlag(DirType.WORK_DIR, DirectoryFlag.NOT_EXECUTABLE); + } + + @Test + public void shouldFailIfWorkDirIsNotWritable() throws IOException { + verifyDirectoryFlag(DirType.WORK_DIR, DirectoryFlag.NOT_WRITABLE); + } + + + @Test + public void shouldFailIfWorkDirIsNotReadable() throws IOException { + verifyDirectoryFlag(DirType.WORK_DIR, DirectoryFlag.NOT_READABLE); + } + + @Test + public void shouldFailIfInternalDirIsNotExecutable() throws IOException { + verifyDirectoryFlag(DirType.INTERNAL_DIR, DirectoryFlag.NOT_EXECUTABLE); + } + + @Test + public void shouldFailIfInternalDirIsNotWritable() throws IOException { + verifyDirectoryFlag(DirType.INTERNAL_DIR, DirectoryFlag.NOT_WRITABLE); + } + + + @Test + public void shouldFailIfInternalDirIsNotReadable() throws IOException { + verifyDirectoryFlag(DirType.INTERNAL_DIR, DirectoryFlag.NOT_READABLE); + } + + @Test + public void shouldNotSupportPathDelimitersAndSpacesInTheInternalDirName() throws IOException { + File foo = tmpDir.newFolder("foo"); + + assertAllocationFails(foo, " remoting "); + assertAllocationFails(foo, " remoting"); + assertAllocationFails(foo, "directory with spaces"); + assertAllocationFails(foo, "nested/directory"); + assertAllocationFails(foo, "nested\\directory\\in\\Windows"); + assertAllocationFails(foo, "just&a&symbol&I&do¬&like"); + } + + @Test + @Bug(39130) + public void shouldFailToStartupIf_WorkDir_IsMissing_andRequired() throws Exception { + final File tmpDirFile = tmpDir.newFolder("foo"); + final File workDir = new File(tmpDirFile, "just/a/long/non/existent/path"); + Assert.assertFalse("The " + DirType.INTERNAL_DIR + " should not exist in the test", workDir.exists()); + + assertAllocationFailsForMissingDir(workDir, DirType.WORK_DIR); + } + + @Test + @Bug(39130) + public void shouldFailToStartupIf_InternalDir_IsMissing_andRequired() throws Exception { + // Create only the working directory, not the nested one + final File tmpDirFile = tmpDir.newFolder("foo"); + final File workDir = new File(tmpDirFile, "just/a/long/non/existent/path"); + Files.createDirectories(workDir.toPath()); + + assertAllocationFailsForMissingDir(workDir, DirType.INTERNAL_DIR); + } + + @Test + public void shouldNotCreateLogsDirIfDisabled() throws Exception { + final File tmpDirFile = tmpDir.newFolder("foo"); + assertDoesNotCreateDisabledDir(tmpDirFile, DirType.LOGS_DIR); + } + + @Test + public void shouldNotCreateJarCacheIfDisabled() throws Exception { + final File tmpDirFile = tmpDir.newFolder("foo"); + assertDoesNotCreateDisabledDir(tmpDirFile, DirType.JAR_CACHE_DIR); + } + + @Test + public void shouldCreateLogFilesOnTheDisk() throws Exception { + final File workDir = tmpDir.newFolder("workDir"); + final WorkDirManager mngr = WorkDirManager.getInstance(); + mngr.initializeWorkDir(workDir, "remoting", false); + mngr.setupLogging(mngr.getLocation(DirType.INTERNAL_DIR).toPath(), null); + + // Write something to logs + String message = String.format("Just 4 test. My Work Dir is %s", workDir); + Logger.getLogger(WorkDirManager.class.getName()).log(Level.INFO, message); + + // Ensure log files have been created + File logsDir = mngr.getLocation(DirType.LOGS_DIR); + assertFileLogsExist(logsDir, "remoting.log", 0); + + // Ensure the entry has been written + File log0 = new File(logsDir, "remoting.log.0"); + try (FileInputStream istr = new FileInputStream(log0)) { + String contents = IOUtils.toString(istr); + assertThat("Log file " + log0 + " should contain the probe message", contents, containsString(message)); + } + } + + @Test + public void shouldUseLoggingSettingsFromFileDefinedByAPI() throws Exception { + final File loggingConfigFile = new File(tmpDir.getRoot(), "julSettings.prop"); + doTestLoggingConfig(loggingConfigFile, true); + } + + @Test + public void shouldUseLoggingSettingsFromFileDefinedBySystemProperty() throws Exception { + final File loggingConfigFile = new File(tmpDir.getRoot(), "julSettings.prop"); + final String oldValue = System.setProperty(WorkDirManager.JUL_CONFIG_FILE_SYSTEM_PROPERTY_NAME, loggingConfigFile.getAbsolutePath()); + try { + doTestLoggingConfig(loggingConfigFile, false); + } finally { + // TODO: Null check and setting empty string is a weird hack + System.setProperty(WorkDirManager.JUL_CONFIG_FILE_SYSTEM_PROPERTY_NAME, oldValue != null ? oldValue : ""); + } + } + + private void doTestLoggingConfig(File loggingConfigFile, boolean passToManager) throws IOException, AssertionError{ + final File workDir = tmpDir.newFolder("workDir"); + final File customLogDir = tmpDir.newFolder("mylogs"); + + // Create config file + try (FileWriter wr = new FileWriter(loggingConfigFile)) { + // Init FileHandler with default XML formatter + wr.write("handlers= java.util.logging.FileHandler\n"); + // TODO: It won't work well on Windows + wr.write("java.util.logging.FileHandler.pattern=" + customLogDir.getAbsolutePath() + "/mylog.log.%g\n"); + wr.write("java.util.logging.FileHandler.limit=81920\n"); + wr.write("java.util.logging.FileHandler.count=5\n"); + } + + // Init WorkDirManager + final WorkDirManager mngr = WorkDirManager.getInstance(); + if (passToManager) { + mngr.setLoggingConfig(loggingConfigFile); + } + mngr.initializeWorkDir(workDir, "remoting", false); + mngr.setupLogging(mngr.getLocation(DirType.INTERNAL_DIR).toPath(), null); + + // Write something to logs + String message = String.format("Just 4 test. My Work Dir is %s", workDir); + Logger.getLogger(WorkDirManager.class.getName()).log(Level.INFO, message); + + // Assert that logs directory still exists, but has no default logs + assertExists(DirType.LOGS_DIR); + File defaultLog0 = new File(mngr.getLocation(DirType.LOGS_DIR), "remoting.log.0"); + Assert.assertFalse("Log settings have been passed from the config file, the default log should not exist: " + defaultLog0, + defaultLog0.exists()); + + // Assert that logs have been written to the specified custom destination + assertFileLogsExist(customLogDir, "mylog.log", 0); + File log0 = new File(customLogDir, "mylog.log.0"); + try (FileInputStream istr = new FileInputStream(log0)) { + String contents = IOUtils.toString(istr); + assertThat("Log file " + log0 + " should contain the probe message", contents, containsString(message)); + } + } + + private void assertFileLogsExist(File logsDir, String prefix, int logFilesNumber) { + for (int i=0; i